agentchannel 0.7.24 → 0.7.26
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/package.json +1 -1
- package/ui/app.js +221 -26
- package/ui/index.html +7 -0
package/package.json
CHANGED
package/ui/app.js
CHANGED
|
@@ -38,6 +38,9 @@ var collapsedGroups = { "AgentChannel": true };
|
|
|
38
38
|
var onlineMembers = {}; // channel -> Set of names
|
|
39
39
|
var channelMetas = {}; // channel name -> meta object
|
|
40
40
|
|
|
41
|
+
var dmChannels = {}; // theirFingerprint -> {key, hash, name, theirFp}
|
|
42
|
+
var dmNames = {}; // theirFingerprint -> display name
|
|
43
|
+
|
|
41
44
|
var encoder = new TextEncoder();
|
|
42
45
|
var decoder = new TextDecoder();
|
|
43
46
|
|
|
@@ -87,6 +90,22 @@ async function hashSubWeb(channelKey, subName) {
|
|
|
87
90
|
return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
async function deriveDmKeyWeb(fpA, fpB) {
|
|
94
|
+
var sorted = [fpA, fpB].sort();
|
|
95
|
+
var ikm = sorted[0] + sorted[1];
|
|
96
|
+
var prk = await hkdfExtract(ikm);
|
|
97
|
+
var keyBytes = await hkdfExpand(prk, "acp1:dm", 32);
|
|
98
|
+
return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function hashDmWeb(fpA, fpB) {
|
|
102
|
+
var sorted = [fpA, fpB].sort();
|
|
103
|
+
var ikm = sorted[0] + sorted[1];
|
|
104
|
+
var prk = await hkdfExtract(ikm);
|
|
105
|
+
var topicBytes = await hkdfExpand(prk, "acp1:topic:dm", 16);
|
|
106
|
+
return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
async function decryptPayload(payload, key) {
|
|
91
110
|
var p = JSON.parse(payload);
|
|
92
111
|
var iv = Uint8Array.from(atob(p.iv), function(c) { return c.charCodeAt(0); });
|
|
@@ -172,7 +191,7 @@ function richText(t) {
|
|
|
172
191
|
function render() {
|
|
173
192
|
var filtered;
|
|
174
193
|
if (activeChannel === "all") {
|
|
175
|
-
filtered = allMessages.
|
|
194
|
+
filtered = allMessages.filter(function(m) { return !m.channel || !m.channel.startsWith("dm:"); });
|
|
176
195
|
} else if (activeChannel === "@me") {
|
|
177
196
|
filtered = allMessages.filter(function(m) {
|
|
178
197
|
return m.content && CONFIG.name && m.content.indexOf("@" + CONFIG.name) !== -1;
|
|
@@ -207,6 +226,7 @@ function render() {
|
|
|
207
226
|
|
|
208
227
|
var html = "";
|
|
209
228
|
var lastSender = null;
|
|
229
|
+
var lastTimestamp = 0;
|
|
210
230
|
var lastChannel = null;
|
|
211
231
|
|
|
212
232
|
for (var i = 0; i < filtered.length; i++) {
|
|
@@ -230,28 +250,27 @@ function render() {
|
|
|
230
250
|
|
|
231
251
|
var time = new Date(msg.timestamp).toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"});
|
|
232
252
|
var color = getColor(msg.sender);
|
|
233
|
-
|
|
253
|
+
// Don't group — each message shows sender + time independently
|
|
254
|
+
// But reduce spacing if same sender within 5 minutes
|
|
255
|
+
var isCompact = lastSender === msg.sender && lastChannel === msg.channel && (msg.timestamp - lastTimestamp < 300000);
|
|
234
256
|
var isMention = msg.content && msg.content.indexOf('@' + CONFIG.name) !== -1;
|
|
235
257
|
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
html += '<span class="conversation__channel">' + mlabel + '</span>';
|
|
245
|
-
}
|
|
246
|
-
html += '<span class="conversation__time">' + time + '</span>';
|
|
247
|
-
html += '</div>';
|
|
248
|
-
html += '<button class="msg-copy" onclick="window.copyMsg(this)" data-msg="' + esc(msg.content).replace(/"/g, '"') + '" title="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>';
|
|
258
|
+
if (lastSender !== null) html += '</div>'; // close previous
|
|
259
|
+
html += '<div class="conversation" style="' + (isCompact ? 'margin-top:4px' : 'margin-top:16px') + (isMention ? ';background:var(--mention-bg);border-left:3px solid var(--mention-text);padding-left:12px;margin-left:-15px;border-radius:4px' : '') + '">';
|
|
260
|
+
html += '<div class="conversation__label">';
|
|
261
|
+
var msgFp = msg.senderKey ? '(' + msg.senderKey.slice(0, 4) + ')' : '';
|
|
262
|
+
html += '<span class="conversation__sender">' + esc(msg.sender) + '<span style="color:var(--text-muted);font-weight:400;font-size:0.65rem;margin-left:2px">' + msgFp + '</span></span>';
|
|
263
|
+
if (activeChannel === "@me") {
|
|
264
|
+
var mlabel = msg.subchannel ? '#' + esc(msg.channel) + ' ##' + esc(msg.subchannel) : '#' + esc(msg.channel);
|
|
265
|
+
html += '<span class="conversation__channel">' + mlabel + '</span>';
|
|
249
266
|
}
|
|
250
|
-
|
|
251
|
-
html += '
|
|
267
|
+
html += '<span class="conversation__time">' + time + '</span>';
|
|
268
|
+
html += '</div>';
|
|
269
|
+
html += '<div class="conversation__text">' + richText(msg.content) + '</div>';
|
|
252
270
|
|
|
253
271
|
lastSender = msg.sender;
|
|
254
272
|
lastChannel = msg.channel;
|
|
273
|
+
lastTimestamp = msg.timestamp;
|
|
255
274
|
}
|
|
256
275
|
if (lastSender !== null) html += '</div>'; // close last conversation
|
|
257
276
|
|
|
@@ -290,10 +309,10 @@ function renderSidebar() {
|
|
|
290
309
|
if (mentionCount > 0 || activeChannel === "@me") {
|
|
291
310
|
var meDiv = document.createElement("div");
|
|
292
311
|
meDiv.className = "sidebar__channel" + (activeChannel === "@me" ? " active" : "");
|
|
293
|
-
meDiv.innerHTML = '<span style="color:var(--mention-text);margin-right:2px">@</span>
|
|
312
|
+
meDiv.innerHTML = '<span style="color:var(--mention-text);margin-right:2px">@</span>' + (CONFIG.name || 'Me') + (mentionCount ? '<span class="badge" style="background:var(--mention-text);color:#fff;opacity:1">' + mentionCount + '</span>' : "");
|
|
294
313
|
meDiv.onclick = function() {
|
|
295
314
|
activeChannel = "@me";
|
|
296
|
-
headerName.textContent = "@Me";
|
|
315
|
+
headerName.textContent = "@" + (CONFIG.name || "Me");
|
|
297
316
|
headerDesc.textContent = "Messages mentioning you";
|
|
298
317
|
document.title = "AgentChannel — @Me";
|
|
299
318
|
renderSidebar();
|
|
@@ -303,6 +322,39 @@ function renderSidebar() {
|
|
|
303
322
|
el.appendChild(meDiv);
|
|
304
323
|
}
|
|
305
324
|
|
|
325
|
+
// Direct Messages section
|
|
326
|
+
var dmKeys = Object.keys(dmChannels);
|
|
327
|
+
if (dmKeys.length > 0) {
|
|
328
|
+
var dmHeader = document.createElement("div");
|
|
329
|
+
dmHeader.style.cssText = "font-size:0.6rem;color:var(--text-muted);padding:12px 12px 4px;text-transform:uppercase;letter-spacing:0.05em;font-weight:600";
|
|
330
|
+
dmHeader.textContent = "Direct Messages";
|
|
331
|
+
el.appendChild(dmHeader);
|
|
332
|
+
|
|
333
|
+
for (var di = 0; di < dmKeys.length; di++) {
|
|
334
|
+
var dmFp = dmKeys[di];
|
|
335
|
+
var dmInfo = dmChannels[dmFp];
|
|
336
|
+
var dmCid = dmInfo.channelId;
|
|
337
|
+
var dmDisplayName = dmNames[dmFp] || dmFp.slice(0, 8);
|
|
338
|
+
var dmDiv = document.createElement("div");
|
|
339
|
+
dmDiv.className = "sidebar__channel" + (activeChannel === dmCid ? " active" : "");
|
|
340
|
+
var dmCount = unreadCounts[dmCid] || 0;
|
|
341
|
+
dmDiv.innerHTML = '<span style="color:var(--accent);margin-right:2px;opacity:0.7">@</span>' + esc(dmDisplayName) + '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:3px;opacity:0.8">(' + dmFp.slice(0, 4) + ')</span>' + (dmCount ? '<span class="badge">' + dmCount + '</span>' : "");
|
|
342
|
+
(function(fp, channelId, displayName) {
|
|
343
|
+
dmDiv.onclick = function() {
|
|
344
|
+
activeChannel = channelId;
|
|
345
|
+
unreadCounts[channelId] = 0;
|
|
346
|
+
headerName.textContent = "@" + displayName;
|
|
347
|
+
headerDesc.textContent = "DM with " + fp.slice(0, 8);
|
|
348
|
+
document.title = "AgentChannel — DM";
|
|
349
|
+
renderSidebar();
|
|
350
|
+
render();
|
|
351
|
+
if (window.renderMembers) window.renderMembers();
|
|
352
|
+
};
|
|
353
|
+
})(dmFp, dmCid, dmDisplayName);
|
|
354
|
+
el.appendChild(dmDiv);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
306
358
|
// Render each parent + children
|
|
307
359
|
for (var pi = 0; pi < parents.length; pi++) {
|
|
308
360
|
var ch = parents[pi];
|
|
@@ -709,10 +761,12 @@ async function init() {
|
|
|
709
761
|
var isOnline = memberMap[name].online;
|
|
710
762
|
var isYou = name === CONFIG.name;
|
|
711
763
|
var memberInfo = Object.values(window.cloudMembers || {}).flat().find(function(m) { return m.name === name; });
|
|
712
|
-
var
|
|
713
|
-
|
|
764
|
+
var fp = memberInfo && memberInfo.fingerprint ? memberInfo.fingerprint : '';
|
|
765
|
+
var fpStr = fp
|
|
766
|
+
? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">(' + fp.slice(0, 4) + ')</span>'
|
|
714
767
|
: '';
|
|
715
|
-
|
|
768
|
+
var dmClick = (!isYou && fp) ? ' onclick="window.openDm(\x27' + fp + '\x27,\x27' + esc(name).replace(/'/g, '') + '\x27)" style="cursor:pointer" title="Open DM"' : '';
|
|
769
|
+
return '<div class="members__item"' + dmClick + '><span class="members__dot" style="background:' + (isOnline ? "#22c55e" : "#666") + '"></span><span class="members__name">' + esc(name) + fpStr + '</span>' + (isYou ? '<span class="members__role">you</span>' : '') + '</div>';
|
|
716
770
|
}).join("");
|
|
717
771
|
|
|
718
772
|
list.innerHTML = html;
|
|
@@ -812,12 +866,17 @@ async function init() {
|
|
|
812
866
|
}
|
|
813
867
|
var total = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
|
|
814
868
|
if (total > 0) document.title = "(" + total + ") AgentChannel";
|
|
815
|
-
var nlabel = ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name;
|
|
869
|
+
var nlabel = ch.isDm ? "DM" : (ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name);
|
|
816
870
|
if (Notification.permission === "granted" && (document.hidden || activeChannel !== chKeyName)) {
|
|
817
871
|
var n = new Notification(nlabel + " @" + msg.sender, {body: msg.content});
|
|
818
872
|
n.onclick = function() {
|
|
819
873
|
window.focus();
|
|
820
|
-
if (ch.
|
|
874
|
+
if (ch.isDm) {
|
|
875
|
+
activeChannel = ch.name;
|
|
876
|
+
unreadCounts[ch.name] = 0;
|
|
877
|
+
renderSidebar();
|
|
878
|
+
render();
|
|
879
|
+
} else if (ch.sub) { window.switchToSub(ch.sub); }
|
|
821
880
|
else { window.switchToChannel(ch.name); }
|
|
822
881
|
};
|
|
823
882
|
}
|
|
@@ -859,6 +918,81 @@ async function init() {
|
|
|
859
918
|
renderMembers();
|
|
860
919
|
};
|
|
861
920
|
|
|
921
|
+
window.openDm = async function(theirFp, theirName) {
|
|
922
|
+
if (!CONFIG.fingerprint || theirFp === CONFIG.fingerprint) return;
|
|
923
|
+
// Derive DM key and hash
|
|
924
|
+
if (!dmChannels[theirFp]) {
|
|
925
|
+
var dmKey = await deriveDmKeyWeb(CONFIG.fingerprint, theirFp);
|
|
926
|
+
var dmHash = await hashDmWeb(CONFIG.fingerprint, theirFp);
|
|
927
|
+
var sorted = [CONFIG.fingerprint, theirFp].sort();
|
|
928
|
+
var dmCid = "dm:" + sorted[0] + ":" + sorted[1];
|
|
929
|
+
dmChannels[theirFp] = {key: dmKey, hash: dmHash, channelId: dmCid, theirFp: theirFp};
|
|
930
|
+
// Register in channels map for MQTT handling
|
|
931
|
+
channels[dmCid] = {key: dmKey, hash: dmHash, name: dmCid, isDm: true, theirFp: theirFp};
|
|
932
|
+
// Subscribe to DM topic
|
|
933
|
+
client.subscribe("ac/1/" + dmHash);
|
|
934
|
+
// Load DM history from cloud
|
|
935
|
+
try {
|
|
936
|
+
var dres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + dmHash + "&since=0&limit=30");
|
|
937
|
+
var drows = await dres.json();
|
|
938
|
+
for (var dri = 0; dri < drows.length; dri++) {
|
|
939
|
+
try {
|
|
940
|
+
var dmsg = JSON.parse(await decryptPayload(drows[dri].ciphertext, dmKey));
|
|
941
|
+
dmsg.channel = dmCid;
|
|
942
|
+
if (dmsg.type !== "channel_meta") {
|
|
943
|
+
// Avoid duplicates
|
|
944
|
+
var isDup = allMessages.some(function(m) { return m.id === dmsg.id; });
|
|
945
|
+
if (!isDup) allMessages.push(dmsg);
|
|
946
|
+
}
|
|
947
|
+
} catch(e) {}
|
|
948
|
+
}
|
|
949
|
+
allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
|
|
950
|
+
} catch(e) {}
|
|
951
|
+
}
|
|
952
|
+
if (theirName) dmNames[theirFp] = theirName;
|
|
953
|
+
var dmCid = dmChannels[theirFp].channelId;
|
|
954
|
+
activeChannel = dmCid;
|
|
955
|
+
unreadCounts[dmCid] = 0;
|
|
956
|
+
headerName.textContent = "@" + (theirName || theirFp.slice(0, 8));
|
|
957
|
+
headerDesc.textContent = "DM with " + theirFp.slice(0, 8);
|
|
958
|
+
document.title = "AgentChannel — DM";
|
|
959
|
+
renderSidebar();
|
|
960
|
+
render();
|
|
961
|
+
renderMembers();
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
// DM send via web UI — encrypt and publish to DM topic
|
|
965
|
+
window.sendDmMessage = async function(theirFp, content) {
|
|
966
|
+
if (!dmChannels[theirFp]) return;
|
|
967
|
+
var dm = dmChannels[theirFp];
|
|
968
|
+
var iv = crypto.getRandomValues(new Uint8Array(12));
|
|
969
|
+
var plaintext = JSON.stringify({
|
|
970
|
+
id: Array.from(crypto.getRandomValues(new Uint8Array(8))).map(function(b){return b.toString(16).padStart(2,"0")}).join(""),
|
|
971
|
+
channel: dm.channelId,
|
|
972
|
+
sender: CONFIG.name,
|
|
973
|
+
content: content,
|
|
974
|
+
timestamp: Date.now(),
|
|
975
|
+
type: "chat",
|
|
976
|
+
senderKey: CONFIG.fingerprint
|
|
977
|
+
});
|
|
978
|
+
var encoded = encoder.encode(plaintext);
|
|
979
|
+
var encrypted = await crypto.subtle.encrypt({name:"AES-GCM", iv:iv}, dm.key, encoded);
|
|
980
|
+
var cipherData = new Uint8Array(encrypted.slice(0, encrypted.byteLength - 16));
|
|
981
|
+
var tag = new Uint8Array(encrypted.slice(encrypted.byteLength - 16));
|
|
982
|
+
var payload = JSON.stringify({
|
|
983
|
+
iv: btoa(String.fromCharCode.apply(null, iv)),
|
|
984
|
+
data: btoa(String.fromCharCode.apply(null, cipherData)),
|
|
985
|
+
tag: btoa(String.fromCharCode.apply(null, tag))
|
|
986
|
+
});
|
|
987
|
+
client.publish("ac/1/" + dm.hash, payload, {qos: 1});
|
|
988
|
+
// Also store to cloud
|
|
989
|
+
fetch("https://api.agentchannel.workers.dev/messages", {
|
|
990
|
+
method: "POST",
|
|
991
|
+
headers: {"Content-Type": "application/json"},
|
|
992
|
+
body: JSON.stringify({channel_hash: dm.hash, id: JSON.parse(plaintext).id, ciphertext: payload, timestamp: Date.now()})
|
|
993
|
+
}).catch(function(){});
|
|
994
|
+
};
|
|
995
|
+
|
|
862
996
|
renderMembers();
|
|
863
997
|
loadCloudMembers();
|
|
864
998
|
}
|
|
@@ -895,7 +1029,7 @@ function openSettings() {
|
|
|
895
1029
|
var overlay = document.createElement('div');
|
|
896
1030
|
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
|
|
897
1031
|
overlay.onclick = function(e) { if (e.target === overlay) document.body.removeChild(overlay); };
|
|
898
|
-
overlay.innerHTML = '<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:24px;width:360px;max-width:90
|
|
1032
|
+
overlay.innerHTML = '<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:24px;width:360px;max-width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.5)">' +
|
|
899
1033
|
'<h3 style="margin-bottom:16px;font-size:1rem;color:var(--text)">Settings</h3>' +
|
|
900
1034
|
'<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:4px">Display Name</label>' +
|
|
901
1035
|
'<input id="settings-name" value="' + (CONFIG.name || '') + '" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;font-size:0.85rem;background:var(--bg-alt);color:var(--text);margin-bottom:16px;outline:none">' +
|
|
@@ -906,9 +1040,14 @@ function openSettings() {
|
|
|
906
1040
|
'</div>' +
|
|
907
1041
|
'<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:4px">Version</label>' +
|
|
908
1042
|
'<div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:20px">v' + (CONFIG.version || '?') + '</div>' +
|
|
1043
|
+
'<div style="border-top:1px solid var(--border);padding-top:16px;margin-bottom:16px;font-size:0.7rem;color:var(--text-muted);line-height:1.6">' +
|
|
1044
|
+
'<div style="margin-bottom:4px">🔒 Messages are end-to-end encrypted (AES-256-GCM)</div>' +
|
|
1045
|
+
'<div style="margin-bottom:4px">🔑 Your private key never leaves this device</div>' +
|
|
1046
|
+
'<div>📎 Fingerprint = your public identity (safe to share)</div>' +
|
|
1047
|
+
'</div>' +
|
|
909
1048
|
'<div style="display:flex;gap:8px;justify-content:flex-end">' +
|
|
910
1049
|
'<button onclick="this.closest(\'div[style*=fixed]\').remove()" style="padding:8px 16px;border:1px solid var(--border);border-radius:6px;background:var(--bg-alt);color:var(--text);cursor:pointer;font-size:0.8rem">Cancel</button>' +
|
|
911
|
-
'<button onclick="saveName()" style="padding:8px 16px;border:none;border-radius:6px;background:#
|
|
1050
|
+
'<button onclick="saveName()" style="padding:8px 16px;border:none;border-radius:6px;background:#1a1a1a;color:#fff;cursor:pointer;font-size:0.8rem;font-weight:600">Save</button>' +
|
|
912
1051
|
'</div></div>';
|
|
913
1052
|
document.body.appendChild(overlay);
|
|
914
1053
|
}
|
|
@@ -938,6 +1077,62 @@ function saveName() {
|
|
|
938
1077
|
}
|
|
939
1078
|
window.saveName = saveName;
|
|
940
1079
|
|
|
1080
|
+
// ── Input: send message + @autocomplete ──────────────────
|
|
1081
|
+
|
|
1082
|
+
function sendMsg() {
|
|
1083
|
+
var input = document.getElementById('msg-input');
|
|
1084
|
+
if (!input || !input.value.trim() || activeChannel === '@me') return;
|
|
1085
|
+
fetch('/api/send', {
|
|
1086
|
+
method: 'POST',
|
|
1087
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1088
|
+
body: JSON.stringify({ channel: activeChannel, message: input.value.trim() })
|
|
1089
|
+
}).then(function() { input.value = ''; }).catch(function(e) { console.error('Send failed:', e); });
|
|
1090
|
+
}
|
|
1091
|
+
window.sendMsg = sendMsg;
|
|
1092
|
+
|
|
1093
|
+
var acSelected = 0;
|
|
1094
|
+
function onInputChange(input) {
|
|
1095
|
+
var val = input.value;
|
|
1096
|
+
var atIdx = val.lastIndexOf('@');
|
|
1097
|
+
var ac = document.getElementById('autocomplete');
|
|
1098
|
+
if (atIdx === -1 || atIdx < val.length - 20) { ac.style.display = 'none'; return; }
|
|
1099
|
+
var query = val.slice(atIdx + 1).toLowerCase();
|
|
1100
|
+
if (query.indexOf(' ') !== -1) { ac.style.display = 'none'; return; }
|
|
1101
|
+
var members = [];
|
|
1102
|
+
var cm = window.cloudMembers || {};
|
|
1103
|
+
for (var k in cm) { for (var i = 0; i < cm[k].length; i++) { var m = cm[k][i]; if (members.indexOf(m.name) === -1) members.push(m.name); } }
|
|
1104
|
+
var filtered = members.filter(function(n) { return n.toLowerCase().indexOf(query) === 0 && n !== CONFIG.name; });
|
|
1105
|
+
if (!filtered.length) { ac.style.display = 'none'; return; }
|
|
1106
|
+
acSelected = 0;
|
|
1107
|
+
ac.style.display = 'block';
|
|
1108
|
+
ac.innerHTML = filtered.slice(0, 6).map(function(n, i) {
|
|
1109
|
+
return '<div style="padding:6px 10px;cursor:pointer;border-radius:4px;font-size:0.8rem;color:var(--text)' + (i === 0 ? ';background:var(--bg-alt)' : '') + '" onmousedown="insertMention(\'' + n + '\')">' + n + '</div>';
|
|
1110
|
+
}).join('');
|
|
1111
|
+
}
|
|
1112
|
+
window.onInputChange = onInputChange;
|
|
1113
|
+
|
|
1114
|
+
function insertMention(name) {
|
|
1115
|
+
var input = document.getElementById('msg-input');
|
|
1116
|
+
var val = input.value;
|
|
1117
|
+
input.value = val.slice(0, val.lastIndexOf('@')) + '@' + name + ' ';
|
|
1118
|
+
input.focus();
|
|
1119
|
+
document.getElementById('autocomplete').style.display = 'none';
|
|
1120
|
+
}
|
|
1121
|
+
window.insertMention = insertMention;
|
|
1122
|
+
|
|
1123
|
+
function onInputKey(e) {
|
|
1124
|
+
if (e.key === 'Enter' && document.getElementById('autocomplete').style.display === 'none') { sendMsg(); return; }
|
|
1125
|
+
var ac = document.getElementById('autocomplete');
|
|
1126
|
+
if (ac.style.display === 'none') return;
|
|
1127
|
+
var items = ac.children;
|
|
1128
|
+
if (e.key === 'ArrowDown') { acSelected = Math.min(acSelected + 1, items.length - 1); e.preventDefault(); }
|
|
1129
|
+
if (e.key === 'ArrowUp') { acSelected = Math.max(acSelected - 1, 0); e.preventDefault(); }
|
|
1130
|
+
if (e.key === 'Tab' || e.key === 'Enter') { if (items[acSelected]) { insertMention(items[acSelected].textContent); e.preventDefault(); } }
|
|
1131
|
+
if (e.key === 'Escape') { ac.style.display = 'none'; }
|
|
1132
|
+
for (var i = 0; i < items.length; i++) items[i].style.background = i === acSelected ? 'var(--bg-alt)' : '';
|
|
1133
|
+
}
|
|
1134
|
+
window.onInputKey = onInputKey;
|
|
1135
|
+
|
|
941
1136
|
// Restore saved theme (default: dark)
|
|
942
1137
|
var savedTheme = localStorage.getItem('ac-theme') || 'dark';
|
|
943
1138
|
document.documentElement.classList.add(savedTheme);
|
package/ui/index.html
CHANGED
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
<div class="empty">Waiting for messages...</div>
|
|
33
33
|
</div>
|
|
34
34
|
</div>
|
|
35
|
+
<div style="position:relative">
|
|
36
|
+
<div id="autocomplete" style="display:none;position:absolute;bottom:100%;left:24px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px;max-height:150px;overflow-y:auto;box-shadow:0 -4px 12px rgba(0,0,0,0.2);z-index:100"></div>
|
|
37
|
+
<div style="padding:8px 24px;border-top:1px solid var(--border);display:flex;gap:8px">
|
|
38
|
+
<input type="text" id="msg-input" placeholder="Type a message... (@name to mention)" style="flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:0.85rem;background:var(--bg);color:var(--text);outline:none" oninput="onInputChange(this)" onkeydown="onInputKey(event)">
|
|
39
|
+
<button onclick="sendMsg()" style="padding:8px 16px;border:none;border-radius:8px;background:#1a1a1a;color:#fff;font-size:0.85rem;cursor:pointer;font-weight:600">Send</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
35
42
|
</div>
|
|
36
43
|
<div class="members" id="members-panel">
|
|
37
44
|
<div class="members__header">Members</div>
|