@decentnetwork/lan 0.1.144 → 0.1.146

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.
Binary file
Binary file
Binary file
Binary file
@@ -76,6 +76,8 @@ export interface IpcHandlers {
76
76
  subscribe?: (emit: (event: Record<string, unknown>) => void) => () => void;
77
77
  /** Offer a local file (by path) to a friend over toxcore file transfer. */
78
78
  fileSend: (userid: string, path: string) => Promise<Record<string, unknown>>;
79
+ /** Delete file/chat messages by id (and their on-disk files). */
80
+ fileDelete: (peer: string, ids: string[]) => Promise<Record<string, unknown>>;
79
81
  /** Mark a conversation read up to `ts` (defaults to now) — clears unread. */
80
82
  chatMarkRead: (userid: string, ts?: number) => Promise<void>;
81
83
  /** Re-read proxy allowlist from config and apply it to the running
@@ -100,7 +102,7 @@ export interface IpcHandlers {
100
102
  selfRestart: () => Promise<Record<string, unknown>>;
101
103
  }
102
104
  export interface IpcRequest {
103
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "chat-mark-read" | "subscribe" | "sign" | "proxy-reload" | "proxy-access" | "self-restart";
105
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "chat-mark-read" | "subscribe" | "sign" | "proxy-reload" | "proxy-access" | "self-restart";
104
106
  address?: string;
105
107
  hello?: string;
106
108
  userid?: string;
@@ -113,6 +115,7 @@ export interface IpcRequest {
113
115
  limit?: number;
114
116
  ts?: number;
115
117
  enabled?: boolean;
118
+ ids?: string[];
116
119
  }
117
120
  export interface IpcResponseOk {
118
121
  ok: true;
@@ -220,6 +220,11 @@ export class IpcServer {
220
220
  throw new Error("path is required");
221
221
  return await this.handlers.fileSend(req.userid, req.path);
222
222
  }
223
+ case "file-delete": {
224
+ if (!req.userid)
225
+ throw new Error("userid is required");
226
+ return await this.handlers.fileDelete(req.userid, req.ids ?? []);
227
+ }
223
228
  case "chat-mark-read": {
224
229
  if (!req.userid)
225
230
  throw new Error("userid is required");
@@ -32,6 +32,7 @@ export interface ChatMessage {
32
32
  sent?: number;
33
33
  durationMs?: number;
34
34
  avgKbps?: number;
35
+ kbps?: number;
35
36
  };
36
37
  }
37
38
  export declare class MessageStore {
@@ -69,7 +70,11 @@ export declare class MessageStore {
69
70
  sent?: number;
70
71
  durationMs?: number;
71
72
  avgKbps?: number;
73
+ kbps?: number;
72
74
  }): boolean;
75
+ /** Remove messages by id. Returns the removed ones so the caller can clean up
76
+ * any on-disk file the chip pointed at. */
77
+ deleteMessages(peer: string, ids: string[]): ChatMessage[];
73
78
  private push;
74
79
  /**
75
80
  * Return history. With no peer, returns every peer's full thread (the legacy
@@ -89,6 +89,24 @@ export class MessageStore {
89
89
  this.scheduleSave();
90
90
  return true;
91
91
  }
92
+ /** Remove messages by id. Returns the removed ones so the caller can clean up
93
+ * any on-disk file the chip pointed at. */
94
+ deleteMessages(peer, ids) {
95
+ const arr = this.byPeer.get(peer);
96
+ if (!arr || !ids.length)
97
+ return [];
98
+ const want = new Set(ids);
99
+ const removed = [];
100
+ for (let i = arr.length - 1; i >= 0; i--) {
101
+ if (want.has(arr[i].id)) {
102
+ removed.push(arr[i]);
103
+ arr.splice(i, 1);
104
+ }
105
+ }
106
+ if (removed.length)
107
+ this.scheduleSave();
108
+ return removed;
109
+ }
92
110
  push(peer, msg) {
93
111
  let arr = this.byPeer.get(peer);
94
112
  if (!arr) {
@@ -470,6 +470,29 @@ export class DaemonServer {
470
470
  this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
471
471
  return { fileId, name, size: data.length };
472
472
  },
473
+ fileDelete: async (userid, ids) => {
474
+ const fs = await import("fs/promises");
475
+ const removed = this.messageStore?.deleteMessages(userid, ids) ?? [];
476
+ const dir = resolve(this.configDir, "downloads");
477
+ let files = 0;
478
+ for (const m of removed) {
479
+ if (!m.file)
480
+ continue;
481
+ // Drop the tracking entry for any still-active send so its later
482
+ // progress events don't try to patch a now-deleted chip.
483
+ for (const [fid, v] of this.activeSends)
484
+ if (v.msgId === m.id)
485
+ this.activeSends.delete(fid);
486
+ try {
487
+ await fs.unlink(resolve(dir, sanitizeFileName(m.file.name)));
488
+ files++;
489
+ }
490
+ catch { /* not on disk (e.g. incoming still in .partial) */ }
491
+ }
492
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
493
+ this.logger.info(`Deleted ${removed.length} message(s), ${files} file(s) for ${userid.slice(0, 8)}`);
494
+ return { deleted: removed.length, files };
495
+ },
473
496
  chatMarkRead: async (userid, ts) => {
474
497
  this.friendMeta?.markRead(userid, ts);
475
498
  },
@@ -786,7 +809,16 @@ export class DaemonServer {
786
809
  const t = this.activeSends.get(p.fileId);
787
810
  if (!t)
788
811
  return;
789
- this.messageStore?.patchFile(t.peer, t.msgId, { sent: p.received });
812
+ // Live speed = Δbytes / Δtime between progress events, lightly smoothed so
813
+ // the UI shows a steady KB/s next to the percentage instead of a jumpy one.
814
+ const now = Date.now();
815
+ if (t.lastSent !== undefined && t.lastMs !== undefined && now > t.lastMs && p.received >= t.lastSent) {
816
+ const kbps = (p.received - t.lastSent) / 1024 / ((now - t.lastMs) / 1000);
817
+ t.emaKbps = t.emaKbps === undefined ? kbps : 0.6 * t.emaKbps + 0.4 * kbps;
818
+ }
819
+ t.lastSent = p.received;
820
+ t.lastMs = now;
821
+ this.messageStore?.patchFile(t.peer, t.msgId, { sent: p.received, kbps: t.emaKbps !== undefined ? Math.round(t.emaKbps) : undefined });
790
822
  this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
791
823
  });
792
824
  this.peerManager.on("file-cancel", (p) => {
@@ -111,8 +111,9 @@ const SPEED_EWMA_KEEP = 0.6; // new = keep*old + (1-keep)*sample
111
111
  // flaky node never stalls the whole experience.
112
112
  const STALL_IDLE_MS = 6000; // no bytes for this long on an open tunnel = stalled → tear down
113
113
  const STALL_MIN_OPEN_MS = 4000; // only a tunnel open at least this long can count as a stall
114
- const STALL_MIN_BYTES = 16 * 1024; // delivered less than this over that time = stall, not a short ok tunnel
115
- const TRIP_AFTER_STALLS = 2; // consecutive stalls before an exit is tripped out
114
+ const STALL_MIN_BYTES = 16 * 1024; // delivered at least this = a healthy, real segment
115
+ const STALL_DEAD_BYTES = 1024; // relayed LESS than this = the exit is genuinely dead (only THIS counts as a stall); a small-but-real request (playlist/key/keep-alive, 1-16 KB) is NEUTRAL, so it can't flap a healthy exit out of the pool
116
+ const TRIP_AFTER_STALLS = 3; // consecutive DEAD tunnels before an exit is tripped out (hysteresis)
116
117
  const TRIP_COOLDOWN_MS = 30_000; // how long a tripped exit stays excluded before it's retried
117
118
  // Measures peak short-window throughput of a byte stream. Feed it every chunk
118
119
  // length via add(); read peakBps at the end. Peak (not average) shrugs off TCP
@@ -454,10 +455,10 @@ export function startMultiExitRouter(opts) {
454
455
  // re-arms on every I/O, so it only fires on genuine inactivity.
455
456
  let stalledOut = false;
456
457
  up.setTimeout(STALL_IDLE_MS, () => {
457
- if (meter.total >= STALL_MIN_BYTES) {
458
+ if (meter.total >= STALL_DEAD_BYTES) {
458
459
  up.setTimeout(0);
459
460
  return;
460
- } // real transfer, just slow
461
+ } // relayed real data → alive, just idle (keep-alive) — don't tear down
461
462
  stalledOut = true;
462
463
  up.destroy();
463
464
  });
@@ -472,7 +473,12 @@ export function startMultiExitRouter(opts) {
472
473
  // Circuit-breaker classification: a tunnel that stayed open a while but
473
474
  // delivered almost nothing is a stall (dead/flaky exit); a tunnel that
474
475
  // moved real data proves the exit is healthy.
475
- if (stalledOut || (durMs >= STALL_MIN_OPEN_MS && meter.total < STALL_MIN_BYTES)) {
476
+ // Only a tunnel that relayed ~NOTHING (dead exit) counts as a stall. A
477
+ // small-but-real request (1-16 KB: playlist, key, keep-alive) is neutral
478
+ // — that's what stops healthy exits flapping out of the pool on normal
479
+ // CCTV/HLS chatter. A real segment (≥16 KB) proves the exit healthy.
480
+ const relayedNothing = meter.total < STALL_DEAD_BYTES;
481
+ if (relayedNothing && (stalledOut || durMs >= STALL_MIN_OPEN_MS)) {
476
482
  recordStall(exit, stalledOut ? "idle stall" : "no data");
477
483
  }
478
484
  else if (meter.total >= STALL_MIN_BYTES) {
@@ -140,6 +140,13 @@ function dkFileSize(n) {
140
140
  if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
141
141
  return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
142
142
  }
143
+ function dkSpeed(kbps) {
144
+ if (kbps == null || !(kbps >= 0)) return "";
145
+ return kbps >= 1024 ? (kbps / 1024).toFixed(2) + " MB/s" : Math.round(kbps) + " KB/s";
146
+ }
147
+ function dkPct(p) {
148
+ return p == null ? "\u2026" : (p >= 100 ? "100" : p.toFixed(2)) + "%";
149
+ }
143
150
  function dkFileUrl(name) {
144
151
  return "/api/file-download?name=" + encodeURIComponent(name);
145
152
  }
@@ -208,6 +215,7 @@ const dkApi = {
208
215
  remove: (userid) => dkPost("/api/friend-remove", { userid }),
209
216
  alias: (userid, alias) => dkPost("/api/friend-alias", { userid, alias }),
210
217
  markRead: (userid) => dkPost("/api/chat-mark-read", { userid }),
218
+ delFiles: (userid, ids) => dkPost("/api/file-delete", { userid, ids }),
211
219
  setProfile: (name, description) => dkPost("/api/set-profile", { name, description }),
212
220
  sendFile: async (userid, file) => {
213
221
  try {
@@ -276,6 +284,7 @@ function useDaemonData() {
276
284
  const d = await dkGet("/api/chat-history?peer=" + encodeURIComponent(peerId));
277
285
  const arr = d.chats && d.chats[peerId] || [];
278
286
  const msgs = arr.map((m) => ({
287
+ id: m.id,
279
288
  from: m.dir === "out" ? "me" : "them",
280
289
  time: dkClock(m.ts),
281
290
  text: m.text,
@@ -285,7 +294,8 @@ function useDaemonData() {
285
294
  dir: m.dir,
286
295
  media: dkFileMediaKind(m.file.name),
287
296
  status: m.file.status,
288
- pct: m.file.status === "sending" && m.file.size ? Math.min(100, Math.floor((m.file.sent || 0) / m.file.size * 100)) : void 0
297
+ pct: m.file.status === "sending" && m.file.size ? Math.min(100, (m.file.sent || 0) / m.file.size * 100) : void 0,
298
+ kbps: m.file.kbps
289
299
  } : void 0,
290
300
  status: m.dir === "out" ? m.status === "queued" ? "queued" : "read" : void 0
291
301
  }));
@@ -1044,114 +1054,155 @@ const mediaBtnStyle = {
1044
1054
  justifyContent: "center",
1045
1055
  lineHeight: 0
1046
1056
  };
1047
- function Msg({ m, peer, T, onTheater }) {
1057
+ function Msg({ m, peer, T, onTheater, onDelete, selMode, selected, onToggleSel }) {
1048
1058
  const mine = m.from === "me";
1049
- return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: mine ? "flex-end" : "flex-start", alignItems: "flex-end", gap: 8, margin: "3px 0" } }, !mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: m.file && (m.file.media === "image" || m.file.media === "video") ? "min(680px, 88%)" : "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, m.file ? (
1050
- // Inline preview/player. Received media: as soon as it's saved.
1051
- // Sent media: once it's fully delivered ('sent') — the daemon keeps a
1052
- // local copy so it plays on the sender's side too.
1053
- (m.file.media === "image" || m.file.media === "video" || m.file.media === "audio") && (mine ? m.file.status === "sent" : m.file.status !== "sending" && m.file.status !== "failed" && m.file.status !== "queued") ? (
1054
- // Received media → inline preview / player, with name, size, download.
1055
- /* @__PURE__ */ React.createElement("div", { style: {
1056
- display: "flex",
1057
- flexDirection: "column",
1058
- gap: 6,
1059
- maxWidth: m.file.media === "image" || m.file.media === "video" ? "100%" : 300,
1060
- width: m.file.media === "image" || m.file.media === "video" ? "100%" : void 0,
1061
- background: "var(--bub-them)",
1062
- border: "1px solid var(--line)",
1063
- borderRadius: 12,
1064
- padding: 6
1065
- } }, (m.file.media === "image" || m.file.media === "video") && /* @__PURE__ */ React.createElement("div", { "data-media": true, style: { position: "relative", lineHeight: 0 } }, m.file.media === "image" ? /* @__PURE__ */ React.createElement(
1066
- "img",
1067
- {
1068
- src: dkFileUrl(m.file.name),
1069
- alt: m.file.name,
1070
- style: { display: "block", width: "100%", maxHeight: "72vh", borderRadius: 8, objectFit: "contain" }
1071
- }
1072
- ) : /* @__PURE__ */ React.createElement(
1073
- "video",
1074
- {
1075
- src: dkFileUrl(m.file.name),
1076
- controls: true,
1077
- preload: "metadata",
1078
- style: { display: "block", width: "100%", maxHeight: "72vh", borderRadius: 8, objectFit: "contain", background: "#000" }
1079
- }
1080
- ), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", top: 8, right: 8, display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(
1081
- "button",
1082
- {
1083
- onClick: () => onTheater({ kind: m.file.media, url: dkFileUrl(m.file.name), name: m.file.name }),
1084
- title: "theater / \u7F51\u9875\u5168\u5C4F",
1085
- style: mediaBtnStyle
1086
- },
1087
- /* @__PURE__ */ React.createElement(Icon, { name: "theater", size: 16, stroke: 2, color: "#fff" })
1088
- ), /* @__PURE__ */ React.createElement(
1089
- "button",
1090
- {
1091
- onClick: (e) => {
1092
- const w = e.currentTarget.closest("[data-media]");
1093
- const el = w && w.querySelector("video,img");
1094
- if (el && el.requestFullscreen) el.requestFullscreen();
1095
- },
1096
- title: "fullscreen / \u5168\u5C4F",
1097
- style: mediaBtnStyle
1098
- },
1099
- /* @__PURE__ */ React.createElement(Icon, { name: "maximize", size: 16, stroke: 2, color: "#fff" })
1100
- ))), m.file.media === "audio" && /* @__PURE__ */ React.createElement("audio", { src: dkFileUrl(m.file.name), controls: true, style: { width: "100%" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "0 2px" } }, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", flexShrink: 0 } }, m.file.size), /* @__PURE__ */ React.createElement("a", { href: dkFileDownloadUrl(m.file.name), download: m.file.name, title: "download", style: { display: "inline-flex", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 15, stroke: 2, color: "var(--accent)" }))))
1101
- ) : (() => {
1102
- const cardStyle = {
1059
+ return /* @__PURE__ */ React.createElement(
1060
+ "div",
1061
+ {
1062
+ onClick: selMode ? () => onToggleSel(m.id) : void 0,
1063
+ style: {
1103
1064
  display: "flex",
1104
- alignItems: "center",
1105
- gap: 10,
1106
- padding: "10px 12px",
1107
- borderRadius: 10,
1108
- background: mine ? "var(--bub-me)" : "var(--bub-them)",
1109
- border: "1px solid " + (mine ? "transparent" : "var(--line)"),
1110
- minWidth: 200,
1111
- textDecoration: "none",
1112
- cursor: mine ? "default" : "pointer"
1113
- };
1114
- const icon = /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 7, flexShrink: 0, background: mine ? "rgba(255,255,255,0.16)" : "var(--chip)", display: "flex", alignItems: "center", justifyContent: "center", color: mine ? "#fff" : "var(--accent)" } }, /* @__PURE__ */ React.createElement(Icon, { name: m.file.media === "image" ? "image" : m.file.media === "video" ? "video" : m.file.media === "audio" ? "play" : "file", size: 18, stroke: 1.9 }));
1115
- const meta = /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: mine ? "#fff" : "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: mine ? "rgba(255,255,255,0.7)" : "var(--faint)", marginTop: 1 } }, m.file.status === "queued" ? `${m.file.size} \xB7 ${T.queued || "queued"}` : m.file.status === "sending" ? `${m.file.size} \xB7 sending ${m.file.pct != null ? m.file.pct + "%" : "\u2026"}` : m.file.status === "failed" ? `${m.file.size} \xB7 failed` : mine ? `${m.file.size} \xB7 sent` : `${m.file.size} \xB7 ${T.open || "open"}`));
1116
- if (mine) {
1117
- return /* @__PURE__ */ React.createElement("div", { style: cardStyle }, icon, meta, m.file.status === "queued" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)" } }, m.file.pct != null ? m.file.pct + "%" : "\u2026") : m.file.status === "failed" ? /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16, stroke: 2.4, color: "rgba(255,200,190,0.95)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }));
1065
+ justifyContent: mine ? "flex-end" : "flex-start",
1066
+ alignItems: "flex-end",
1067
+ gap: 8,
1068
+ margin: "3px 0",
1069
+ cursor: selMode ? "pointer" : "default",
1070
+ borderRadius: 8,
1071
+ background: selMode && selected ? "rgba(91,140,255,0.12)" : "transparent",
1072
+ padding: selMode ? "2px 4px" : 0
1118
1073
  }
1119
- return /* @__PURE__ */ React.createElement(
1120
- "div",
1121
- {
1122
- style: cardStyle,
1123
- title: "open",
1124
- onClick: () => window.open(dkFileUrl(m.file.name), "_blank", "noopener")
1125
- },
1126
- icon,
1127
- meta,
1128
- /* @__PURE__ */ React.createElement(
1129
- "a",
1074
+ },
1075
+ selMode && /* @__PURE__ */ React.createElement("div", { style: {
1076
+ alignSelf: "center",
1077
+ width: 18,
1078
+ height: 18,
1079
+ borderRadius: 5,
1080
+ flexShrink: 0,
1081
+ border: "1.5px solid " + (selected ? "var(--accent)" : "var(--line)"),
1082
+ background: selected ? "var(--accent)" : "transparent",
1083
+ display: "flex",
1084
+ alignItems: "center",
1085
+ justifyContent: "center"
1086
+ } }, selected && /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 3, color: "#fff" })),
1087
+ !mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }),
1088
+ /* @__PURE__ */ React.createElement("div", { style: { maxWidth: m.file && (m.file.media === "image" || m.file.media === "video") ? "min(680px, 88%)" : "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, m.file ? (
1089
+ // Inline preview/player. Received media: as soon as it's saved.
1090
+ // Sent media: once it's fully delivered ('sent') — the daemon keeps a
1091
+ // local copy so it plays on the sender's side too.
1092
+ (m.file.media === "image" || m.file.media === "video" || m.file.media === "audio") && (mine ? m.file.status === "sent" : m.file.status !== "sending" && m.file.status !== "failed" && m.file.status !== "queued") ? (
1093
+ // Received media → inline preview / player, with name, size, download.
1094
+ /* @__PURE__ */ React.createElement("div", { style: {
1095
+ display: "flex",
1096
+ flexDirection: "column",
1097
+ gap: 6,
1098
+ maxWidth: m.file.media === "image" || m.file.media === "video" ? "100%" : 300,
1099
+ width: m.file.media === "image" || m.file.media === "video" ? "100%" : void 0,
1100
+ background: "var(--bub-them)",
1101
+ border: "1px solid var(--line)",
1102
+ borderRadius: 12,
1103
+ padding: 6
1104
+ } }, (m.file.media === "image" || m.file.media === "video") && /* @__PURE__ */ React.createElement("div", { "data-media": true, style: { position: "relative", lineHeight: 0 } }, m.file.media === "image" ? /* @__PURE__ */ React.createElement(
1105
+ "img",
1106
+ {
1107
+ src: dkFileUrl(m.file.name),
1108
+ alt: m.file.name,
1109
+ style: { display: "block", width: "100%", maxHeight: "72vh", borderRadius: 8, objectFit: "contain" }
1110
+ }
1111
+ ) : /* @__PURE__ */ React.createElement(
1112
+ "video",
1113
+ {
1114
+ src: dkFileUrl(m.file.name),
1115
+ controls: true,
1116
+ preload: "metadata",
1117
+ style: { display: "block", width: "100%", maxHeight: "72vh", borderRadius: 8, objectFit: "contain", background: "#000" }
1118
+ }
1119
+ ), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", top: 8, right: 8, display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(
1120
+ "button",
1130
1121
  {
1131
- href: dkFileDownloadUrl(m.file.name),
1132
- download: m.file.name,
1133
- title: "download",
1134
- onClick: (e) => e.stopPropagation(),
1135
- style: { display: "inline-flex", flexShrink: 0 }
1122
+ onClick: () => onTheater({ kind: m.file.media, url: dkFileUrl(m.file.name), name: m.file.name }),
1123
+ title: "theater / \u7F51\u9875\u5168\u5C4F",
1124
+ style: mediaBtnStyle
1136
1125
  },
1137
- /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: "var(--accent)" })
1138
- )
1139
- );
1140
- })()
1141
- ) : /* @__PURE__ */ React.createElement("div", { style: {
1142
- padding: "8px 12px",
1143
- borderRadius: 12,
1144
- borderBottomRightRadius: mine ? 4 : 12,
1145
- borderBottomLeftRadius: mine ? 12 : 4,
1146
- background: mine ? "var(--bub-me)" : "var(--bub-them)",
1147
- color: mine ? "#fff" : "var(--text)",
1148
- border: "1px solid " + (mine ? "transparent" : "var(--line)"),
1149
- fontFamily: "var(--ui)",
1150
- fontSize: 13.5,
1151
- lineHeight: 1.4,
1152
- letterSpacing: -0.1,
1153
- wordBreak: "break-word"
1154
- } }, m.text), /* @__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), mine && m.status === "queued" ? /* @__PURE__ */ React.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 2 }, title: "waiting for peer \u2014 will send when they're online" }, /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--faint)" }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, T.queued || "queued")) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" }))));
1126
+ /* @__PURE__ */ React.createElement(Icon, { name: "theater", size: 16, stroke: 2, color: "#fff" })
1127
+ ), /* @__PURE__ */ React.createElement(
1128
+ "button",
1129
+ {
1130
+ onClick: (e) => {
1131
+ const w = e.currentTarget.closest("[data-media]");
1132
+ const el = w && w.querySelector("video,img");
1133
+ if (el && el.requestFullscreen) el.requestFullscreen();
1134
+ },
1135
+ title: "fullscreen / \u5168\u5C4F",
1136
+ style: mediaBtnStyle
1137
+ },
1138
+ /* @__PURE__ */ React.createElement(Icon, { name: "maximize", size: 16, stroke: 2, color: "#fff" })
1139
+ ))), m.file.media === "audio" && /* @__PURE__ */ React.createElement("audio", { src: dkFileUrl(m.file.name), controls: true, style: { width: "100%" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "0 2px" } }, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", flexShrink: 0 } }, m.file.size), /* @__PURE__ */ React.createElement("a", { href: dkFileDownloadUrl(m.file.name), download: m.file.name, title: "download", style: { display: "inline-flex", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 15, stroke: 2, color: "var(--accent)" }))))
1140
+ ) : (() => {
1141
+ const cardStyle = {
1142
+ display: "flex",
1143
+ alignItems: "center",
1144
+ gap: 10,
1145
+ padding: "10px 12px",
1146
+ borderRadius: 10,
1147
+ background: mine ? "var(--bub-me)" : "var(--bub-them)",
1148
+ border: "1px solid " + (mine ? "transparent" : "var(--line)"),
1149
+ minWidth: 200,
1150
+ textDecoration: "none",
1151
+ cursor: mine ? "default" : "pointer"
1152
+ };
1153
+ const icon = /* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 7, flexShrink: 0, background: mine ? "rgba(255,255,255,0.16)" : "var(--chip)", display: "flex", alignItems: "center", justifyContent: "center", color: mine ? "#fff" : "var(--accent)" } }, /* @__PURE__ */ React.createElement(Icon, { name: m.file.media === "image" ? "image" : m.file.media === "video" ? "video" : m.file.media === "audio" ? "play" : "file", size: 18, stroke: 1.9 }));
1154
+ const meta = /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: mine ? "#fff" : "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: mine ? "rgba(255,255,255,0.7)" : "var(--faint)", marginTop: 1 } }, m.file.status === "queued" ? `${m.file.size} \xB7 ${T.queued || "queued"}` : m.file.status === "sending" ? `${m.file.size} \xB7 ${dkPct(m.file.pct)}${m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : ""}` : m.file.status === "failed" ? `${m.file.size} \xB7 failed` : mine ? `${m.file.size} \xB7 sent` : `${m.file.size} \xB7 ${T.open || "open"}`));
1155
+ if (mine) {
1156
+ return /* @__PURE__ */ React.createElement("div", { style: cardStyle }, icon, meta, m.file.status === "queued" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)", whiteSpace: "nowrap" } }, dkPct(m.file.pct), m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : "") : m.file.status === "failed" ? /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16, stroke: 2.4, color: "rgba(255,200,190,0.95)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }));
1157
+ }
1158
+ return /* @__PURE__ */ React.createElement(
1159
+ "div",
1160
+ {
1161
+ style: cardStyle,
1162
+ title: "open",
1163
+ onClick: () => window.open(dkFileUrl(m.file.name), "_blank", "noopener")
1164
+ },
1165
+ icon,
1166
+ meta,
1167
+ /* @__PURE__ */ React.createElement(
1168
+ "a",
1169
+ {
1170
+ href: dkFileDownloadUrl(m.file.name),
1171
+ download: m.file.name,
1172
+ title: "download",
1173
+ onClick: (e) => e.stopPropagation(),
1174
+ style: { display: "inline-flex", flexShrink: 0 }
1175
+ },
1176
+ /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: "var(--accent)" })
1177
+ )
1178
+ );
1179
+ })()
1180
+ ) : /* @__PURE__ */ React.createElement("div", { style: {
1181
+ padding: "8px 12px",
1182
+ borderRadius: 12,
1183
+ borderBottomRightRadius: mine ? 4 : 12,
1184
+ borderBottomLeftRadius: mine ? 12 : 4,
1185
+ background: mine ? "var(--bub-me)" : "var(--bub-them)",
1186
+ color: mine ? "#fff" : "var(--text)",
1187
+ border: "1px solid " + (mine ? "transparent" : "var(--line)"),
1188
+ fontFamily: "var(--ui)",
1189
+ fontSize: 13.5,
1190
+ lineHeight: 1.4,
1191
+ letterSpacing: -0.1,
1192
+ wordBreak: "break-word"
1193
+ } }, m.text), /* @__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), !selMode && m.id && /* @__PURE__ */ React.createElement(
1194
+ "button",
1195
+ {
1196
+ title: T.delete || "delete",
1197
+ onClick: (e) => {
1198
+ e.stopPropagation();
1199
+ onDelete(m.id);
1200
+ },
1201
+ style: { background: "none", border: "none", cursor: "pointer", padding: 0, marginLeft: 2, display: "inline-flex", opacity: 0.55 }
1202
+ },
1203
+ /* @__PURE__ */ React.createElement(Icon, { name: "trash", size: 11, stroke: 2, color: "var(--faint)" })
1204
+ ), mine && m.status === "queued" ? /* @__PURE__ */ React.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 2 }, title: "waiting for peer \u2014 will send when they're online" }, /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--faint)" }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, T.queued || "queued")) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" })))
1205
+ );
1155
1206
  }
1156
1207
  function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onAlias, onRemove, onOpenNet }) {
1157
1208
  const scrollRef = React.useRef(null);
@@ -1161,6 +1212,33 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1161
1212
  if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
1162
1213
  }, [peer.id, threadProp]);
1163
1214
  const [menu, setMenu] = React.useState(false);
1215
+ const [selMode, setSelMode] = React.useState(false);
1216
+ const [sel, setSel] = React.useState(() => /* @__PURE__ */ new Set());
1217
+ const [hidden, setHidden] = React.useState(() => /* @__PURE__ */ new Set());
1218
+ React.useEffect(() => {
1219
+ setSelMode(false);
1220
+ setSel(/* @__PURE__ */ new Set());
1221
+ setHidden(/* @__PURE__ */ new Set());
1222
+ }, [peer.id]);
1223
+ const toggleSel = (id) => setSel((s) => {
1224
+ const n = new Set(s);
1225
+ n.has(id) ? n.delete(id) : n.add(id);
1226
+ return n;
1227
+ });
1228
+ const doDelete = (ids) => {
1229
+ ids = ids.filter(Boolean);
1230
+ if (!ids.length) return;
1231
+ if (!window.confirm(ids.length === 1 ? T.delete1 || "Delete this message and its file?" : (T.deleteN || "Delete {n} selected?").replace("{n}", ids.length))) return;
1232
+ setHidden((h) => {
1233
+ const n = new Set(h);
1234
+ ids.forEach((i) => n.add(i));
1235
+ return n;
1236
+ });
1237
+ dkApi.delFiles(peer.userId, ids).catch(() => {
1238
+ });
1239
+ setSel(/* @__PURE__ */ new Set());
1240
+ setSelMode(false);
1241
+ };
1164
1242
  const [draft, setDraft] = React.useState("");
1165
1243
  const [dragOver, setDragOver] = React.useState(false);
1166
1244
  const [sending, setSending] = React.useState(null);
@@ -1215,11 +1293,31 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1215
1293
  /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__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), style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--accent)", display: "inline-flex", alignItems: "center", gap: 4 } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 }), " ", peer.ip))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__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)" } }, /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
1216
1294
  setMenu(false);
1217
1295
  onAlias(peer);
1296
+ } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "check", label: T.selectDelete || "Select & delete", onClick: () => {
1297
+ setMenu(false);
1298
+ setSelMode(true);
1218
1299
  } }), /* @__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: () => {
1219
1300
  setMenu(false);
1220
1301
  onRemove(peer);
1221
1302
  } }))))),
1222
- /* @__PURE__ */ React.createElement("div", { ref: scrollRef, style: { flex: 1, overflow: "auto", padding: "18px 22px" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto" } }, thread.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(Msg, { key: i, m, peer, T, onTheater: setTheater })))),
1303
+ /* @__PURE__ */ React.createElement("div", { ref: scrollRef, style: { flex: 1, overflow: "auto", padding: "18px 22px" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, 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(
1304
+ Msg,
1305
+ {
1306
+ key: m.id || i,
1307
+ m,
1308
+ peer,
1309
+ T,
1310
+ onTheater: setTheater,
1311
+ onDelete: (id) => doDelete([id]),
1312
+ selMode,
1313
+ selected: sel.has(m.id),
1314
+ onToggleSel: toggleSel
1315
+ }
1316
+ )))),
1317
+ selMode && /* @__PURE__ */ React.createElement("div", { style: { flexShrink: 0, borderTop: "1px solid var(--line)", padding: "10px 16px", background: "var(--panel)", display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, sel.size, " ", T.selected || "selected"), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { size: "sm", onClick: () => {
1318
+ setSelMode(false);
1319
+ setSel(/* @__PURE__ */ new Set());
1320
+ } }, T.cancel || "Cancel"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", icon: "trash", onClick: () => doDelete([...sel]) }, T.delete || "Delete")),
1223
1321
  /* @__PURE__ */ React.createElement("div", { style: { flexShrink: 0, borderTop: "1px solid var(--line)", padding: "12px 16px", background: "var(--panel)" } }, sending && /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto 8px", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--faint)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "paperclip", size: 13, stroke: 2, color: "var(--accent)" }), (lang === "zh" ? "\u53D1\u9001\u4E2D: " : "Sending: ") + sending.name), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement(
1224
1322
  "input",
1225
1323
  {
package/dist/ui/server.js CHANGED
@@ -258,6 +258,19 @@ export function startFriendUi(opts) {
258
258
  });
259
259
  return;
260
260
  }
261
+ // Delete file/chat messages by id (removes their on-disk files too).
262
+ if (req.method === "POST" && url === "/api/file-delete") {
263
+ const body = await readBody(req);
264
+ const userid = typeof body.userid === "string" ? body.userid : "";
265
+ const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
266
+ if (!userid || !ids.length) {
267
+ sendJson(res, 400, { ok: false, error: "userid and ids required" });
268
+ return;
269
+ }
270
+ const r = await opts.call({ op: "file-delete", userid, ids });
271
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, ...(r.data ?? {}) } : { ok: false, error: r.error });
272
+ return;
273
+ }
261
274
  // ── File transfer ──────────────────────────────────────────────────
262
275
  // Upload: the browser POSTs raw file bytes with ?userid=&name= in the
263
276
  // query. We stream them to a temp file (no in-memory buffering of large
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.144",
3
+ "version": "0.1.146",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@decentnetwork/dora": "^0.1.11",
82
- "@decentnetwork/peer": "^0.1.77",
82
+ "@decentnetwork/peer": "^0.1.78",
83
83
  "ink": "^5.2.1",
84
84
  "js-yaml": "^4.1.0",
85
85
  "node-forge": "^1.4.0",