@decentnetwork/lan 0.1.145 → 0.1.147
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.
- package/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/carrier/peer-manager.d.ts +2 -0
- package/dist/carrier/peer-manager.js +4 -0
- package/dist/daemon/ipc.d.ts +6 -1
- package/dist/daemon/ipc.js +10 -0
- package/dist/daemon/message-store.d.ts +5 -0
- package/dist/daemon/message-store.js +18 -0
- package/dist/daemon/server.js +49 -1
- package/dist/proxy/multi-exit-router.js +11 -5
- package/dist/ui/desktop/app.js +220 -105
- package/dist/ui/server.js +26 -0
- package/package.json +2 -2
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -64,6 +64,8 @@ export declare class PeerManager extends EventEmitter {
|
|
|
64
64
|
sendFile(userid: string, data: Uint8Array, name: string): string | null;
|
|
65
65
|
/** Accept an incoming file offer. */
|
|
66
66
|
acceptFile(userid: string, fileNumber: number): void;
|
|
67
|
+
/** Cancel an in-flight send by content fileId. Returns true if it was found. */
|
|
68
|
+
cancelSend(userid: string, fileId: string): boolean;
|
|
67
69
|
/**
|
|
68
70
|
* Send an outbound friend request to a Carrier address (NOT a bare
|
|
69
71
|
* userid — sendFriendRequest needs the address form because it
|
|
@@ -125,6 +125,10 @@ export class PeerManager extends EventEmitter {
|
|
|
125
125
|
acceptFile(userid, fileNumber) {
|
|
126
126
|
this.peer?.acceptFile(userid, fileNumber);
|
|
127
127
|
}
|
|
128
|
+
/** Cancel an in-flight send by content fileId. Returns true if it was found. */
|
|
129
|
+
cancelSend(userid, fileId) {
|
|
130
|
+
return this.peer?.cancelFileById(userid, fileId, true) ?? false;
|
|
131
|
+
}
|
|
128
132
|
/**
|
|
129
133
|
* Send an outbound friend request to a Carrier address (NOT a bare
|
|
130
134
|
* userid — sendFriendRequest needs the address form because it
|
package/dist/daemon/ipc.d.ts
CHANGED
|
@@ -76,6 +76,10 @@ 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>>;
|
|
81
|
+
/** Cancel in-flight sends by chat message id. */
|
|
82
|
+
fileCancel: (peer: string, ids: string[]) => Promise<Record<string, unknown>>;
|
|
79
83
|
/** Mark a conversation read up to `ts` (defaults to now) — clears unread. */
|
|
80
84
|
chatMarkRead: (userid: string, ts?: number) => Promise<void>;
|
|
81
85
|
/** Re-read proxy allowlist from config and apply it to the running
|
|
@@ -100,7 +104,7 @@ export interface IpcHandlers {
|
|
|
100
104
|
selfRestart: () => Promise<Record<string, unknown>>;
|
|
101
105
|
}
|
|
102
106
|
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";
|
|
107
|
+
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" | "file-cancel" | "chat-mark-read" | "subscribe" | "sign" | "proxy-reload" | "proxy-access" | "self-restart";
|
|
104
108
|
address?: string;
|
|
105
109
|
hello?: string;
|
|
106
110
|
userid?: string;
|
|
@@ -113,6 +117,7 @@ export interface IpcRequest {
|
|
|
113
117
|
limit?: number;
|
|
114
118
|
ts?: number;
|
|
115
119
|
enabled?: boolean;
|
|
120
|
+
ids?: string[];
|
|
116
121
|
}
|
|
117
122
|
export interface IpcResponseOk {
|
|
118
123
|
ok: true;
|
package/dist/daemon/ipc.js
CHANGED
|
@@ -220,6 +220,16 @@ 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
|
+
}
|
|
228
|
+
case "file-cancel": {
|
|
229
|
+
if (!req.userid)
|
|
230
|
+
throw new Error("userid is required");
|
|
231
|
+
return await this.handlers.fileCancel(req.userid, req.ids ?? []);
|
|
232
|
+
}
|
|
223
233
|
case "chat-mark-read": {
|
|
224
234
|
if (!req.userid)
|
|
225
235
|
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) {
|
package/dist/daemon/server.js
CHANGED
|
@@ -470,6 +470,45 @@ 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
|
+
},
|
|
496
|
+
fileCancel: async (userid, ids) => {
|
|
497
|
+
let n = 0;
|
|
498
|
+
for (const id of ids) {
|
|
499
|
+
for (const [fid, v] of this.activeSends) {
|
|
500
|
+
if (v.peer !== userid || v.msgId !== id)
|
|
501
|
+
continue;
|
|
502
|
+
if (this.peerManager?.cancelSend(userid, fid))
|
|
503
|
+
n++;
|
|
504
|
+
this.activeSends.delete(fid);
|
|
505
|
+
this.messageStore?.patchFile(userid, id, { status: "failed", sent: 0, kbps: undefined });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
|
|
509
|
+
this.logger.info(`Cancelled ${n} send(s) for ${userid.slice(0, 8)}`);
|
|
510
|
+
return { cancelled: n };
|
|
511
|
+
},
|
|
473
512
|
chatMarkRead: async (userid, ts) => {
|
|
474
513
|
this.friendMeta?.markRead(userid, ts);
|
|
475
514
|
},
|
|
@@ -786,7 +825,16 @@ export class DaemonServer {
|
|
|
786
825
|
const t = this.activeSends.get(p.fileId);
|
|
787
826
|
if (!t)
|
|
788
827
|
return;
|
|
789
|
-
|
|
828
|
+
// Live speed = Δbytes / Δtime between progress events, lightly smoothed so
|
|
829
|
+
// the UI shows a steady KB/s next to the percentage instead of a jumpy one.
|
|
830
|
+
const now = Date.now();
|
|
831
|
+
if (t.lastSent !== undefined && t.lastMs !== undefined && now > t.lastMs && p.received >= t.lastSent) {
|
|
832
|
+
const kbps = (p.received - t.lastSent) / 1024 / ((now - t.lastMs) / 1000);
|
|
833
|
+
t.emaKbps = t.emaKbps === undefined ? kbps : 0.6 * t.emaKbps + 0.4 * kbps;
|
|
834
|
+
}
|
|
835
|
+
t.lastSent = p.received;
|
|
836
|
+
t.lastMs = now;
|
|
837
|
+
this.messageStore?.patchFile(t.peer, t.msgId, { sent: p.received, kbps: t.emaKbps !== undefined ? Math.round(t.emaKbps) : undefined });
|
|
790
838
|
this.ipcEvents.emit("event", { type: "chat", userid: t.peer, dir: "out" });
|
|
791
839
|
});
|
|
792
840
|
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
|
|
115
|
-
const
|
|
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 >=
|
|
458
|
+
if (meter.total >= STALL_DEAD_BYTES) {
|
|
458
459
|
up.setTimeout(0);
|
|
459
460
|
return;
|
|
460
|
-
} // real
|
|
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
|
-
|
|
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) {
|
package/dist/ui/desktop/app.js
CHANGED
|
@@ -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,8 @@ 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 }),
|
|
219
|
+
cancelSend: (userid, ids) => dkPost("/api/file-cancel", { userid, ids }),
|
|
211
220
|
setProfile: (name, description) => dkPost("/api/set-profile", { name, description }),
|
|
212
221
|
sendFile: async (userid, file) => {
|
|
213
222
|
try {
|
|
@@ -276,6 +285,7 @@ function useDaemonData() {
|
|
|
276
285
|
const d = await dkGet("/api/chat-history?peer=" + encodeURIComponent(peerId));
|
|
277
286
|
const arr = d.chats && d.chats[peerId] || [];
|
|
278
287
|
const msgs = arr.map((m) => ({
|
|
288
|
+
id: m.id,
|
|
279
289
|
from: m.dir === "out" ? "me" : "them",
|
|
280
290
|
time: dkClock(m.ts),
|
|
281
291
|
text: m.text,
|
|
@@ -285,7 +295,8 @@ function useDaemonData() {
|
|
|
285
295
|
dir: m.dir,
|
|
286
296
|
media: dkFileMediaKind(m.file.name),
|
|
287
297
|
status: m.file.status,
|
|
288
|
-
pct: m.file.status === "sending" && m.file.size ? Math.min(100,
|
|
298
|
+
pct: m.file.status === "sending" && m.file.size ? Math.min(100, (m.file.sent || 0) / m.file.size * 100) : void 0,
|
|
299
|
+
kbps: m.file.kbps
|
|
289
300
|
} : void 0,
|
|
290
301
|
status: m.dir === "out" ? m.status === "queued" ? "queued" : "read" : void 0
|
|
291
302
|
}));
|
|
@@ -1044,114 +1055,166 @@ const mediaBtnStyle = {
|
|
|
1044
1055
|
justifyContent: "center",
|
|
1045
1056
|
lineHeight: 0
|
|
1046
1057
|
};
|
|
1047
|
-
function Msg({ m, peer, T, onTheater }) {
|
|
1058
|
+
function Msg({ m, peer, T, onTheater, onDelete, onCancel, selMode, selected, onToggleSel }) {
|
|
1048
1059
|
const mine = m.from === "me";
|
|
1049
|
-
return /* @__PURE__ */ React.createElement(
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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 = {
|
|
1060
|
+
return /* @__PURE__ */ React.createElement(
|
|
1061
|
+
"div",
|
|
1062
|
+
{
|
|
1063
|
+
onClick: selMode ? () => onToggleSel(m.id) : void 0,
|
|
1064
|
+
style: {
|
|
1103
1065
|
display: "flex",
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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)" }));
|
|
1066
|
+
justifyContent: mine ? "flex-end" : "flex-start",
|
|
1067
|
+
alignItems: "flex-end",
|
|
1068
|
+
gap: 8,
|
|
1069
|
+
margin: "3px 0",
|
|
1070
|
+
cursor: selMode ? "pointer" : "default",
|
|
1071
|
+
borderRadius: 8,
|
|
1072
|
+
background: selMode && selected ? "rgba(91,140,255,0.12)" : "transparent",
|
|
1073
|
+
padding: selMode ? "2px 4px" : 0
|
|
1118
1074
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1075
|
+
},
|
|
1076
|
+
selMode && /* @__PURE__ */ React.createElement("div", { style: {
|
|
1077
|
+
alignSelf: "center",
|
|
1078
|
+
width: 18,
|
|
1079
|
+
height: 18,
|
|
1080
|
+
borderRadius: 5,
|
|
1081
|
+
flexShrink: 0,
|
|
1082
|
+
border: "1.5px solid " + (selected ? "var(--accent)" : "var(--line)"),
|
|
1083
|
+
background: selected ? "var(--accent)" : "transparent",
|
|
1084
|
+
display: "flex",
|
|
1085
|
+
alignItems: "center",
|
|
1086
|
+
justifyContent: "center"
|
|
1087
|
+
} }, selected && /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 3, color: "#fff" })),
|
|
1088
|
+
!mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }),
|
|
1089
|
+
/* @__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 ? (
|
|
1090
|
+
// Inline preview/player. Received media: as soon as it's saved.
|
|
1091
|
+
// Sent media: once it's fully delivered ('sent') — the daemon keeps a
|
|
1092
|
+
// local copy so it plays on the sender's side too.
|
|
1093
|
+
(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") ? (
|
|
1094
|
+
// Received media → inline preview / player, with name, size, download.
|
|
1095
|
+
/* @__PURE__ */ React.createElement("div", { style: {
|
|
1096
|
+
display: "flex",
|
|
1097
|
+
flexDirection: "column",
|
|
1098
|
+
gap: 6,
|
|
1099
|
+
maxWidth: m.file.media === "image" || m.file.media === "video" ? "100%" : 300,
|
|
1100
|
+
width: m.file.media === "image" || m.file.media === "video" ? "100%" : void 0,
|
|
1101
|
+
background: "var(--bub-them)",
|
|
1102
|
+
border: "1px solid var(--line)",
|
|
1103
|
+
borderRadius: 12,
|
|
1104
|
+
padding: 6
|
|
1105
|
+
} }, (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(
|
|
1106
|
+
"img",
|
|
1107
|
+
{
|
|
1108
|
+
src: dkFileUrl(m.file.name),
|
|
1109
|
+
alt: m.file.name,
|
|
1110
|
+
style: { display: "block", width: "100%", maxHeight: "72vh", borderRadius: 8, objectFit: "contain" }
|
|
1111
|
+
}
|
|
1112
|
+
) : /* @__PURE__ */ React.createElement(
|
|
1113
|
+
"video",
|
|
1130
1114
|
{
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1115
|
+
src: dkFileUrl(m.file.name),
|
|
1116
|
+
controls: true,
|
|
1117
|
+
preload: "metadata",
|
|
1118
|
+
style: { display: "block", width: "100%", maxHeight: "72vh", borderRadius: 8, objectFit: "contain", background: "#000" }
|
|
1119
|
+
}
|
|
1120
|
+
), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", top: 8, right: 8, display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(
|
|
1121
|
+
"button",
|
|
1122
|
+
{
|
|
1123
|
+
onClick: () => onTheater({ kind: m.file.media, url: dkFileUrl(m.file.name), name: m.file.name }),
|
|
1124
|
+
title: "theater / \u7F51\u9875\u5168\u5C4F",
|
|
1125
|
+
style: mediaBtnStyle
|
|
1136
1126
|
},
|
|
1137
|
-
/* @__PURE__ */ React.createElement(Icon, { name: "
|
|
1138
|
-
)
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1127
|
+
/* @__PURE__ */ React.createElement(Icon, { name: "theater", size: 16, stroke: 2, color: "#fff" })
|
|
1128
|
+
), /* @__PURE__ */ React.createElement(
|
|
1129
|
+
"button",
|
|
1130
|
+
{
|
|
1131
|
+
onClick: (e) => {
|
|
1132
|
+
const w = e.currentTarget.closest("[data-media]");
|
|
1133
|
+
const el = w && w.querySelector("video,img");
|
|
1134
|
+
if (el && el.requestFullscreen) el.requestFullscreen();
|
|
1135
|
+
},
|
|
1136
|
+
title: "fullscreen / \u5168\u5C4F",
|
|
1137
|
+
style: mediaBtnStyle
|
|
1138
|
+
},
|
|
1139
|
+
/* @__PURE__ */ React.createElement(Icon, { name: "maximize", size: 16, stroke: 2, color: "#fff" })
|
|
1140
|
+
))), 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)" }))))
|
|
1141
|
+
) : (() => {
|
|
1142
|
+
const cardStyle = {
|
|
1143
|
+
display: "flex",
|
|
1144
|
+
alignItems: "center",
|
|
1145
|
+
gap: 10,
|
|
1146
|
+
padding: "10px 12px",
|
|
1147
|
+
borderRadius: 10,
|
|
1148
|
+
background: mine ? "var(--bub-me)" : "var(--bub-them)",
|
|
1149
|
+
border: "1px solid " + (mine ? "transparent" : "var(--line)"),
|
|
1150
|
+
minWidth: 200,
|
|
1151
|
+
textDecoration: "none",
|
|
1152
|
+
cursor: mine ? "default" : "pointer"
|
|
1153
|
+
};
|
|
1154
|
+
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 }));
|
|
1155
|
+
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"}`));
|
|
1156
|
+
if (mine) {
|
|
1157
|
+
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)" }), (m.file.status === "sending" || m.file.status === "queued") && m.id && /* @__PURE__ */ React.createElement(
|
|
1158
|
+
"button",
|
|
1159
|
+
{
|
|
1160
|
+
title: T.cancel || "cancel",
|
|
1161
|
+
onClick: (e) => {
|
|
1162
|
+
e.stopPropagation();
|
|
1163
|
+
onCancel(m.id);
|
|
1164
|
+
},
|
|
1165
|
+
style: { background: "rgba(255,255,255,0.16)", border: "none", borderRadius: 6, cursor: "pointer", padding: 3, marginLeft: 2, display: "inline-flex", flexShrink: 0 }
|
|
1166
|
+
},
|
|
1167
|
+
/* @__PURE__ */ React.createElement(Icon, { name: "x", size: 13, stroke: 2.4, color: "#fff" })
|
|
1168
|
+
));
|
|
1169
|
+
}
|
|
1170
|
+
return /* @__PURE__ */ React.createElement(
|
|
1171
|
+
"div",
|
|
1172
|
+
{
|
|
1173
|
+
style: cardStyle,
|
|
1174
|
+
title: "open",
|
|
1175
|
+
onClick: () => window.open(dkFileUrl(m.file.name), "_blank", "noopener")
|
|
1176
|
+
},
|
|
1177
|
+
icon,
|
|
1178
|
+
meta,
|
|
1179
|
+
/* @__PURE__ */ React.createElement(
|
|
1180
|
+
"a",
|
|
1181
|
+
{
|
|
1182
|
+
href: dkFileDownloadUrl(m.file.name),
|
|
1183
|
+
download: m.file.name,
|
|
1184
|
+
title: "download",
|
|
1185
|
+
onClick: (e) => e.stopPropagation(),
|
|
1186
|
+
style: { display: "inline-flex", flexShrink: 0 }
|
|
1187
|
+
},
|
|
1188
|
+
/* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: "var(--accent)" })
|
|
1189
|
+
)
|
|
1190
|
+
);
|
|
1191
|
+
})()
|
|
1192
|
+
) : /* @__PURE__ */ React.createElement("div", { style: {
|
|
1193
|
+
padding: "8px 12px",
|
|
1194
|
+
borderRadius: 12,
|
|
1195
|
+
borderBottomRightRadius: mine ? 4 : 12,
|
|
1196
|
+
borderBottomLeftRadius: mine ? 12 : 4,
|
|
1197
|
+
background: mine ? "var(--bub-me)" : "var(--bub-them)",
|
|
1198
|
+
color: mine ? "#fff" : "var(--text)",
|
|
1199
|
+
border: "1px solid " + (mine ? "transparent" : "var(--line)"),
|
|
1200
|
+
fontFamily: "var(--ui)",
|
|
1201
|
+
fontSize: 13.5,
|
|
1202
|
+
lineHeight: 1.4,
|
|
1203
|
+
letterSpacing: -0.1,
|
|
1204
|
+
wordBreak: "break-word"
|
|
1205
|
+
} }, 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(
|
|
1206
|
+
"button",
|
|
1207
|
+
{
|
|
1208
|
+
title: T.delete || "delete",
|
|
1209
|
+
onClick: (e) => {
|
|
1210
|
+
e.stopPropagation();
|
|
1211
|
+
onDelete(m.id);
|
|
1212
|
+
},
|
|
1213
|
+
style: { background: "none", border: "none", cursor: "pointer", padding: 0, marginLeft: 2, display: "inline-flex", opacity: 0.55 }
|
|
1214
|
+
},
|
|
1215
|
+
/* @__PURE__ */ React.createElement(Icon, { name: "trash", size: 11, stroke: 2, color: "var(--faint)" })
|
|
1216
|
+
), 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)" })))
|
|
1217
|
+
);
|
|
1155
1218
|
}
|
|
1156
1219
|
function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onAlias, onRemove, onOpenNet }) {
|
|
1157
1220
|
const scrollRef = React.useRef(null);
|
|
@@ -1161,6 +1224,37 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
|
|
|
1161
1224
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
1162
1225
|
}, [peer.id, threadProp]);
|
|
1163
1226
|
const [menu, setMenu] = React.useState(false);
|
|
1227
|
+
const [selMode, setSelMode] = React.useState(false);
|
|
1228
|
+
const [sel, setSel] = React.useState(() => /* @__PURE__ */ new Set());
|
|
1229
|
+
const [hidden, setHidden] = React.useState(() => /* @__PURE__ */ new Set());
|
|
1230
|
+
React.useEffect(() => {
|
|
1231
|
+
setSelMode(false);
|
|
1232
|
+
setSel(/* @__PURE__ */ new Set());
|
|
1233
|
+
setHidden(/* @__PURE__ */ new Set());
|
|
1234
|
+
}, [peer.id]);
|
|
1235
|
+
const toggleSel = (id) => setSel((s) => {
|
|
1236
|
+
const n = new Set(s);
|
|
1237
|
+
n.has(id) ? n.delete(id) : n.add(id);
|
|
1238
|
+
return n;
|
|
1239
|
+
});
|
|
1240
|
+
const doCancel = (id) => {
|
|
1241
|
+
if (id) dkApi.cancelSend(peer.userId, [id]).catch(() => {
|
|
1242
|
+
});
|
|
1243
|
+
};
|
|
1244
|
+
const doDelete = (ids) => {
|
|
1245
|
+
ids = ids.filter(Boolean);
|
|
1246
|
+
if (!ids.length) return;
|
|
1247
|
+
if (!window.confirm(ids.length === 1 ? T.delete1 || "Delete this message and its file?" : (T.deleteN || "Delete {n} selected?").replace("{n}", ids.length))) return;
|
|
1248
|
+
setHidden((h) => {
|
|
1249
|
+
const n = new Set(h);
|
|
1250
|
+
ids.forEach((i) => n.add(i));
|
|
1251
|
+
return n;
|
|
1252
|
+
});
|
|
1253
|
+
dkApi.delFiles(peer.userId, ids).catch(() => {
|
|
1254
|
+
});
|
|
1255
|
+
setSel(/* @__PURE__ */ new Set());
|
|
1256
|
+
setSelMode(false);
|
|
1257
|
+
};
|
|
1164
1258
|
const [draft, setDraft] = React.useState("");
|
|
1165
1259
|
const [dragOver, setDragOver] = React.useState(false);
|
|
1166
1260
|
const [sending, setSending] = React.useState(null);
|
|
@@ -1215,11 +1309,32 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
|
|
|
1215
1309
|
/* @__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
1310
|
setMenu(false);
|
|
1217
1311
|
onAlias(peer);
|
|
1312
|
+
} }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "check", label: T.selectDelete || "Select & delete", onClick: () => {
|
|
1313
|
+
setMenu(false);
|
|
1314
|
+
setSelMode(true);
|
|
1218
1315
|
} }), /* @__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
1316
|
setMenu(false);
|
|
1220
1317
|
onRemove(peer);
|
|
1221
1318
|
} }))))),
|
|
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(
|
|
1319
|
+
/* @__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(
|
|
1320
|
+
Msg,
|
|
1321
|
+
{
|
|
1322
|
+
key: m.id || i,
|
|
1323
|
+
m,
|
|
1324
|
+
peer,
|
|
1325
|
+
T,
|
|
1326
|
+
onTheater: setTheater,
|
|
1327
|
+
onDelete: (id) => doDelete([id]),
|
|
1328
|
+
onCancel: doCancel,
|
|
1329
|
+
selMode,
|
|
1330
|
+
selected: sel.has(m.id),
|
|
1331
|
+
onToggleSel: toggleSel
|
|
1332
|
+
}
|
|
1333
|
+
)))),
|
|
1334
|
+
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: () => {
|
|
1335
|
+
setSelMode(false);
|
|
1336
|
+
setSel(/* @__PURE__ */ new Set());
|
|
1337
|
+
} }, T.cancel || "Cancel"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", icon: "trash", onClick: () => doDelete([...sel]) }, T.delete || "Delete")),
|
|
1223
1338
|
/* @__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
1339
|
"input",
|
|
1225
1340
|
{
|
package/dist/ui/server.js
CHANGED
|
@@ -258,6 +258,32 @@ 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
|
+
}
|
|
274
|
+
// Cancel in-flight sends by chat message id.
|
|
275
|
+
if (req.method === "POST" && url === "/api/file-cancel") {
|
|
276
|
+
const body = await readBody(req);
|
|
277
|
+
const userid = typeof body.userid === "string" ? body.userid : "";
|
|
278
|
+
const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
|
|
279
|
+
if (!userid || !ids.length) {
|
|
280
|
+
sendJson(res, 400, { ok: false, error: "userid and ids required" });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const r = await opts.call({ op: "file-cancel", userid, ids });
|
|
284
|
+
sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, ...(r.data ?? {}) } : { ok: false, error: r.error });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
261
287
|
// ── File transfer ──────────────────────────────────────────────────
|
|
262
288
|
// Upload: the browser POSTs raw file bytes with ?userid=&name= in the
|
|
263
289
|
// 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.
|
|
3
|
+
"version": "0.1.147",
|
|
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.
|
|
82
|
+
"@decentnetwork/peer": "^0.1.79",
|
|
83
83
|
"ink": "^5.2.1",
|
|
84
84
|
"js-yaml": "^4.1.0",
|
|
85
85
|
"node-forge": "^1.4.0",
|