Skip to content

Commit dd7130b

Browse files
committed
Split Messages and Channels into independent panels
Messages now shows direct messages only. Channels gets its own persistent split view — channel list on the left, thread on the right — instead of jumping over to Messages when you open a channel. Each panel remembers its own open conversation independently, so switching pages never loses your place in either. Nav badges for both are now a red circle with a white count, and channel messages now correctly bump the Channels badge instead of always bumping Messages (previously a bug — pushConv only ever called bumpMessageBadge regardless of whether the message was a DM or a channel broadcast).
1 parent ff864ae commit dd7130b

3 files changed

Lines changed: 137 additions & 106 deletions

File tree

‎app.js‎

Lines changed: 101 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const ONLINE_MS = 30 * 60 * 1000;
22

33
const S = {
44
ws:null, connected:false, myNodeNum:null,
5-
nodes:{}, channels:{}, conversations:{}, activeTab:null, unread:{},
5+
nodes:{}, channels:{}, conversations:{}, activeDM:null, activeChannel:null, unread:{},
66
msgIndex:{},
77
filter:'all', startTime:null, selectedNode:null,
88
uptimeTimer:null, wsTimer:null,
@@ -83,8 +83,8 @@ function handle(msg) {
8383
S.everConnected=true;
8484
try { localStorage.setItem('mn_ever_connected','1'); localStorage.setItem('mn_last_ip', msg.ip||''); } catch {}
8585
setConnUI(true, msg.ip);
86-
{ const sb=document.getElementById('sendBtn'); if(sb) sb.disabled=false; }
87-
{ const ci=document.getElementById('compInp'); if(ci) ci.disabled=false; }
86+
['sendBtn','sendBtn2'].forEach(id=>{ const b=document.getElementById(id); if(b) b.disabled=false; });
87+
['compInp','compInp2'].forEach(id=>{ const i=document.getElementById(id); if(i) i.disabled=false; });
8888
startUptime();
8989
hideFirstRun();
9090
updateIdentity();
@@ -100,8 +100,8 @@ function handle(msg) {
100100
case 'disconnected': case 'connectError':
101101
S.connected=false;
102102
setConnUI(false, msg.type==='connectError' ? msg.error : 'Disconnected');
103-
{ const sb=document.getElementById('sendBtn'); if(sb) sb.disabled=true; }
104-
{ const ci=document.getElementById('compInp'); if(ci) ci.disabled=true; }
103+
['sendBtn','sendBtn2'].forEach(id=>{ const b=document.getElementById(id); if(b) b.disabled=true; });
104+
['compInp','compInp2'].forEach(id=>{ const i=document.getElementById(id); if(i) i.disabled=true; });
105105
clearInterval(S.uptimeTimer);
106106
{ const ut=document.getElementById('uptimeText'); if(ut) ut.textContent=''; }
107107
if(msg.type==='connectError') toast(msg.error,'err');
@@ -188,7 +188,8 @@ function handle(msg) {
188188
rxRssi:msg.rxRssi, rxSnr:msg.rxSnr,
189189
hops:(msg.hopStart!=null&&msg.hopLimit!=null)?msg.hopStart-msg.hopLimit:null
190190
});
191-
if(document.hidden||S.activeTab!==tabId){
191+
const activeId=isDM?S.activeDM:S.activeChannel;
192+
if(document.hidden||activeId!==tabId){
192193
try{ new Notification(S.nodes[msg.fromNum]?.name||'Mesh',{body:msg.text}); }catch{}
193194
}
194195
break;
@@ -239,7 +240,8 @@ function handle(msg) {
239240
m.ackTs = new Date();
240241
toast(m.statusLabel,'err');
241242
}
242-
if(S.activeTab===ref.tabId) renderMessages();
243+
if(ref.tabId.startsWith('ch:')){ if(S.activeChannel===ref.tabId) renderChannelThread(); }
244+
else { if(S.activeDM===ref.tabId) renderDMThread(); }
243245
}
244246
}
245247
break;
@@ -283,10 +285,13 @@ function setFilter(f){
283285
}
284286

285287
// ── CONVERSATIONS ─────────────────────────────────────────────────
288+
// Messages (DMs) and Channels are two independent panels — each remembers
289+
// its own open conversation (S.activeDM / S.activeChannel) so switching
290+
// between them, or to another page entirely, never loses your place.
286291
function ensureChTab(ch){
287292
const id='ch:'+ch.index;
288293
if(!S.conversations[id]){S.conversations[id]=[];S.unread[id]=0;}
289-
if(!S.activeTab) setActiveTab(id);
294+
if(!S.activeChannel) setActiveTab(id);
290295
}
291296
function ensureDMTab(num){
292297
const id='dm:'+num;
@@ -298,20 +303,21 @@ function pushConv(tabId,m){
298303
const idx=S.conversations[tabId].length;
299304
S.conversations[tabId].push(m);
300305
if(S.conversations[tabId].length>500) S.conversations[tabId].shift();
301-
if(S.activeTab!==tabId) {
306+
307+
const isCh=tabId.startsWith('ch:');
308+
const activeId=isCh?S.activeChannel:S.activeDM;
309+
if(activeId!==tabId){
302310
S.unread[tabId]=(S.unread[tabId]||0)+1;
303-
// Bump right panel badge
304-
if(!m.sys) bumpMessageBadge();
311+
if(!m.sys) isCh ? bumpChannelBadge() : bumpMessageBadge();
305312
}
306-
renderChatList();
307-
if(S.activeTab===tabId) renderMessages();
308-
// Refresh channels tab if open
309-
if(S.page==='channels') renderChannelsList();
313+
if(isCh){ renderChannelsList(); if(activeId===tabId) renderChannelThread(); }
314+
else { renderChatList(); if(activeId===tabId) renderDMThread(); }
310315
return idx;
311316
}
312317
function setActiveTab(id){
313-
S.activeTab=id; S.unread[id]=0;
314-
renderChatList(); renderMessages(); updateHeader();
318+
S.unread[id]=0;
319+
if(id.startsWith('ch:')){ S.activeChannel=id; renderChannelsList(); renderChannelThread(); }
320+
else { S.activeDM=id; renderChatList(); renderDMThread(); }
315321
}
316322
function openDMWith(num){
317323
ensureDMTab(num); renderChatList(); setActiveTab('dm:'+num);
@@ -323,43 +329,18 @@ function sysMsg(text){
323329
const id=keys.length?keys[0]:'ch:0';
324330
if(!S.conversations[id]) S.conversations[id]=[];
325331
S.conversations[id].push({sys:true,text,ts:new Date()});
326-
if(S.activeTab===id) renderMessages();
332+
if(id.startsWith('ch:')){ if(S.activeChannel===id) renderChannelThread(); }
333+
else { if(S.activeDM===id) renderDMThread(); }
327334
}
328335

329-
// ── CONVERSATION LIST (channels + open direct messages) ───────────
336+
// ── CONVERSATION LIST (direct messages only) ────────────────────────
330337
function renderChatList(){
331338
const el=document.getElementById('chatList');
332339
if(!el) return;
333340
let html='';
334341

335-
// Channels
336-
const chans=Object.values(S.channels).sort((a,b)=>a.index-b.index);
337-
if(chans.length){
338-
html+='<div class="list-hdr">Channels <span class="list-hdr-count">'+chans.length+'</span></div>';
339-
chans.forEach(ch=>{
340-
const id='ch:'+ch.index;
341-
const msgs=(S.conversations[id]||[]).filter(m=>!m.sys);
342-
const last=msgs[msgs.length-1];
343-
const unread=S.unread[id]||0;
344-
const active=S.activeTab===id;
345-
const preview=last?(last.mine?'You: '+esc(last.text):esc(last.fromName||'?')+': '+esc(last.text)):'No messages yet';
346-
const tstr=last?timeStr(last.ts):'';
347-
html+=`<div class="chat-row${active?' active':''}" onclick="setActiveTab('${id}')">
348-
<div class="avatar ch-av"><span class="icon sm">campaign</span></div>
349-
<div class="row-info">
350-
<div class="row-name">${esc(ch.name)}<span style="font-size:10px;color:var(--label-3);font-weight:400">${ch.role==='PRIMARY'?'Primary':''}</span></div>
351-
<div class="row-preview">${last&&last.mine?tickPreview(last.status):''}${preview}</div>
352-
</div>
353-
<div class="row-meta">
354-
<span class="row-time">${tstr}</span>
355-
${unread?`<span class="unread-badge">${unread}</span>`:''}
356-
</div>
357-
</div>`;
358-
});
359-
}
360-
361-
// Direct messages — only conversations that actually exist (the full node
362-
// roster now lives on the Peers page)
342+
// Direct messages only — channels live on their own Channels page now.
343+
// Only conversations that actually exist (the full node roster lives on Peers).
363344
let dms=Object.keys(S.conversations)
364345
.filter(k=>k.startsWith('dm:'))
365346
.map(k=>({ id:k, num:parseInt(k.slice(3)), node:S.nodes[parseInt(k.slice(3))]||null }));
@@ -371,15 +352,14 @@ function renderChatList(){
371352
});
372353

373354
if(dms.length){
374-
html+=`<div class="list-hdr">Direct messages <span class="list-hdr-count">${dms.length}</span></div>`;
375355
dms.forEach(d=>{
376356
const n=d.node||{num:d.num,name:numToId(d.num),shortName:'???'};
377357
const isMe=n.num===S.myNodeNum;
378358
const online=d.node?isOnline(d.node):false;
379359
const msgs=(S.conversations[d.id]||[]).filter(m=>!m.sys);
380360
const last=msgs[msgs.length-1];
381361
const unread=S.unread[d.id]||0;
382-
const active=S.activeTab===d.id;
362+
const active=S.activeDM===d.id;
383363
const sc=sigColor(n.rssi);
384364
const preview=last?(last.mine?'You: '+esc(last.text):esc(last.text))
385365
:(n.lastHeard?'Last heard '+timeAgo(n.lastHeard):'No messages yet');
@@ -404,8 +384,8 @@ function renderChatList(){
404384
if(!html){
405385
html=`<div class="list-empty">
406386
<span class="icon xl">forum</span>
407-
<div class="list-empty-title">No conversations yet</div>
408-
<div class="list-empty-sub">${S.connected?'Channels appear once your node finishes loading. Open Peers to start a direct message.':'Connect to your node to load channels and peers.'}</div>
387+
<div class="list-empty-title">No direct messages yet</div>
388+
<div class="list-empty-sub">${S.connected?'Open Peers to start a direct message.':'Connect to your node, then open Peers to start a direct message.'}</div>
409389
</div>`;
410390
}
411391

@@ -499,54 +479,74 @@ function tickPreview(status){
499479
}
500480

501481
// ── CHAT HEADER ───────────────────────────────────────────────────
502-
function updateHeader(){
482+
// Messages (DMs) and Channels each have their own header/feed/composer DOM,
483+
// so each gets its own render pair — but both build bubbles the same way.
484+
function renderDMHeader(){
503485
const nameEl=document.getElementById('chName');
504486
const subEl=document.getElementById('chSub');
505487
const aviEl=document.getElementById('chAvi');
506488
const destLabel=document.getElementById('composeDestLabel');
489+
const id=S.activeDM;
507490

508-
if(!S.activeTab){
491+
if(!id){
509492
nameEl.textContent='Select a conversation';
510-
subEl.textContent='Pick a channel or peer on the left';
493+
subEl.textContent='Pick a peer on the left';
511494
destLabel.textContent='No conversation selected';
495+
aviEl.innerHTML='<span class="icon" style="color:var(--label-3)">forum</span>';
512496
return;
513497
}
514-
if(S.activeTab.startsWith('ch:')){
515-
const idx=parseInt(S.activeTab.slice(3));
516-
const ch=S.channels[idx];
517-
nameEl.textContent=ch?ch.name:'Channel '+idx;
518-
aviEl.innerHTML='<span class="icon" style="color:var(--blue)">campaign</span>';
519-
destLabel.textContent=(ch?ch.name:'Channel '+idx)+' — all nodes';
520-
const nodeCount=Object.keys(S.nodes).length;
521-
subEl.textContent=(ch?.role==='PRIMARY'?'Primary channel':'Secondary channel')+' · broadcast to '+nodeCount+' known node'+(nodeCount!==1?'s':'');
522-
} else {
523-
const num=parseInt(S.activeTab.slice(3));
524-
const n=S.nodes[num];
525-
nameEl.textContent=n?n.name:numToId(num);
526-
aviEl.innerHTML='<span class="icon" style="color:var(--label-3)">person</span>';
527-
destLabel.textContent=(n?n.name:numToId(num))+' — direct message';
528-
const online=n&&isOnline(n);
529-
subEl.textContent=n?(online?'Online now':'Last seen '+(n.lastHeard?timeAgo(n.lastHeard):'unknown')):numToId(num);
498+
const num=parseInt(id.slice(3));
499+
const n=S.nodes[num];
500+
nameEl.textContent=n?n.name:numToId(num);
501+
aviEl.innerHTML='<span class="icon" style="color:var(--label-3)">person</span>';
502+
destLabel.textContent=(n?n.name:numToId(num))+' — direct message';
503+
const online=n&&isOnline(n);
504+
subEl.textContent=n?(online?'Online now':'Last seen '+(n.lastHeard?timeAgo(n.lastHeard):'unknown')):numToId(num);
505+
}
506+
function renderChannelHeader(){
507+
const nameEl=document.getElementById('chName2');
508+
const subEl=document.getElementById('chSub2');
509+
const aviEl=document.getElementById('chAvi2');
510+
const destLabel=document.getElementById('composeDestLabel2');
511+
const id=S.activeChannel;
512+
513+
if(!id){
514+
nameEl.textContent='Select a channel';
515+
subEl.textContent='Pick a channel on the left';
516+
destLabel.textContent='No channel selected';
517+
aviEl.innerHTML='<span class="icon" style="color:var(--label-3)">hub</span>';
518+
return;
530519
}
520+
const idx=parseInt(id.slice(3));
521+
const ch=S.channels[idx];
522+
nameEl.textContent=ch?ch.name:'Channel '+idx;
523+
aviEl.innerHTML='<span class="icon" style="color:var(--accent)">campaign</span>';
524+
destLabel.textContent=(ch?ch.name:'Channel '+idx)+' — all nodes';
525+
const nodeCount=Object.keys(S.nodes).length;
526+
subEl.textContent=(ch?.role==='PRIMARY'?'Primary channel':'Secondary channel')+' · broadcast to '+nodeCount+' known node'+(nodeCount!==1?'s':'');
531527
}
532528

533529
// ── MESSAGES ──────────────────────────────────────────────────────
534-
function renderMessages(){
535-
const feed=document.getElementById('msgFeed');
536-
const msgs=S.activeTab?(S.conversations[S.activeTab]||[]):[];
530+
function renderDMThread(){
531+
renderDMHeader();
532+
const id=S.activeDM;
533+
let title='Meshnatter', sub='Connect to get started';
534+
if(id){ const num=parseInt(id.slice(3)); title=S.nodes[num]?.name||numToId(num); sub='No messages — say something!'; }
535+
renderThreadInto('msgFeed', id, title, sub);
536+
}
537+
function renderChannelThread(){
538+
renderChannelHeader();
539+
const id=S.activeChannel;
540+
let title='Meshnatter', sub='Select a channel on the left to see its broadcasts';
541+
if(id){ const idx=parseInt(id.slice(3)); title=S.channels[idx]?.name||'Channel'; sub='No messages yet'; }
542+
renderThreadInto('msgFeed2', id, title, sub);
543+
}
544+
function renderThreadInto(feedId, tabId, emptyTitle, emptySub){
545+
const feed=document.getElementById(feedId);
546+
const msgs=tabId?(S.conversations[tabId]||[]):[];
537547

538548
if(!msgs.length){
539-
let title='', sub='';
540-
if(S.activeTab?.startsWith('ch:')){
541-
const idx=parseInt(S.activeTab.slice(3));
542-
title=S.channels[idx]?.name||'Channel'; sub='No messages yet';
543-
} else if(S.activeTab?.startsWith('dm:')){
544-
const num=parseInt(S.activeTab.slice(3));
545-
title=S.nodes[num]?.name||numToId(num); sub='No messages — say something!';
546-
} else {
547-
title='Meshnatter'; sub='Connect to get started';
548-
}
549-
feed.innerHTML=`<div class="msg-empty"><span class="icon xl">forum</span><div class="msg-empty-title">${esc(title)}</div><div class="msg-empty-sub">${esc(sub)}</div></div>`;
549+
feed.innerHTML=`<div class="msg-empty"><span class="icon xl">forum</span><div class="msg-empty-title">${esc(emptyTitle)}</div><div class="msg-empty-sub">${esc(emptySub)}</div></div>`;
550550
return;
551551
}
552552

@@ -582,7 +582,7 @@ function renderMessages(){
582582
maxretransmit:`<span class="icon sm">error_outline</span> Max Retransmission Reached`,
583583
nack: `<span class="icon sm">error_outline</span> `+(m.statusLabel||m.statusError||'Failed'),
584584
};
585-
statusLine=`<div class="msg-status-line ${st}" onclick="showMessageDetails('${S.activeTab}',${msgs.indexOf(m)})" title="Message details">${labels[st]||''}</div>`;
585+
statusLine=`<div class="msg-status-line ${st}" onclick="showMessageDetails('${tabId}',${msgs.indexOf(m)})" title="Message details">${labels[st]||''}</div>`;
586586
}
587587

588588
// Incoming signal info
@@ -640,13 +640,15 @@ function showMessageDetails(tabId, idx){
640640
document.getElementById('mdOv').classList.add('show');
641641
}
642642

643-
function sendMsg(){
644-
const inp=document.getElementById('compInp');
643+
function sendMsg(kind){
644+
const isCh=kind==='ch';
645+
const activeId=isCh?S.activeChannel:S.activeDM;
646+
const inp=document.getElementById(isCh?'compInp2':'compInp');
645647
const text=inp.value.trim();
646-
if(!text||!S.connected||!S.activeTab) return;
648+
if(!text||!S.connected||!activeId) return;
647649
let destinationNum=null, channelIndex=0;
648-
if(S.activeTab.startsWith('ch:')) channelIndex=parseInt(S.activeTab.slice(3));
649-
else destinationNum=parseInt(S.activeTab.slice(3));
650+
if(isCh) channelIndex=parseInt(activeId.slice(3));
651+
else destinationNum=parseInt(activeId.slice(3));
650652
wsSend({type:'sendMessage',text,destinationNum,channelIndex});
651653
inp.value=''; inp.focus();
652654
}
@@ -783,18 +785,18 @@ function toggleConn(){
783785
wsSend({type:'disconnect'});
784786
S.connected=false; S.myNodeNum=null;
785787
S.nodes={}; S.channels={}; S.conversations={}; S.unread={};
786-
S.activeTab=null; S.msgIndex={};
788+
S.activeDM=null; S.activeChannel=null; S.msgIndex={};
787789
Object.values(markers).forEach(m=>map.removeLayer(m));
788790
for(const k in markers) delete markers[k];
789791
setConnUI(false,'Disconnected');
790-
document.getElementById('sendBtn').disabled=true;
791-
document.getElementById('compInp').disabled=true;
792+
['sendBtn','sendBtn2'].forEach(id=>{ document.getElementById(id).disabled=true; });
793+
['compInp','compInp2'].forEach(id=>{ document.getElementById(id).disabled=true; });
792794
clearInterval(S.uptimeTimer);
793795
document.getElementById('uptimeText').textContent='';
794796
S.deviceConfig=null; S.configDirty=false;
795-
renderChatList(); renderStats(); renderMap(); renderMessages(); renderPeers();
797+
renderChatList(); renderStats(); renderMap(); renderDMThread(); renderChannelThread(); renderPeers();
796798
renderChannelsList(); closeNodeDetail();
797-
updateHeader(); updateIdentity();
799+
updateIdentity();
798800
if(S.page==='config') renderConfigPage();
799801
} else {
800802
const ip=document.getElementById('nodeIp').value.trim();
@@ -1308,7 +1310,7 @@ function renderChannelsList() {
13081310
const msgs = (S.conversations[tabId] || []).filter(m => !m.sys);
13091311
const last = msgs[msgs.length - 1];
13101312
const unread = S.unread[tabId] || 0;
1311-
const active = S.activeTab === tabId;
1313+
const active = S.activeChannel === tabId;
13121314
const nodeCount = Object.values(S.nodes).length;
13131315
const onlineCount = Object.values(S.nodes).filter(isOnline).length;
13141316
const lastPreview = last
@@ -1334,8 +1336,7 @@ function renderChannelsList() {
13341336

13351337
function openChannel(tabId) {
13361338
setActiveTab(tabId);
1337-
navigate('messages');
1338-
const inp = document.getElementById('compInp');
1339+
const inp = document.getElementById('compInp2');
13391340
if (inp) inp.focus();
13401341
}
13411342

@@ -1382,11 +1383,11 @@ function init() {
13821383
navigate(page);
13831384

13841385
renderChatList();
1385-
renderMessages();
1386+
renderDMThread();
13861387
renderPeers();
13871388
renderChannelsList();
1389+
renderChannelThread();
13881390
updateIdentity();
1389-
updateHeader();
13901391

13911392
// Guided first-run connect card, or the compact reconnect control for returning users
13921393
if (!S.everConnected) showFirstRun();

0 commit comments

Comments
 (0)