channels: make hub switching first-class

This commit is contained in:
DeFiDude 2026-07-31 04:43:00 -06:00
parent 20207eb9e6
commit 28a3c01a80
6 changed files with 749 additions and 23 deletions

View file

@ -129,6 +129,9 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
.expect("channels notification route test");
let share_test = read_source(root.join("dashboard/scripts/test_channels_share.js"))
.expect("channels share test");
let hub_switcher_test =
read_source(root.join("dashboard/scripts/test_channels_hub_switcher.js"))
.expect("channels hub switcher test");
let tauri_events =
read_source(root.join("dashboard/static/js/tauri_events.js")).expect("tauri event bridge");
let tauri_lib = read_source(root.join("src-tauri/src/lib.rs")).expect("tauri lib");
@ -142,6 +145,8 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(!index.contains("Group conversations"));
assert!(!index.contains("channels-sidebar-subtitle"));
assert!(index.contains("Available hubs"));
assert!(index.contains("id=\"channel-hub-switcher-btn\""));
assert!(index.contains("aria-haspopup=\"dialog\""));
assert!(index.contains("id=\"channel-owned-hub\""));
assert!(index.contains("id=\"channel-owned-hub-manage\""));
assert!(!index.contains("channels-refresh-btn"));
@ -162,6 +167,22 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(channels_js.contains("function _channelsBuildHubMark"));
assert!(channels_js.contains("function _channelsHubDistance"));
assert!(channels_js.contains("function channelsConnectToHub"));
assert!(channels_js.contains("function _channelsHubSwitcherModel"));
assert!(channels_js.contains("function _channelsHubConnectMode"));
assert!(channels_js.contains("function channelsOpenHubSwitcher"));
assert!(channels_js.contains("hubSwitcher.addEventListener('click', channelsOpenHubSwitcher)"));
assert!(channels_js.contains("Ratspeak keeps one live hub at a time"));
assert!(channels_js.contains("Saved channels and local history stay on this device"));
assert!(channels_js.contains("list.setAttribute('aria-live', 'polite')"));
assert!(channels_js.contains("list.setAttribute('aria-busy', 'true')"));
assert!(channels_js.contains("titleElement.textContent = 'Switch channel hub'"));
assert!(channels_js.contains("switching: connectMode.kind === 'switch'"));
assert!(channels_js.contains("'Could not switch channel hubs.'"));
assert!(channels_js.contains("openedEpoch === _channelsHistoryEpoch"));
assert!(
channels_js.contains("openedGeneration === (Number(channelsSnapshot.generation) || 0)")
);
assert!(channels_js.contains("dismissHubSwitcher()"));
assert!(channels_js.contains("channelHubOwnDestinationHash"));
assert!(!channels_js.contains("hub.nearby ? 'Nearby' : 'Recent'"));
assert!(channels_js.contains("TextEncoder"));
@ -175,6 +196,9 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(!channels_js.contains("function _channelsInitials"));
assert!(channels_css.contains(".channel-hub-row-mark"));
assert!(channels_css.contains(".channel-hub-row-distance"));
assert!(channels_css.contains(".channel-hub-switcher-btn"));
assert!(channels_css.contains(".channel-hub-switcher-list .channel-hub-row.current"));
assert!(channels_css.contains(".channel-hub-switch-impact"));
assert!(channels_js.contains("function _channelsBuildHubNotice"));
assert!(channels_js.contains("function _channelsBuildHubGreeting"));
assert!(channels_js.contains("function _channelsGroupPresenceEvents"));
@ -400,6 +424,7 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
assert!(channels_css.contains(".channel-share-qr-shell"));
assert!(channels_css.contains(".channel-share-input"));
assert!(share_test.contains("channel share tests passed"));
assert!(hub_switcher_test.contains("channel hub switcher tests passed"));
}
#[test]

View file

@ -11,7 +11,7 @@
injects pixel values via evaluateJavascript; desktop falls back to 0. -->
<title>Ratspeak - Dashboard</title>
<link rel="stylesheet" href="/static/fonts/fonts.css">
<link rel="stylesheet" href="/static/style.css?v=1.0.28">
<link rel="stylesheet" href="/static/style.css?v=1.0.29">
</head>
<body class="checking-setup">
@ -563,11 +563,16 @@
</div>
<div class="channel-hub-strip" id="channel-hub-strip" data-phase="offline">
<span class="channel-live-beacon" aria-hidden="true"></span>
<div class="channel-hub-strip-copy">
<strong id="channel-hub-strip-title">Not connected</strong>
<span id="channel-hub-strip-meta">Choose a hub to begin</span>
</div>
<button class="channel-hub-switcher-btn" id="channel-hub-switcher-btn" type="button" aria-haspopup="dialog" aria-label="Choose a channel hub">
<span class="channel-live-beacon" aria-hidden="true"></span>
<span class="channel-hub-strip-copy">
<strong id="channel-hub-strip-title">Not connected</strong>
<span id="channel-hub-strip-meta">Choose a hub to begin</span>
</span>
<span class="channel-hub-switcher-chevron" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</span>
</button>
<button class="nr-btn nr-btn-ghost channels-icon-btn channel-hub-menu-btn" id="channel-hub-menu-btn" type="button" title="Hub options" aria-label="Channel hub options" hidden>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
@ -1848,7 +1853,7 @@
<script src="/static/js/vendor/jsQR.js"></script>
<script src="/static/js/contact_card.js"></script>
<script src="/static/js/lxmf.js"></script>
<script src="/static/js/channels.js?v=1.0.33"></script>
<script src="/static/js/channels.js?v=1.0.34"></script>
<script src="/static/js/channel_hub.js?v=1.0.29"></script>
<script src="/static/js/propagation.js"></script>
<script src="/static/js/settings.js"></script>

View file

@ -107,7 +107,8 @@ assert.strictEqual(
);
assert.strictEqual(
presentationContext.meta({ destination_hash: 'abcd', saved: true, nearby: true }),
'short:abcd'
'Saved · short:abcd',
'saved identity must remain visible even while a hub is also nearby'
);
assert(indexSource.indexOf('Available hubs') !== -1,

View file

@ -0,0 +1,194 @@
#!/usr/bin/env node
// Deterministic model and source coverage for the one-live-hub switcher.
'use strict';
var assert = require('assert');
var fs = require('fs');
var path = require('path');
var vm = require('vm');
var root = path.join(__dirname, '..');
var channelsSource = fs.readFileSync(
path.join(root, 'static', 'js', 'channels.js'),
'utf8'
);
var indexSource = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
var cssSource = fs.readFileSync(
path.join(root, 'static', 'css', '09-channels.css'),
'utf8'
);
function sourceRange(startName, endName) {
var start = channelsSource.indexOf('function ' + startName);
var end = channelsSource.indexOf('\nfunction ' + endName, start);
assert(start !== -1 && end !== -1, startName + ' source range must exist');
return channelsSource.slice(start, end);
}
var modelSource = sourceRange('_channelsMergedHubs', '_channelsSetText');
var context = {
Object: Object,
String: String,
channelsSavedHubs: [],
channelsDiscoveredHubs: [],
channelsSnapshot: {},
channelHubOwnDestinationHash: function() { return ''; }
};
context._channelsIsConnected = function() {
return context.channelsSnapshot.phase === 'active' ||
context.channelsSnapshot.phase === 'stale';
};
vm.runInNewContext(modelSource, context, {
filename: 'channels-hub-switcher-model.js'
});
var HUB_A = '00112233445566778899aabbccddeeff';
var HUB_B = '11112222333344445555666677778888';
var HUB_C = '22223333444455556666777788889999';
var HUB_D = '3333444455556666777788889999aaaa';
context.channelsSavedHubs = [
{
destination_hash: HUB_A,
label: 'Home relay',
nickname: 'rat',
last_connected: 30
},
{
destination_hash: HUB_B,
label: 'Field relay',
nickname: 'rat',
last_connected: 20
},
{
destination_hash: HUB_C,
label: 'Archive relay',
nickname: 'rat',
last_connected: 10
}
];
context.channelsDiscoveredHubs = [
{
destination_hash: HUB_A,
announced_name: 'Home',
hops: 0,
last_seen: 100
},
{
destination_hash: HUB_B,
announced_name: 'Field',
hops: 1,
last_seen: 90
},
{
destination_hash: HUB_D,
announced_name: 'Ridge',
hops: 2,
last_seen: 80
}
];
context.channelsSnapshot = {
phase: 'active',
selected_hub_destination: HUB_A,
hub: {
destination_hash: HUB_A,
name: 'Home',
hops: 0
}
};
var model = context._channelsHubSwitcherModel();
assert.strictEqual(model.current.destination_hash, HUB_A);
assert.strictEqual(model.current.saved, true);
assert.strictEqual(model.current.nearby, true);
assert.strictEqual(
Array.prototype.map.call(model.nearby, function(hub) {
return hub.destination_hash;
}).join(','),
HUB_B + ',' + HUB_D,
'recently heard choices must preserve hop-first ordering'
);
assert.strictEqual(model.saved.length, 1);
assert.strictEqual(model.saved[0].destination_hash, HUB_C);
assert(
!Array.prototype.some.call(model.nearby, function(hub) {
return hub.destination_hash === HUB_A;
}),
'the selected hub must be pinned once, not repeated as another choice'
);
context.channelsSavedHubs = [];
context.channelsDiscoveredHubs = [];
context.channelsSnapshot = {
phase: 'connecting',
selected_hub_destination: HUB_D,
hub: {
destination_hash: HUB_D,
announced_name: 'Uncached target'
}
};
model = context._channelsHubSwitcherModel();
assert.strictEqual(model.current.destination_hash, HUB_D,
'an unsaved in-flight target must remain visible as the current selection');
assert.strictEqual(model.current.announced_name, 'Uncached target');
function mode(phase, target, selected) {
context.channelsSnapshot = {
phase: phase,
selected_hub_destination: selected || null,
hub: selected ? { destination_hash: selected } : null
};
return context._channelsHubConnectMode(target).kind;
}
assert.strictEqual(mode('active', HUB_A, HUB_A), 'current');
assert.strictEqual(mode('stale', HUB_B, HUB_A), 'switch');
assert.strictEqual(mode('reconnecting', HUB_A, HUB_A), 'recovering');
assert.strictEqual(mode('reconnecting', HUB_B, HUB_A), 'switch',
'a person may deliberately replace a bounded recovery attempt');
assert.strictEqual(mode('resolving', HUB_B, HUB_A), 'pending');
assert.strictEqual(mode('connecting', HUB_B, HUB_A), 'pending');
assert.strictEqual(mode('awaiting_welcome', HUB_B, HUB_A), 'pending');
assert.strictEqual(mode('error', HUB_A, HUB_A), 'connect',
'an ended attempt remains retryable');
assert.strictEqual(mode('offline', HUB_B, null), 'connect');
var stripPosition = indexSource.indexOf('id="channel-hub-switcher-btn"');
var listPosition = indexSource.indexOf('id="channels-list"');
assert(stripPosition !== -1 && stripPosition < listPosition,
'the hub selector must remain visibly above the selected hub channel list');
assert(indexSource.indexOf('aria-haspopup="dialog"') !== -1);
assert(channelsSource.indexOf('function channelsOpenHubSwitcher()') !== -1);
assert(channelsSource.indexOf("hubSwitcher.addEventListener('click', channelsOpenHubSwitcher)") !== -1);
assert(channelsSource.indexOf('Ratspeak keeps one live hub at a time.') !== -1);
assert(channelsSource.indexOf('Saved channels and local history stay on this device.') !== -1);
assert(channelsSource.indexOf("list.setAttribute('aria-live', 'polite')") !== -1);
assert(channelsSource.indexOf("list.setAttribute('aria-busy', 'true')") !== -1);
assert(channelsSource.indexOf("titleElement.textContent = 'Switch channel hub'") !== -1);
assert(channelsSource.indexOf("'Switch hub'") !== -1);
assert(channelsSource.indexOf("'Switching channel hub\\u2026'") !== -1);
assert(channelsSource.indexOf("switching: connectMode.kind === 'switch'") !== -1);
assert(channelsSource.indexOf("'Could not switch channel hubs.'") !== -1);
assert(channelsSource.indexOf("case 'reconnecting': return { label: 'Reconnecting'") !== -1);
assert(channelsSource.indexOf('openedEpoch === _channelsHistoryEpoch') !== -1);
assert(channelsSource.indexOf('openedGeneration === (Number(channelsSnapshot.generation) || 0)') !== -1);
assert(channelsSource.indexOf('dismissHubSwitcher()') !== -1,
'an identity change must close the old identity switcher');
assert(channelsSource.indexOf("RS.invoke('connect_channel_hub'") !== -1);
assert(channelsSource.indexOf('CHANNELS_CONNECTION_BUDGET') === -1,
'the frontend must not invent or raise the runtime connection budget');
var switcherSource = sourceRange('channelsOpenHubSwitcher', 'channelsOpenConnectSheet');
assert(switcherSource.indexOf('localStorage') === -1);
assert(switcherSource.indexOf("RS.invoke('connect_channel_hub'") === -1,
'choosing a hub must open explicit review instead of connecting from the switcher');
assert(switcherSource.indexOf('channelsRefreshAvailableHubs()') !== -1);
assert(switcherSource.indexOf('Scan') === -1,
'the recent announce cache must not be presented as an active network scan');
assert(cssSource.indexOf('.channel-hub-switcher-btn') !== -1);
assert(cssSource.indexOf('.channel-hub-switcher-list .channel-hub-row.current') !== -1);
assert(cssSource.indexOf('.channel-hub-switch-impact') !== -1);
console.log('channel hub switcher tests passed');

View file

@ -102,6 +102,7 @@
.channels-icon-btn:focus-visible,
.channels-text-btn:focus-visible,
.message-mode-option:focus-visible,
.channel-hub-switcher-btn:focus-visible,
.channel-hub-row:focus-visible,
.channel-room-row:focus-visible,
.channels-list-section-action:focus-visible,
@ -113,15 +114,39 @@
.channel-hub-strip {
display: flex;
align-items: center;
gap: var(--space-5);
min-height: 54px;
margin: 0 var(--space-5) var(--space-5);
padding: var(--space-5) var(--space-6);
overflow: hidden;
border: 1px solid var(--border-surface-soft);
border-radius: var(--radius-lg);
background: var(--surface-panel-muted);
}
.channel-hub-switcher-btn {
display: flex;
min-width: 0;
min-height: 52px;
flex: 1;
align-items: center;
gap: var(--space-5);
padding: var(--space-5) var(--space-4) var(--space-5) var(--space-6);
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background var(--transition-fast);
}
.channel-hub-switcher-btn:hover {
background: var(--surface-row-hover);
}
.channel-hub-switcher-btn:focus-visible {
outline-offset: -3px;
}
.channel-hub-strip[data-phase="active"],
.channel-hub-strip[data-phase="stale"] {
border-color: var(--status-online-border);
@ -199,10 +224,23 @@
width: 30px;
height: 30px;
flex-basis: 30px;
margin-right: var(--space-3);
border-color: transparent;
background: transparent;
}
.channel-hub-switcher-chevron {
display: inline-flex;
flex: 0 0 auto;
color: var(--text-muted);
transition: transform var(--transition-fast), color var(--transition-fast);
}
.channel-hub-switcher-btn:hover .channel-hub-switcher-chevron {
color: var(--text-secondary);
transform: translateY(1px);
}
.channel-owned-hub {
padding: 0 var(--space-4) var(--space-4);
border-bottom: 1px solid var(--border-row);
@ -601,6 +639,13 @@
white-space: nowrap;
}
.channel-hub-row-trailing {
display: flex;
flex: 0 0 auto;
align-items: flex-end;
gap: var(--space-2);
}
.channel-row-status {
display: inline-flex;
align-items: center;
@ -623,6 +668,11 @@
color: var(--status-error);
}
.channel-row-status.recovering {
background: var(--status-warning-bg);
color: var(--status-warning);
}
.channel-room-row.has-unread .channel-room-row-title {
font-weight: var(--type-weight-semibold);
}
@ -1750,6 +1800,20 @@
margin-top: 1px;
}
.channel-hub-switch-impact {
padding: var(--space-4) var(--space-5);
border-left: 3px solid var(--status-warning);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
background: var(--surface-panel-muted);
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.45;
}
.channel-hub-switch-impact[hidden] {
display: none;
}
.channel-sheet-field {
display: flex;
flex-direction: column;
@ -1786,6 +1850,42 @@
overflow-y: auto;
}
.channel-hub-switcher-sheet .bottom-sheet-body {
gap: var(--space-5);
}
.channel-hub-switcher-list {
display: flex;
min-height: 96px;
flex-direction: column;
}
.channel-hub-switcher-list .channels-list-section {
margin-top: var(--space-3);
}
.channel-hub-switcher-list .channel-hub-row {
min-height: 58px;
margin-bottom: var(--space-2);
border-color: var(--border-surface-soft);
background: var(--surface-panel-muted);
}
.channel-hub-switcher-list .channel-hub-row:hover:not(:disabled) {
border-color: var(--border-surface-strong);
background: var(--surface-row-hover);
}
.channel-hub-switcher-list .channel-hub-row.current {
border-color: var(--accent-border);
background: var(--accent-bg);
}
.channel-hub-switcher-list .channel-hub-row:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.channel-sheet-hub {
display: flex;
align-items: center;

View file

@ -76,6 +76,7 @@ var _channelsHistoryEpoch = 0;
var _channelsRoomIndexRequestSeq = 0;
var _channelsUnreadRequestSeq = 0;
var _channelsRenderedRoomKey = '';
var _channelsHubSwitcherDismiss = null;
var CHANNEL_PRESENCE_GROUP_WINDOW_MS = 5 * 60 * 1000;
// Brief leave/rejoin churn is one continuous presence when nothing happens between it.
var CHANNEL_PRESENCE_REJOIN_WINDOW_MS = 15 * 1000;
@ -306,7 +307,13 @@ function channelsConnectToHub(hub, options) {
channelsHistorySelection = null;
channelsApplySnapshot(snapshot);
if (typeof showToast === 'function') {
showToast('Connecting to channel hub\u2026', 'toast-blue', 2600);
showToast(
options.switching
? 'Switching channel hub\u2026'
: 'Connecting to channel hub\u2026',
'toast-blue',
2600
);
}
return snapshot;
});
@ -558,6 +565,101 @@ function _channelsMergedHubs() {
});
}
function _channelsSelectedHubDestination() {
return String(
channelsSnapshot.selected_hub_destination ||
(channelsSnapshot.hub && channelsSnapshot.hub.destination_hash) ||
''
).trim().toLowerCase();
}
// One stable navigation model for the measured one-live-hub product budget.
// The selected hub is pinned separately; recently heard hubs and saved-only
// hubs remain visible choices without implying background presence.
function _channelsHubSwitcherModel() {
var selected = _channelsSelectedHubDestination();
var merged = _channelsMergedHubs();
var current = null;
var nearby = [];
var saved = [];
merged.forEach(function(hub) {
if (selected && hub.destination_hash === selected) {
current = Object.assign({}, hub);
} else if (hub.nearby) {
nearby.push(hub);
} else if (hub.saved) {
saved.push(hub);
}
});
if (selected) {
var observed = channelsSnapshot.hub &&
String(channelsSnapshot.hub.destination_hash || '').toLowerCase() === selected
? channelsSnapshot.hub
: null;
current = Object.assign(
{ destination_hash: selected, saved: false, nearby: false },
current || {},
observed || {}
);
current.destination_hash = selected;
}
return {
current: current,
nearby: nearby,
saved: saved
};
}
function _channelsConnectCommandBlocked() {
return channelsSnapshot.phase === 'resolving' ||
channelsSnapshot.phase === 'connecting' ||
channelsSnapshot.phase === 'awaiting_welcome';
}
function _channelsHubConnectMode(destinationHash) {
var destination = String(destinationHash || '').trim().toLowerCase();
var current = _channelsSelectedHubDestination();
var same = !!destination && !!current && destination === current;
if (_channelsConnectCommandBlocked()) {
return {
kind: 'pending',
current_destination: current,
same_destination: same
};
}
if (same && _channelsIsConnected()) {
return {
kind: 'current',
current_destination: current,
same_destination: true
};
}
if (same && channelsSnapshot.phase === 'reconnecting') {
return {
kind: 'recovering',
current_destination: current,
same_destination: true
};
}
if (current && destination && destination !== current &&
(_channelsIsConnected() || channelsSnapshot.phase === 'reconnecting')) {
return {
kind: 'switch',
current_destination: current,
same_destination: false
};
}
return {
kind: 'connect',
current_destination: current,
same_destination: same
};
}
function _channelsSetText(id, value) {
var el = _channelsEl(id);
if (el) el.textContent = value == null ? '' : String(value);
@ -1186,6 +1288,7 @@ function renderChannels() {
function _channelsRenderHubStrip() {
var strip = _channelsEl('channel-hub-strip');
var switcher = _channelsEl('channel-hub-switcher-btn');
var menu = _channelsEl('channel-hub-menu-btn');
if (!strip) return;
@ -1211,6 +1314,16 @@ function _channelsRenderHubStrip() {
}
_channelsSetText('channel-hub-strip-title', title);
_channelsSetText('channel-hub-strip-meta', meta);
if (switcher) {
var currentName = hub ? _channelsHubName(hub) : '';
switcher.setAttribute(
'aria-label',
currentName
? 'Choose a channel hub. Current selection: ' + currentName
: 'Choose a channel hub'
);
switcher.title = currentName ? 'Switch channel hub' : 'Choose a channel hub';
}
}
function _channelsRenderList() {
@ -1413,7 +1526,7 @@ function _channelsHubDistance(hub) {
function _channelsHubMeta(hub) {
var hash = _channelsShortHash(hub && hub.destination_hash);
return hub && hub.saved && !hub.nearby ? 'Saved \u00b7 ' + hash : hash;
return hub && hub.saved ? 'Saved \u00b7 ' + hash : hash;
}
function _channelsBuildHubMark(hub) {
@ -1425,11 +1538,14 @@ function _channelsBuildHubMark(hub) {
return mark;
}
function _channelsBuildHubRow(hub) {
function _channelsBuildHubRow(hub, options) {
options = options || {};
var row = document.createElement('button');
row.type = 'button';
row.className = 'channel-hub-row';
row.className = 'channel-hub-row' + (options.current ? ' current' : '');
row.dataset.destinationHash = hub.destination_hash;
if (options.current) row.setAttribute('aria-current', 'true');
row.disabled = !!options.disabled;
row.appendChild(_channelsBuildHubMark(hub));
@ -1445,11 +1561,24 @@ function _channelsBuildHubRow(hub) {
copy.appendChild(meta);
row.appendChild(copy);
var trailing = document.createElement('span');
trailing.className = 'channel-hub-row-trailing';
var distance = document.createElement('span');
distance.className = 'channel-hub-row-distance';
distance.textContent = _channelsHubDistance(hub);
if (distance.textContent) row.appendChild(distance);
row.addEventListener('click', function() { channelsOpenConnectSheet(hub); });
if (distance.textContent) trailing.appendChild(distance);
if (options.status) {
var status = document.createElement('span');
status.className = 'channel-row-status' +
(options.statusTone ? ' ' + options.statusTone : '');
status.textContent = options.status;
trailing.appendChild(status);
}
if (trailing.childNodes.length) row.appendChild(trailing);
row.addEventListener('click', function() {
if (typeof options.onSelect === 'function') options.onSelect(hub);
else channelsOpenConnectSheet(hub);
});
return row;
}
@ -3140,6 +3269,203 @@ function channelsOpenSharedChannel(payload) {
}
window.channelsOpenSharedChannel = channelsOpenSharedChannel;
function _channelsHubCurrentStatus() {
switch (channelsSnapshot.phase) {
case 'active': return { label: 'Live now', tone: 'joined' };
case 'stale': return { label: 'Recovering', tone: 'recovering' };
case 'reconnecting': return { label: 'Reconnecting', tone: 'recovering' };
case 'error': return { label: 'Needs attention', tone: 'error' };
default: return { label: _channelsPhaseLabel(channelsSnapshot.phase), tone: '' };
}
}
function channelsOpenHubSwitcher() {
if (typeof _rsBuildSheet !== 'function') return;
if (typeof _channelsHubSwitcherDismiss === 'function') {
_channelsHubSwitcherDismiss();
}
var openedEpoch = _channelsHistoryEpoch;
var openedGeneration = Number(channelsSnapshot.generation) || 0;
var built = _rsBuildSheet({ title: 'Channel hubs' }, function() {
if (_channelsHubSwitcherDismiss === built.dismiss) {
_channelsHubSwitcherDismiss = null;
}
});
_channelsHubSwitcherDismiss = built.dismiss;
built.sheet.classList.add('channel-hub-switcher-sheet');
var copy = document.createElement('p');
copy.className = 'channel-sheet-copy';
copy.textContent = 'Ratspeak keeps one live hub at a time. Switching ends the current live rooms; saved channels and local history stay on this device.';
built.body.appendChild(copy);
var list = document.createElement('div');
list.className = 'channel-hub-switcher-list';
list.setAttribute('aria-live', 'polite');
built.body.appendChild(list);
var refreshInFlight = false;
function contextIsCurrent() {
return openedEpoch === _channelsHistoryEpoch &&
openedGeneration === (Number(channelsSnapshot.generation) || 0);
}
function retireStaleSwitcher() {
built.dismiss();
if (typeof showToast === 'function') {
showToast('Channels changed. Choose a hub again.', 'toast-orange', 2800);
}
}
function chooseHub(hub, current) {
if (!contextIsCurrent()) {
retireStaleSwitcher();
return;
}
built.dismiss();
setTimeout(function() {
if (!contextIsCurrent()) {
retireStaleSwitcher();
return;
}
if (current && channelsSnapshot.hub) channelsOpenHubOptions();
else channelsOpenConnectSheet(hub);
}, 220);
}
function appendHubSection(label, hubs, options) {
options = options || {};
if (!hubs.length && !options.always) return;
var section = _channelsListSection(label, {
className: 'channel-hub-switcher-section',
actionText: options.actionText,
actionDisabled: options.actionDisabled,
action: options.action
});
if (options.refreshAction) {
var sectionAction = section.querySelector('.channels-list-section-action');
if (sectionAction) sectionAction.dataset.channelHubRefresh = 'true';
}
list.appendChild(section);
hubs.forEach(function(hub) {
list.appendChild(_channelsBuildHubRow(hub, {
current: !!options.current,
disabled: !!options.disabled,
status: options.status || '',
statusTone: options.statusTone || '',
onSelect: function(selected) {
chooseHub(selected, !!options.current);
}
}));
});
}
function refreshNearby() {
if (refreshInFlight) return;
if (!contextIsCurrent()) {
retireStaleSwitcher();
return;
}
refreshInFlight = true;
list.setAttribute('aria-busy', 'true');
var refreshButton = list.querySelector('[data-channel-hub-refresh]');
if (refreshButton) {
refreshButton.disabled = true;
refreshButton.textContent = 'Checking\u2026';
}
channelsRefreshAvailableHubs().then(function() {
refreshInFlight = false;
list.removeAttribute('aria-busy');
if (built.sheet.isConnected && contextIsCurrent()) {
renderList();
var nextRefresh = list.querySelector('[data-channel-hub-refresh]');
if (nextRefresh) nextRefresh.focus();
} else if (built.sheet.isConnected) {
retireStaleSwitcher();
}
}, function() {
refreshInFlight = false;
list.removeAttribute('aria-busy');
if (built.sheet.isConnected && contextIsCurrent()) {
renderList();
var nextRefresh = list.querySelector('[data-channel-hub-refresh]');
if (nextRefresh) nextRefresh.focus();
} else if (built.sheet.isConnected) {
retireStaleSwitcher();
}
});
}
function renderList() {
list.textContent = '';
var model = _channelsHubSwitcherModel();
var blocked = _channelsConnectCommandBlocked();
if (model.current) {
var currentStatus = _channelsHubCurrentStatus();
appendHubSection('Current selection', [model.current], {
current: true,
status: currentStatus.label,
statusTone: currentStatus.tone
});
}
appendHubSection('Recently heard', model.nearby, {
always: true,
actionText: refreshInFlight ? 'Checking\u2026' : 'Refresh',
actionDisabled: refreshInFlight,
action: refreshNearby,
refreshAction: true,
disabled: blocked
});
if (!model.nearby.length) {
list.appendChild(_channelsDirectoryStatus(
refreshInFlight ? 'Checking recent hub announcements\u2026' : 'No other hubs heard recently',
'Saved hubs remain available below'
));
}
appendHubSection('Saved on this device', model.saved, {
disabled: blocked
});
if (!model.current && !model.nearby.length && !model.saved.length) {
list.appendChild(_channelsDirectoryStatus(
'No channel hubs yet',
'Add a trusted destination address to begin'
));
}
if (blocked && (model.nearby.length || model.saved.length)) {
list.appendChild(_channelsDirectoryStatus(
'Connection in progress',
'Open the current selection to cancel before choosing another hub',
'warning'
));
}
}
var add = document.createElement('button');
add.type = 'button';
add.className = 'nr-btn nr-btn-secondary';
add.textContent = 'Add by address';
add.addEventListener('click', function() {
built.dismiss();
setTimeout(function() { channelsOpenConnectSheet(); }, 220);
});
var done = document.createElement('button');
done.type = 'button';
done.className = 'nr-btn nr-btn-primary';
done.textContent = 'Done';
done.addEventListener('click', built.dismiss);
built.footer.appendChild(add);
built.footer.appendChild(done);
renderList();
_channelsPresentSheet(built, modelCurrentFocus());
function modelCurrentFocus() {
return list.querySelector('.channel-hub-row.current') ||
list.querySelector('.channel-hub-row:not(:disabled)') ||
add;
}
}
function channelsOpenConnectSheet(prefill) {
if (typeof _rsBuildSheet !== 'function') return;
prefill = prefill || {};
@ -3152,6 +3478,7 @@ function channelsOpenConnectSheet(prefill) {
var sharedRoom = prefill.shared_room || '';
var defaultNick = prefill.nickname || (_channelsSavedHub(selectedHash) || {}).nickname || channelsSnapshot.nickname || _channelsDefaultNickname();
var built = _rsBuildSheet({ title: 'Connect to Channels' }, function() {});
var titleElement = built.sheet.querySelector('.bottom-sheet-title');
var copy = document.createElement('p');
copy.className = 'channel-sheet-copy';
@ -3175,6 +3502,11 @@ function channelsOpenConnectSheet(prefill) {
trust.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg><span>Choose a hub you trust. The Link is encrypted in transit, but the hub relays and can read channel messages.</span>';
built.body.appendChild(trust);
var switchImpact = document.createElement('div');
switchImpact.className = 'channel-hub-switch-impact';
switchImpact.hidden = true;
built.body.appendChild(switchImpact);
var destinationInput = document.createElement('input');
destinationInput.type = 'text';
destinationInput.className = 'nr-input-sm mono';
@ -3188,6 +3520,7 @@ function channelsOpenConnectSheet(prefill) {
var entered = destinationInput.value.trim().toLowerCase();
if (entered !== selectedHash) selectedLabel = '';
if (entered !== sharedDestination) sharedRoom = '';
updateConnectionChoice();
});
if (hubs.length) {
@ -3226,6 +3559,7 @@ function channelsOpenConnectSheet(prefill) {
available.querySelectorAll('.channel-sheet-hub').forEach(function(el) { el.classList.remove('selected'); });
row.classList.add('selected');
error.textContent = '';
updateConnectionChoice();
});
available.appendChild(row);
});
@ -3254,10 +3588,65 @@ function channelsOpenConnectSheet(prefill) {
var connect = document.createElement('button');
connect.type = 'button';
connect.className = 'nr-btn nr-btn-primary';
connect.textContent = sharedRoom ? 'Connect and review' : 'Connect';
var connectBusy = false;
function updateConnectionChoice() {
var destination = destinationInput.value.trim().toLowerCase();
var mode = _channelsHubConnectMode(destination);
var currentHub = mode.current_destination
? _channelsHubByDestination(mode.current_destination)
: null;
var currentName = currentHub
? _channelsTimelineHubName(currentHub)
: 'the current hub';
switchImpact.hidden = true;
if (mode.kind === 'switch') {
if (titleElement) titleElement.textContent = 'Switch channel hub';
copy.textContent = sharedRoom
? 'This share points to ' + sharedRoom + '. Review and authenticate the new hub first; Ratspeak will then open a separate join review.'
: 'Review the next hub and nickname before replacing the current live session.';
switchImpact.hidden = false;
switchImpact.textContent = channelsSnapshot.phase === 'reconnecting'
? 'Switching stops recovery for ' + currentName + '. Saved channels and local history stay on this device.'
: 'Switching ends your live rooms on ' + currentName + '. Saved channels and local history stay on this device.';
connect.textContent = connectBusy
? 'Switching\u2026'
: (sharedRoom ? 'Switch and review' : 'Switch hub');
connect.disabled = connectBusy;
} else if (mode.kind === 'current') {
if (titleElement) titleElement.textContent = 'Current channel hub';
copy.textContent = 'You are already connected to this hub. Choose another destination to switch, or use Hub options to manage this session.';
connect.textContent = 'Connected';
connect.disabled = true;
} else if (mode.kind === 'recovering') {
if (titleElement) titleElement.textContent = 'Hub recovery in progress';
copy.textContent = 'Ratspeak is recovering this hub connection. Choose another destination to stop recovery and switch.';
connect.textContent = 'Recovering\u2026';
connect.disabled = true;
} else if (mode.kind === 'pending') {
if (titleElement) titleElement.textContent = 'Connection in progress';
copy.textContent = 'Finish or cancel the current connection before choosing another hub.';
switchImpact.hidden = false;
switchImpact.textContent = 'Open the current hub from the switcher to cancel this attempt.';
connect.textContent = 'Connection in progress';
connect.disabled = true;
} else {
if (titleElement) titleElement.textContent = 'Connect to Channels';
copy.textContent = sharedRoom
? 'This share points to ' + sharedRoom + '. Review and authenticate the hub first; Ratspeak will then open a separate join review.'
: 'Choose a recently heard hub or enter its destination. Ratspeak will authenticate the connection.';
connect.textContent = connectBusy
? 'Connecting\u2026'
: (sharedRoom ? 'Connect and review' : 'Connect');
connect.disabled = connectBusy;
}
}
connect.addEventListener('click', function() {
var destination = destinationInput.value.trim().toLowerCase();
var nickname = nicknameInput.value.trim();
var connectMode = _channelsHubConnectMode(destination);
if (!/^[0-9a-f]{32}$/.test(destination)) {
error.textContent = 'Enter a 32-character hexadecimal destination hash.';
destinationInput.focus();
@ -3268,8 +3657,8 @@ function channelsOpenConnectSheet(prefill) {
nicknameInput.focus();
return;
}
connect.disabled = true;
connect.textContent = 'Connecting\u2026';
connectBusy = true;
updateConnectionChoice();
error.textContent = '';
var pendingShare = sharedRoom && destination === sharedDestination
? {
@ -3284,20 +3673,25 @@ function channelsOpenConnectSheet(prefill) {
announced_name: selectedLabel,
nickname: nickname
}, {
preserve_pending_share: true
preserve_pending_share: true,
switching: connectMode.kind === 'switch'
}).then(function() {
built.dismiss();
}).catch(function(err) {
if (channelsPendingShareJoin === pendingShare) {
channelsPendingShareJoin = null;
}
error.textContent = (err && err.message) || 'Could not connect to this hub.';
connect.disabled = false;
connect.textContent = sharedRoom ? 'Connect and review' : 'Connect';
error.textContent = (err && err.message) ||
(connectMode.kind === 'switch'
? 'Could not switch channel hubs.'
: 'Could not connect to this hub.');
connectBusy = false;
updateConnectionChoice();
});
});
built.footer.appendChild(cancel);
built.footer.appendChild(connect);
updateConnectionChoice();
_channelsPresentSheet(built, selectedHash ? nicknameInput : destinationInput);
}
@ -3822,6 +4216,8 @@ function _channelsBindUI() {
});
var join = _channelsEl('channels-join-btn');
if (join) join.addEventListener('click', function() { channelsOpenJoinSheet(); });
var hubSwitcher = _channelsEl('channel-hub-switcher-btn');
if (hubSwitcher) hubSwitcher.addEventListener('click', channelsOpenHubSwitcher);
var hubMenu = _channelsEl('channel-hub-menu-btn');
if (hubMenu) hubMenu.addEventListener('click', channelsOpenHubOptions);
var roomMenu = _channelsEl('channel-room-menu-btn');
@ -3903,6 +4299,11 @@ RS.listen('announce_received', function() {
});
RS.listen('lxmf_identity', function() {
if (typeof _channelsHubSwitcherDismiss === 'function') {
var dismissHubSwitcher = _channelsHubSwitcherDismiss;
_channelsHubSwitcherDismiss = null;
dismissHubSwitcher();
}
channelsSavedHubs = [];
channelsSavedRooms = [];
channelsRoomIndex = [];