mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-04 23:31:26 +00:00
## Problem Channels page shows 53K 'Unknown' messages — undecryptable GRP_TXT packets with no content. Pure noise. ## Fix - Backend: channels API filters out undecrypted messages by default - `?includeEncrypted=true` param to include them - Frontend: 'Show encrypted' toggle in channels sidebar - Unknown channels grayed out with '(no key)' label - Toggle persists in localStorage Fixes #727 --------- Co-authored-by: you <you@example.com>
This commit is contained in:
@@ -1260,6 +1260,119 @@ func (db *DB) GetChannels(region ...string) ([]map[string]interface{}, error) {
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
// GetEncryptedChannels returns channels where all messages are undecryptable (no key).
|
||||
// These have decoded_json with type "GRP_TXT" and decryptionStatus "no_key".
|
||||
func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{}, error) {
|
||||
regionParam := ""
|
||||
if len(region) > 0 {
|
||||
regionParam = region[0]
|
||||
}
|
||||
regionCodes := normalizeRegionCodes(regionParam)
|
||||
|
||||
var querySQL string
|
||||
args := make([]interface{}, 0, len(regionCodes))
|
||||
|
||||
if len(regionCodes) > 0 {
|
||||
placeholders := make([]string, len(regionCodes))
|
||||
for i, code := range regionCodes {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, code)
|
||||
}
|
||||
regionPlaceholder := strings.Join(placeholders, ",")
|
||||
if db.isV3 {
|
||||
querySQL = fmt.Sprintf(`SELECT DISTINCT t.decoded_json, t.first_seen
|
||||
FROM transmissions t
|
||||
JOIN observations o ON o.transmission_id = t.id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
WHERE t.payload_type = 5
|
||||
AND obs.rowid IS NOT NULL AND UPPER(TRIM(obs.iata)) IN (%s)
|
||||
ORDER BY t.first_seen ASC`, regionPlaceholder)
|
||||
} else {
|
||||
querySQL = fmt.Sprintf(`SELECT DISTINCT t.decoded_json, t.first_seen
|
||||
FROM transmissions t
|
||||
JOIN observations o ON o.transmission_id = t.id
|
||||
WHERE t.payload_type = 5
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM observers obs
|
||||
WHERE obs.id = o.observer_id
|
||||
AND UPPER(TRIM(obs.iata)) IN (%s)
|
||||
)
|
||||
ORDER BY t.first_seen ASC`, regionPlaceholder)
|
||||
}
|
||||
} else {
|
||||
querySQL = `SELECT decoded_json, first_seen FROM transmissions WHERE payload_type = 5 ORDER BY first_seen ASC`
|
||||
}
|
||||
|
||||
rows, err := db.conn.Query(querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type encChanInfo struct {
|
||||
hash string
|
||||
messageCount int
|
||||
lastActivity string
|
||||
}
|
||||
channelMap := map[string]*encChanInfo{}
|
||||
|
||||
for rows.Next() {
|
||||
var dj, fs sql.NullString
|
||||
if err := rows.Scan(&dj, &fs); err != nil { continue }
|
||||
if !dj.Valid {
|
||||
continue
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
if json.Unmarshal([]byte(dj.String), &decoded) != nil {
|
||||
continue
|
||||
}
|
||||
dtype, _ := decoded["type"].(string)
|
||||
// Only include undecryptable GRP_TXT packets (not CHAN)
|
||||
if dtype != "GRP_TXT" {
|
||||
continue
|
||||
}
|
||||
ds, _ := decoded["decryptionStatus"].(string)
|
||||
if ds != "no_key" {
|
||||
continue
|
||||
}
|
||||
// Group by channelHashHex
|
||||
chHash, _ := decoded["channelHashHex"].(string)
|
||||
if chHash == "" {
|
||||
if chNum, ok := decoded["channelHash"].(float64); ok {
|
||||
chHash = fmt.Sprintf("%02X", int(chNum))
|
||||
}
|
||||
}
|
||||
if chHash == "" {
|
||||
chHash = "?"
|
||||
}
|
||||
key := chHash
|
||||
|
||||
ch, exists := channelMap[key]
|
||||
if !exists {
|
||||
ch = &encChanInfo{hash: key, lastActivity: nullStrVal(fs)}
|
||||
channelMap[key] = ch
|
||||
}
|
||||
ch.messageCount++
|
||||
if fs.Valid && fs.String > ch.lastActivity {
|
||||
ch.lastActivity = fs.String
|
||||
}
|
||||
}
|
||||
|
||||
channels := make([]map[string]interface{}, 0, len(channelMap))
|
||||
for _, ch := range channelMap {
|
||||
channels = append(channels, map[string]interface{}{
|
||||
"hash": "enc_" + ch.hash,
|
||||
"name": "Encrypted (0x" + ch.hash + ")",
|
||||
"lastMessage": nil,
|
||||
"lastSender": nil,
|
||||
"messageCount": ch.messageCount,
|
||||
"lastActivity": ch.lastActivity,
|
||||
"encrypted": true,
|
||||
})
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
// GetChannelMessages returns messages for a specific channel.
|
||||
// Uses transmission-level ordering (first_seen) to ensure correct message
|
||||
// sequence even when observations arrive out of order.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// seedEncryptedChannelData adds undecryptable GRP_TXT packets to the test DB.
|
||||
func seedEncryptedChannelData(t *testing.T, db *DB) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := now.Add(-1 * time.Hour).Unix()
|
||||
|
||||
// Two encrypted GRP_TXT packets on channel hash "A1B2"
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('EE01', 'enc_hash_001', ?, 1, 5, '{"type":"GRP_TXT","channelHashHex":"A1B2","decryptionStatus":"no_key"}')`, recent)
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('EE02', 'enc_hash_002', ?, 1, 5, '{"type":"GRP_TXT","channelHashHex":"A1B2","decryptionStatus":"no_key"}')`, recent)
|
||||
|
||||
// Observations for both
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES ((SELECT id FROM transmissions WHERE hash='enc_hash_001'), 1, 10.0, -90, '[]', ?)`, recentEpoch)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES ((SELECT id FROM transmissions WHERE hash='enc_hash_002'), 1, 10.0, -90, '[]', ?)`, recentEpoch)
|
||||
}
|
||||
|
||||
func TestGetEncryptedChannels(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
seedEncryptedChannelData(t, db)
|
||||
|
||||
channels, err := db.GetEncryptedChannels()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(channels) != 1 {
|
||||
t.Fatalf("expected 1 encrypted channel, got %d", len(channels))
|
||||
}
|
||||
ch := channels[0]
|
||||
if ch["hash"] != "enc_A1B2" {
|
||||
t.Errorf("expected hash enc_A1B2, got %v", ch["hash"])
|
||||
}
|
||||
if ch["encrypted"] != true {
|
||||
t.Errorf("expected encrypted=true, got %v", ch["encrypted"])
|
||||
}
|
||||
if ch["messageCount"] != 2 {
|
||||
t.Errorf("expected messageCount=2, got %v", ch["messageCount"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsAPIExcludesEncrypted(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
// Seed encrypted data into the server's DB
|
||||
// setupTestServer uses seedTestData which has no encrypted packets,
|
||||
// so default /api/channels should NOT include encrypted channels.
|
||||
req := httptest.NewRequest("GET", "/api/channels", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
channels := body["channels"].([]interface{})
|
||||
|
||||
for _, ch := range channels {
|
||||
m := ch.(map[string]interface{})
|
||||
if enc, ok := m["encrypted"]; ok && enc == true {
|
||||
t.Errorf("default /api/channels should not include encrypted channels, found: %v", m["hash"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsAPIIncludesEncryptedWithParam(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
// Add encrypted data to the server's DB
|
||||
seedEncryptedChannelData(t, srv.db)
|
||||
// Reload store so in-memory also has the data
|
||||
store := NewPacketStore(srv.db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/channels?includeEncrypted=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
channels := body["channels"].([]interface{})
|
||||
|
||||
foundEncrypted := false
|
||||
for _, ch := range channels {
|
||||
m := ch.(map[string]interface{})
|
||||
if enc, ok := m["encrypted"]; ok && enc == true {
|
||||
foundEncrypted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundEncrypted {
|
||||
t.Error("expected encrypted channels with includeEncrypted=true, found none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMessagesExcludesEncrypted(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
seedEncryptedChannelData(t, srv.db)
|
||||
store := NewPacketStore(srv.db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
|
||||
// Request messages for the encrypted channel — should return empty
|
||||
req := httptest.NewRequest("GET", "/api/channels/enc_A1B2/messages", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
messages, ok := body["messages"].([]interface{})
|
||||
if !ok {
|
||||
// messages might be null/missing — that's fine, means no messages
|
||||
return
|
||||
}
|
||||
// Encrypted messages should not be returned as readable messages
|
||||
for _, msg := range messages {
|
||||
m := msg.(map[string]interface{})
|
||||
if text, ok := m["text"].(string); ok && text != "" {
|
||||
t.Errorf("encrypted channel should not return readable messages, got text: %s", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1656,6 +1656,7 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||||
region := r.URL.Query().Get("region")
|
||||
includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true"
|
||||
// Prefer DB for full history (in-memory store has limited retention)
|
||||
if s.db != nil {
|
||||
channels, err := s.db.GetChannels(region)
|
||||
@@ -1663,11 +1664,22 @@ func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if includeEncrypted {
|
||||
encrypted, err := s.db.GetEncryptedChannels(region)
|
||||
if err != nil {
|
||||
log.Printf("WARN GetEncryptedChannels: %v", err)
|
||||
} else {
|
||||
channels = append(channels, encrypted...)
|
||||
}
|
||||
}
|
||||
writeJSON(w, ChannelListResponse{Channels: channels})
|
||||
return
|
||||
}
|
||||
if s.store != nil {
|
||||
channels := s.store.GetChannels(region)
|
||||
if includeEncrypted {
|
||||
channels = append(channels, s.store.GetEncryptedChannels(region)...)
|
||||
}
|
||||
writeJSON(w, ChannelListResponse{Channels: channels})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3155,6 +3155,84 @@ func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
|
||||
return channels
|
||||
}
|
||||
|
||||
// GetEncryptedChannels returns undecryptable GRP_TXT channels from in-memory packets.
|
||||
func (s *PacketStore) GetEncryptedChannels(region string) []map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
var regionObs map[string]bool
|
||||
if region != "" {
|
||||
regionObs = s.resolveRegionObservers(region)
|
||||
}
|
||||
grpTxts := s.byPayloadType[5]
|
||||
|
||||
type encInfo struct {
|
||||
hash string
|
||||
messageCount int
|
||||
lastActivity string
|
||||
}
|
||||
type grpDec struct {
|
||||
Type string `json:"type"`
|
||||
ChannelHash interface{} `json:"channelHash"`
|
||||
ChannelHashHex string `json:"channelHashHex"`
|
||||
DecryptionStatus string `json:"decryptionStatus"`
|
||||
}
|
||||
channelMap := map[string]*encInfo{}
|
||||
|
||||
for _, tx := range grpTxts {
|
||||
if regionObs != nil {
|
||||
match := false
|
||||
for _, obs := range tx.Observations {
|
||||
if regionObs[obs.ObserverID] {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
var decoded grpDec
|
||||
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) != nil {
|
||||
continue
|
||||
}
|
||||
if decoded.Type != "GRP_TXT" || decoded.DecryptionStatus != "no_key" {
|
||||
continue
|
||||
}
|
||||
chHash := decoded.ChannelHashHex
|
||||
if chHash == "" {
|
||||
if num, ok := decoded.ChannelHash.(float64); ok {
|
||||
chHash = fmt.Sprintf("%02X", int(num))
|
||||
}
|
||||
}
|
||||
if chHash == "" {
|
||||
chHash = "?"
|
||||
}
|
||||
ch := channelMap[chHash]
|
||||
if ch == nil {
|
||||
ch = &encInfo{hash: chHash, lastActivity: tx.FirstSeen}
|
||||
channelMap[chHash] = ch
|
||||
}
|
||||
ch.messageCount++
|
||||
if tx.FirstSeen >= ch.lastActivity {
|
||||
ch.lastActivity = tx.FirstSeen
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
channels := make([]map[string]interface{}, 0, len(channelMap))
|
||||
for _, ch := range channelMap {
|
||||
channels = append(channels, map[string]interface{}{
|
||||
"hash": "enc_" + ch.hash,
|
||||
"name": "Encrypted (0x" + ch.hash + ")",
|
||||
"lastMessage": nil,
|
||||
"lastSender": nil,
|
||||
"messageCount": ch.messageCount,
|
||||
"lastActivity": ch.lastActivity,
|
||||
"encrypted": true,
|
||||
})
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
// GetChannelMessages returns deduplicated messages for a channel from in-memory packets.
|
||||
func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int, region ...string) ([]map[string]interface{}, int) {
|
||||
s.mu.RLock()
|
||||
|
||||
+41
-9
@@ -530,6 +530,9 @@
|
||||
<div class="ch-sidebar" aria-label="Channel list">
|
||||
<div class="ch-sidebar-header">
|
||||
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
|
||||
<label class="ch-encrypted-toggle" title="Show encrypted channels (no key configured)">
|
||||
<input type="checkbox" id="chShowEncrypted"> <span class="ch-toggle-label">🔒 No key</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ch-key-input-wrap" style="padding:4px 8px">
|
||||
<form id="chKeyForm" autocomplete="off">
|
||||
@@ -556,6 +559,17 @@
|
||||
</div>`;
|
||||
|
||||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||||
|
||||
// Encrypted channels toggle (#727)
|
||||
var showEncryptedCb = document.getElementById('chShowEncrypted');
|
||||
var showEncrypted = localStorage.getItem('channels-show-encrypted') === 'true';
|
||||
showEncryptedCb.checked = showEncrypted;
|
||||
showEncryptedCb.addEventListener('change', function () {
|
||||
showEncrypted = showEncryptedCb.checked;
|
||||
localStorage.setItem('channels-show-encrypted', showEncrypted ? 'true' : 'false');
|
||||
loadChannels(true);
|
||||
});
|
||||
|
||||
regionChangeHandler = RegionFilter.onChange(function () {
|
||||
loadChannels(true).then(async function () {
|
||||
if (!selectedHash) return;
|
||||
@@ -576,6 +590,13 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-enable encrypted toggle if deep-linking to an encrypted channel
|
||||
if (routeParam && routeParam.startsWith('enc_') && !showEncrypted) {
|
||||
showEncrypted = true;
|
||||
showEncryptedCb.checked = true;
|
||||
localStorage.setItem('channels-show-encrypted', 'true');
|
||||
}
|
||||
|
||||
loadObserverRegions();
|
||||
loadChannels().then(async function () {
|
||||
// Also load user-added encrypted channels into the sidebar
|
||||
@@ -876,7 +897,11 @@
|
||||
async function loadChannels(silent) {
|
||||
try {
|
||||
const rp = RegionFilter.getRegionParam();
|
||||
const qs = rp ? '?region=' + encodeURIComponent(rp) : '';
|
||||
var showEnc = localStorage.getItem('channels-show-encrypted') === 'true';
|
||||
var params = [];
|
||||
if (rp) params.push('region=' + encodeURIComponent(rp));
|
||||
if (showEnc) params.push('includeEncrypted=true');
|
||||
const qs = params.length ? '?' + params.join('&') : '';
|
||||
const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels });
|
||||
channels = (data.channels || []).map(ch => {
|
||||
ch.lastActivityMs = ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0;
|
||||
@@ -903,22 +928,26 @@
|
||||
});
|
||||
|
||||
el.innerHTML = sorted.map(ch => {
|
||||
const name = ch.name || `Channel ${formatHashHex(ch.hash)}`;
|
||||
const color = getChannelColor(ch.hash);
|
||||
const isEncrypted = ch.encrypted === true;
|
||||
const name = isEncrypted ? (ch.name || 'Unknown') : (ch.name || `Channel ${formatHashHex(ch.hash)}`);
|
||||
const color = isEncrypted ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash);
|
||||
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
|
||||
const preview = ch.lastSender && ch.lastMessage
|
||||
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
|
||||
: `${ch.messageCount} messages`;
|
||||
const preview = isEncrypted
|
||||
? `${ch.messageCount} encrypted messages (no key configured)`
|
||||
: ch.lastSender && ch.lastMessage
|
||||
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
|
||||
: `${ch.messageCount} messages`;
|
||||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||||
const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase();
|
||||
const encClass = isEncrypted ? ' ch-encrypted' : '';
|
||||
const abbr = isEncrypted ? '🔒' : (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase());
|
||||
// Channel color dot for color picker (#674)
|
||||
const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null;
|
||||
const dotStyle = chColor ? ` style="background:${chColor}"` : '';
|
||||
// Left border for assigned color
|
||||
const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : '';
|
||||
|
||||
return `<button class="ch-item${sel}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${escapeHtml(abbr)}</div>
|
||||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}>
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${isEncrypted ? '🔒' : escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
<span class="ch-item-name">${escapeHtml(name)}</span>
|
||||
@@ -1024,6 +1053,9 @@
|
||||
|
||||
async function refreshMessages(opts) {
|
||||
if (!selectedHash) return;
|
||||
// Skip refresh for encrypted channels — no messages to fetch
|
||||
var selCh = channels.find(function (c) { return c.hash === selectedHash; });
|
||||
if (selCh && selCh.encrypted) return;
|
||||
opts = opts || {};
|
||||
const msgEl = document.getElementById('chMessages');
|
||||
if (!msgEl) return;
|
||||
|
||||
@@ -463,6 +463,14 @@ fieldset.mc-section legend.mc-label { padding: 0; }
|
||||
.ch-sidebar-title {
|
||||
display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 700; margin-bottom: 8px;
|
||||
}
|
||||
.ch-encrypted-toggle {
|
||||
display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted);
|
||||
cursor: pointer; user-select: none; margin-bottom: 4px;
|
||||
}
|
||||
.ch-encrypted-toggle input { margin: 0; cursor: pointer; }
|
||||
.ch-toggle-label { white-space: nowrap; }
|
||||
.ch-item.ch-encrypted { opacity: 0.55; }
|
||||
.ch-item.ch-encrypted .ch-item-name { font-style: italic; }
|
||||
.ch-icon { font-size: 20px; }
|
||||
.ch-sidebar-controls { display: flex; align-items: center; gap: 6px; }
|
||||
.ch-region-select {
|
||||
|
||||
@@ -5087,6 +5087,7 @@ console.log('\n=== analytics.js: renderMultiByteAdopters ===');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ===== packets.js: anomaly banner rendering =====
|
||||
console.log('\n=== packets.js: anomaly UI rendering ===');
|
||||
{
|
||||
@@ -5224,6 +5225,47 @@ console.log('\n=== channel-decrypt.js: key derivation, MAC, parsing, storage ===
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Encrypted Channels Toggle Tests (#728) =====
|
||||
{
|
||||
console.log('\n--- Encrypted Channels Toggle (#728) ---');
|
||||
|
||||
test('encrypted toggle reads from localStorage', () => {
|
||||
const store = {};
|
||||
const ls = {
|
||||
getItem: k => store[k] || null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
};
|
||||
// Default: not set → should be false
|
||||
assert.strictEqual(ls.getItem('channels-show-encrypted'), null);
|
||||
const showEncrypted = ls.getItem('channels-show-encrypted') === 'true';
|
||||
assert.strictEqual(showEncrypted, false);
|
||||
|
||||
// Set to true
|
||||
ls.setItem('channels-show-encrypted', 'true');
|
||||
assert.strictEqual(ls.getItem('channels-show-encrypted') === 'true', true);
|
||||
|
||||
// Set to false
|
||||
ls.setItem('channels-show-encrypted', 'false');
|
||||
assert.strictEqual(ls.getItem('channels-show-encrypted') === 'true', false);
|
||||
});
|
||||
|
||||
test('encrypted channels get ch-encrypted CSS class', () => {
|
||||
// Simulate the rendering logic from channels.js
|
||||
const ch = { hash: 'enc_A1B2', name: 'Encrypted (0xA1B2)', encrypted: true, messageCount: 5 };
|
||||
const isEncrypted = ch.encrypted === true;
|
||||
const encClass = isEncrypted ? ' ch-encrypted' : '';
|
||||
const className = 'ch-item' + encClass;
|
||||
assert.ok(className.includes('ch-encrypted'), 'encrypted channel should have ch-encrypted class');
|
||||
|
||||
// Non-encrypted channel should NOT have the class
|
||||
const ch2 = { hash: 'AABB', name: '#general', encrypted: false };
|
||||
const encClass2 = ch2.encrypted === true ? ' ch-encrypted' : '';
|
||||
const className2 = 'ch-item' + encClass2;
|
||||
assert.ok(!className2.includes('ch-encrypted'), 'non-encrypted channel should not have ch-encrypted class');
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
Reference in New Issue
Block a user