mirror of
https://github.com/ratspeak/Ratspeak
synced 2026-08-12 18:07:35 -04:00
channels: present authenticated hub profiles
This commit is contained in:
parent
5bdaeb6330
commit
bb40092210
7 changed files with 832 additions and 29 deletions
|
|
@ -132,6 +132,9 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
|
|||
let hub_switcher_test =
|
||||
read_source(root.join("dashboard/scripts/test_channels_hub_switcher.js"))
|
||||
.expect("channels hub switcher test");
|
||||
let hub_profile_test =
|
||||
read_source(root.join("dashboard/scripts/test_channels_hub_profile.js"))
|
||||
.expect("channels hub profile 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");
|
||||
|
|
@ -244,6 +247,8 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
|
|||
assert!(channels_css.contains(".channels-layout:not(.room-live)"));
|
||||
assert!(channels_css.contains(".channel-transition-rail"));
|
||||
assert!(channels_css.contains(".channel-hub-greeting"));
|
||||
assert!(channels_css.contains(".channel-hub-profile-capabilities"));
|
||||
assert!(channels_css.contains(".channel-hub-greeting-delivery"));
|
||||
assert!(channels_css.contains(".channel-presence-summary"));
|
||||
assert!(channels_css.contains(".channel-history-rail"));
|
||||
assert!(channels_css.contains(".channel-day-separator"));
|
||||
|
|
@ -431,6 +436,7 @@ fn channels_keep_hubs_live_only_and_wire_bounded_local_history_across_the_produc
|
|||
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"));
|
||||
assert!(hub_profile_test.contains("channel hub profile tests passed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ async function main() {
|
|||
|
||||
assert(navSource.indexOf('channelsRefreshDirectory(false);') !== -1,
|
||||
'entering the Channels view must request an idle or stale directory');
|
||||
assert(channelsSource.indexOf('service_model_version: 2') !== -1);
|
||||
assert(channelsSource.indexOf('service_model_version: 3') !== -1);
|
||||
assert(channelsSource.indexOf('channelsOpenJoinSheet(room.name)') !== -1);
|
||||
assert(channelsSource.indexOf("RS.invoke('refresh_channel_directory')") !== -1);
|
||||
|
||||
|
|
|
|||
200
dashboard/scripts/test_channels_hub_profile.js
Normal file
200
dashboard/scripts/test_channels_hub_profile.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env node
|
||||
// Deterministic typed-model and source coverage for authenticated hub identity,
|
||||
// welcome provenance, capabilities, limits, and public-directory presentation.
|
||||
|
||||
'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 hubSource = fs.readFileSync(
|
||||
path.join(root, 'static', 'js', 'channel_hub.js'),
|
||||
'utf8'
|
||||
);
|
||||
var cssSource = fs.readFileSync(
|
||||
path.join(root, 'static', 'css', '09-channels.css'),
|
||||
'utf8'
|
||||
);
|
||||
var responsiveSource = fs.readFileSync(
|
||||
path.join(root, 'static', 'css', '13-responsive.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(
|
||||
'_channelsCurrentHubObserved',
|
||||
'_channelsConnectCommandBlocked'
|
||||
);
|
||||
var context = {
|
||||
Array: Array,
|
||||
Math: Math,
|
||||
Number: Number,
|
||||
String: String,
|
||||
channelsSnapshot: {}
|
||||
};
|
||||
vm.runInNewContext(modelSource, context, {
|
||||
filename: 'channels-hub-profile-model.js'
|
||||
});
|
||||
|
||||
var HUB = '00112233445566778899aabbccddeeff';
|
||||
var IDENTITY = 'ffeeddccbbaa99887766554433221100';
|
||||
var greeting = {
|
||||
text: 'Read the field rules before transmitting.',
|
||||
received_at_ms: 1_900_000_000_000,
|
||||
source_hash: IDENTITY,
|
||||
delivery: 'resource',
|
||||
completeness: 'complete'
|
||||
};
|
||||
context.channelsSnapshot = {
|
||||
protocol_version: '0.1.3',
|
||||
phase: 'active',
|
||||
nickname: 'Field Rat',
|
||||
selected_hub_destination: HUB,
|
||||
hub: {
|
||||
destination_hash: HUB,
|
||||
name: 'Legacy root projection'
|
||||
},
|
||||
directory: { phase: 'idle', rooms: [] },
|
||||
hub_greeting: null,
|
||||
hubs: [{
|
||||
destination_hash: HUB,
|
||||
observed: {
|
||||
phase: 'active',
|
||||
nickname: 'Field Rat',
|
||||
hub: {
|
||||
destination_hash: HUB,
|
||||
identity_hash: IDENTITY,
|
||||
announced_name: 'Ridge Relay',
|
||||
name: 'Ridge Operations',
|
||||
version: '0.3.2',
|
||||
hops: 2,
|
||||
link_mdu: 383,
|
||||
connected_at_ms: 1_900_000_000_000,
|
||||
capabilities: {
|
||||
actions: true,
|
||||
direct_notices: true,
|
||||
resource_envelopes: true,
|
||||
rejoin_grace: false
|
||||
},
|
||||
limits: {
|
||||
max_nick_bytes: 32,
|
||||
max_room_name_bytes: 64,
|
||||
max_message_body_bytes: 350,
|
||||
max_rooms_per_session: 32,
|
||||
rate_messages_per_minute: 240
|
||||
}
|
||||
},
|
||||
directory: {
|
||||
phase: 'ready',
|
||||
rooms: [{ name: 'field' }, { name: 'ops' }],
|
||||
complete: false,
|
||||
omitted_count: 3,
|
||||
refreshed_at_ms: 1_900_000_000_500
|
||||
},
|
||||
greeting: greeting
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
var profile = context._channelsHubProfileModel();
|
||||
assert.strictEqual(profile.display_name, 'Ridge Operations');
|
||||
assert.strictEqual(profile.authenticated_name, 'Ridge Operations');
|
||||
assert.strictEqual(profile.announced_name, 'Ridge Relay');
|
||||
assert.strictEqual(profile.name_mismatch, true);
|
||||
assert.strictEqual(profile.destination_hash, HUB);
|
||||
assert.strictEqual(profile.identity_hash, IDENTITY);
|
||||
assert.strictEqual(profile.authenticated_session, true);
|
||||
assert.strictEqual(profile.hops, 2);
|
||||
assert.strictEqual(profile.link_mdu, 383);
|
||||
assert.strictEqual(profile.greeting, greeting,
|
||||
'the hub-keyed typed greeting must outrank the legacy root projection');
|
||||
assert.strictEqual(profile.directory.count, 2);
|
||||
assert.strictEqual(profile.directory.complete, false);
|
||||
assert.strictEqual(
|
||||
profile.directory.summary,
|
||||
'2 public channels shown \u00b7 3 more omitted by the hub'
|
||||
);
|
||||
assert.strictEqual(profile.capabilities.resource_envelopes, true);
|
||||
assert.strictEqual(profile.capabilities.rejoin_grace, false);
|
||||
assert.strictEqual(profile.limits.max_message_body_bytes, 350);
|
||||
assert.strictEqual(profile.limits.rate_messages_per_minute, 240);
|
||||
|
||||
context.channelsSnapshot = {
|
||||
protocol_version: '0.1.3',
|
||||
phase: 'stale',
|
||||
hubs: [],
|
||||
hub: {
|
||||
destination_hash: HUB,
|
||||
identity_hash: IDENTITY,
|
||||
announced_name: 'Same Name',
|
||||
name: 'same name',
|
||||
capabilities: {},
|
||||
limits: {
|
||||
max_nick_bytes: null,
|
||||
max_message_body_bytes: undefined
|
||||
}
|
||||
},
|
||||
directory: {
|
||||
phase: 'idle',
|
||||
rooms: [],
|
||||
complete: false,
|
||||
omitted_count: null
|
||||
},
|
||||
hub_greeting: {
|
||||
text: 'Short packet greeting',
|
||||
delivery: 'notice',
|
||||
completeness: 'unframed'
|
||||
}
|
||||
};
|
||||
profile = context._channelsHubProfileModel();
|
||||
assert.strictEqual(profile.name_mismatch, false,
|
||||
'display-only name comparison should be case insensitive');
|
||||
assert.strictEqual(profile.authenticated_session, false,
|
||||
'an identity remembered from a stale projection is not a current Link claim');
|
||||
assert.strictEqual(profile.limits.max_nick_bytes, null,
|
||||
'a missing native limit must not be coerced into a zero-byte limit');
|
||||
assert.strictEqual(profile.limits.max_message_body_bytes, null);
|
||||
assert.strictEqual(
|
||||
profile.directory.summary,
|
||||
'Public channels have not been requested for this Link.'
|
||||
);
|
||||
assert.strictEqual(profile.greeting.delivery, 'notice');
|
||||
assert.strictEqual(profile.greeting.completeness, 'unframed');
|
||||
|
||||
assert(modelSource.indexOf('JSON.parse') === -1);
|
||||
assert(modelSource.indexOf('cbor') === -1);
|
||||
assert(modelSource.indexOf('.body') === -1,
|
||||
'JavaScript must consume typed native fields instead of protocol bodies');
|
||||
assert(channelsSource.indexOf('Welcome & guidance') !== -1);
|
||||
assert(channelsSource.indexOf(
|
||||
'Complete bounded transfer \\u00b7 authenticated Link'
|
||||
) !== -1);
|
||||
assert(channelsSource.indexOf(
|
||||
'NOTICE delivery \\u00b7 completion not signaled by this hub'
|
||||
) !== -1);
|
||||
assert(channelsSource.indexOf('Authenticated channel hub') !== -1);
|
||||
assert(channelsSource.indexOf('Name differs from the recent announce') !== -1);
|
||||
assert(channelsSource.indexOf('Private or secret channels may be intentionally hidden') !== -1);
|
||||
assert(channelsSource.indexOf('Advertised by this hub in the authenticated WELCOME.') !== -1);
|
||||
assert(channelsSource.indexOf("secondary.dataset.channelAction = 'hub-info'") !== -1);
|
||||
assert(channelsSource.indexOf("action === 'hub-info'") !== -1);
|
||||
assert(hubSource.indexOf("'Welcome & guidance'") !== -1);
|
||||
assert(hubSource.indexOf('Use for rules and where to begin') !== -1);
|
||||
assert(cssSource.indexOf('.channel-hub-profile-capabilities') !== -1);
|
||||
assert(cssSource.indexOf('.channel-hub-greeting-delivery') !== -1);
|
||||
assert(responsiveSource.indexOf('.channel-hub-profile-capabilities') !== -1);
|
||||
|
||||
console.log('channel hub profile tests passed');
|
||||
|
|
@ -966,6 +966,17 @@
|
|||
padding-top: var(--space-8);
|
||||
}
|
||||
|
||||
.channel-welcome-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.channel-welcome-actions .nr-btn {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.channel-transition-card {
|
||||
width: min(100%, 620px);
|
||||
margin: 0 auto var(--space-8);
|
||||
|
|
@ -1291,7 +1302,21 @@
|
|||
|
||||
.channel-hub-greeting.compact {
|
||||
width: 100%;
|
||||
margin: var(--space-6) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.channel-hub-greeting-delivery {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-row);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-2xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.channel-hub-greeting[data-delivery="resource"][data-completeness="complete"]
|
||||
.channel-hub-greeting-delivery {
|
||||
color: var(--status-online);
|
||||
}
|
||||
|
||||
.channel-system-event {
|
||||
|
|
@ -1706,6 +1731,188 @@
|
|||
line-height: var(--type-leading-copy);
|
||||
}
|
||||
|
||||
.channel-hub-profile {
|
||||
gap: var(--space-7);
|
||||
}
|
||||
|
||||
.channel-hub-profile-hero {
|
||||
padding-bottom: var(--space-6);
|
||||
border-bottom: 1px solid var(--border-row);
|
||||
}
|
||||
|
||||
.channel-hub-profile-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: var(--type-weight-semibold);
|
||||
letter-spacing: var(--type-section-label-tracking);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.channel-hub-profile-status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 0 3px var(--status-online-bg);
|
||||
}
|
||||
|
||||
.channel-hub-profile-hero[data-phase="stale"] .channel-hub-profile-status-dot,
|
||||
.channel-hub-profile-hero[data-phase="reconnecting"] .channel-hub-profile-status-dot,
|
||||
.channel-hub-profile-hero[data-phase="resolving"] .channel-hub-profile-status-dot,
|
||||
.channel-hub-profile-hero[data-phase="connecting"] .channel-hub-profile-status-dot,
|
||||
.channel-hub-profile-hero[data-phase="awaiting_welcome"] .channel-hub-profile-status-dot {
|
||||
background: var(--status-warning);
|
||||
box-shadow: 0 0 0 3px var(--status-warning-bg);
|
||||
}
|
||||
|
||||
.channel-hub-profile-hero[data-phase="error"] .channel-hub-profile-status-dot,
|
||||
.channel-hub-profile-hero[data-phase="offline"] .channel-hub-profile-status-dot {
|
||||
background: var(--text-muted);
|
||||
box-shadow: 0 0 0 3px var(--surface-control);
|
||||
}
|
||||
|
||||
.channel-hub-profile-hero h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--type-weight-semibold);
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.channel-hub-profile-hero p {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.channel-hub-profile-mismatch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
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(--status-warning-bg);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.channel-hub-profile-mismatch strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--type-weight-semibold);
|
||||
}
|
||||
|
||||
.channel-hub-profile-empty-guidance {
|
||||
padding: var(--space-5);
|
||||
border: 1px dashed var(--border-surface-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.channel-hub-profile-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.channel-hub-profile-section h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--type-weight-semibold);
|
||||
}
|
||||
|
||||
.channel-hub-profile-section > p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.channel-hub-profile-details {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.channel-hub-profile-detail strong.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.channel-hub-profile-note {
|
||||
padding-left: var(--space-4);
|
||||
border-left: 2px solid var(--border-surface-strong);
|
||||
}
|
||||
|
||||
.channel-hub-profile-capabilities {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.channel-hub-profile-capability {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-panel-muted);
|
||||
}
|
||||
|
||||
.channel-hub-profile-capability-mark {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--status-online-bg);
|
||||
color: var(--status-online);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--type-weight-semibold);
|
||||
}
|
||||
|
||||
.channel-hub-profile-capability[data-enabled="false"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.channel-hub-profile-capability[data-enabled="false"]
|
||||
.channel-hub-profile-capability-mark {
|
||||
background: var(--surface-control);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.channel-hub-profile-capability-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.channel-hub-profile-capability-copy strong {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--type-weight-semibold);
|
||||
}
|
||||
|
||||
.channel-hub-profile-capability-copy > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-2xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.channel-share-qr-shell {
|
||||
display: flex;
|
||||
width: min(100%, 292px);
|
||||
|
|
|
|||
|
|
@ -3730,6 +3730,28 @@
|
|||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.channel-welcome-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-welcome-actions .nr-btn {
|
||||
min-height: 42px;
|
||||
flex: 1 1 132px;
|
||||
}
|
||||
|
||||
.channel-hub-profile-capabilities {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.channel-hub-profile-hero h2 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.channel-hub-profile-detail {
|
||||
grid-template-columns: minmax(82px, auto) minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.channel-welcome-mark {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
|
|
|
|||
|
|
@ -2078,9 +2078,13 @@ function channelHubOpenManager(initialOverview) {
|
|||
greetingInput.className = 'nr-input-sm channel-host-greeting';
|
||||
greetingInput.maxLength = 512;
|
||||
greetingInput.rows = 3;
|
||||
greetingInput.placeholder = 'Welcome people and tell them where to begin';
|
||||
greetingInput.placeholder = 'Welcome people, share rules, and tell them where to begin';
|
||||
greetingInput.value = settings.greeting || '';
|
||||
var greetingField = _channelHubField('Welcome message', greetingInput, 'Shown once when someone connects');
|
||||
var greetingField = _channelHubField(
|
||||
'Welcome & guidance',
|
||||
greetingInput,
|
||||
'Shown after WELCOME when someone connects'
|
||||
);
|
||||
var greetingCount = greetingField.querySelector('.channel-host-field-hint');
|
||||
profile.appendChild(greetingField);
|
||||
panels.settings.appendChild(profile);
|
||||
|
|
@ -2426,7 +2430,8 @@ function channelHubOpenManager(initialOverview) {
|
|||
save.disabled = busy || adminMutationBusy || !dirty || !nameInput.value.trim();
|
||||
save.hidden = activeTab !== 'settings';
|
||||
impact.hidden = !(dirty && overview.status && overview.status.running);
|
||||
greetingCount.textContent = (greetingInput.value.length || 0) + '/512 · Shown once when someone connects';
|
||||
greetingCount.textContent = (greetingInput.value.length || 0) +
|
||||
'/512 \u00b7 Use for rules and where to begin';
|
||||
}
|
||||
|
||||
function setBusy(value, label) {
|
||||
|
|
|
|||
|
|
@ -613,6 +613,114 @@ function _channelsHubSwitcherModel() {
|
|||
};
|
||||
}
|
||||
|
||||
function _channelsCurrentHubObserved() {
|
||||
var destination = String(
|
||||
(channelsSnapshot.hub && channelsSnapshot.hub.destination_hash) ||
|
||||
channelsSnapshot.selected_hub_destination ||
|
||||
''
|
||||
).toLowerCase();
|
||||
var hubs = Array.isArray(channelsSnapshot.hubs) ? channelsSnapshot.hubs : [];
|
||||
for (var i = 0; i < hubs.length; i++) {
|
||||
if (String(hubs[i].destination_hash || '').toLowerCase() !== destination) continue;
|
||||
return hubs[i].observed || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A presentation-safe view of authenticated native state. This intentionally
|
||||
// interprets no RRC payloads in JavaScript: WELCOME, directory, and greeting
|
||||
// provenance have already crossed the native validation boundary as typed
|
||||
// fields.
|
||||
function _channelsHubProfileModel() {
|
||||
var observed = _channelsCurrentHubObserved();
|
||||
var hub = (observed && observed.hub) || channelsSnapshot.hub || {};
|
||||
var directory = (observed && observed.directory) ||
|
||||
channelsSnapshot.directory || {};
|
||||
var greeting = (observed && observed.greeting) ||
|
||||
channelsSnapshot.hub_greeting || null;
|
||||
var authenticatedName = String(hub.name || '').trim();
|
||||
var announcedName = String(hub.announced_name || '').trim();
|
||||
var destination = String(hub.destination_hash || '').toLowerCase();
|
||||
var directoryRooms = Array.isArray(directory.rooms) ? directory.rooms : [];
|
||||
var omitted = Number.isSafeInteger(Number(directory.omitted_count))
|
||||
? Math.max(0, Number(directory.omitted_count)) : 0;
|
||||
var directoryPhase = String(directory.phase || 'idle');
|
||||
var directorySummary;
|
||||
if (directoryPhase === 'loading') {
|
||||
directorySummary = 'Refreshing public channels\u2026';
|
||||
} else if (directoryPhase === 'error') {
|
||||
directorySummary = directory.last_error ||
|
||||
'Public channel information is unavailable.';
|
||||
} else if (directoryPhase !== 'ready') {
|
||||
directorySummary = 'Public channels have not been requested for this Link.';
|
||||
} else if (!directoryRooms.length && directory.complete) {
|
||||
directorySummary = 'No public channels were advertised.';
|
||||
} else {
|
||||
directorySummary = directoryRooms.length + ' public ' +
|
||||
(directoryRooms.length === 1 ? 'channel' : 'channels');
|
||||
if (!directory.complete) {
|
||||
directorySummary += omitted
|
||||
? ' shown \u00b7 ' + omitted + ' more omitted by the hub'
|
||||
: ' shown \u00b7 response may be incomplete';
|
||||
}
|
||||
}
|
||||
|
||||
function safeLimit(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
value = Number(value);
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
var capabilities = hub.capabilities || {};
|
||||
var limits = hub.limits || {};
|
||||
var phase = String((observed && observed.phase) ||
|
||||
channelsSnapshot.phase || 'offline');
|
||||
var identityHash = String(hub.identity_hash || '').toLowerCase();
|
||||
var connectedAt = safeLimit(hub.connected_at_ms);
|
||||
return {
|
||||
hub: hub,
|
||||
destination_hash: destination,
|
||||
identity_hash: identityHash,
|
||||
authenticated_name: authenticatedName || null,
|
||||
announced_name: announcedName || null,
|
||||
display_name: authenticatedName || announcedName ||
|
||||
(destination ? 'Channel hub ' + destination.slice(0, 8) : 'Channel hub'),
|
||||
name_mismatch: !!(authenticatedName && announcedName &&
|
||||
authenticatedName.toLowerCase() !== announcedName.toLowerCase()),
|
||||
phase: phase,
|
||||
authenticated_session: !!(identityHash && connectedAt != null &&
|
||||
(phase === 'active' || phase === 'stale')),
|
||||
nickname: (observed && observed.nickname) || channelsSnapshot.nickname || null,
|
||||
hops: safeLimit(hub.hops),
|
||||
link_mdu: safeLimit(hub.link_mdu),
|
||||
connected_at_ms: connectedAt,
|
||||
hub_version: hub.version || null,
|
||||
protocol_version: channelsSnapshot.protocol_version || null,
|
||||
greeting: greeting,
|
||||
directory: {
|
||||
phase: directoryPhase,
|
||||
count: directoryRooms.length,
|
||||
complete: !!directory.complete,
|
||||
omitted_count: omitted,
|
||||
refreshed_at_ms: safeLimit(directory.refreshed_at_ms),
|
||||
summary: directorySummary
|
||||
},
|
||||
capabilities: {
|
||||
actions: !!capabilities.actions,
|
||||
direct_notices: !!capabilities.direct_notices,
|
||||
resource_envelopes: !!capabilities.resource_envelopes,
|
||||
rejoin_grace: !!capabilities.rejoin_grace
|
||||
},
|
||||
limits: {
|
||||
max_nick_bytes: safeLimit(limits.max_nick_bytes),
|
||||
max_room_name_bytes: safeLimit(limits.max_room_name_bytes),
|
||||
max_message_body_bytes: safeLimit(limits.max_message_body_bytes),
|
||||
max_rooms_per_session: safeLimit(limits.max_rooms_per_session),
|
||||
rate_messages_per_minute: safeLimit(limits.rate_messages_per_minute)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function _channelsConnectCommandBlocked() {
|
||||
return channelsSnapshot.phase === 'resolving' ||
|
||||
channelsSnapshot.phase === 'connecting' ||
|
||||
|
|
@ -2095,6 +2203,7 @@ function _channelsRenderRoomEmpty(transcript) {
|
|||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'nr-btn nr-btn-primary';
|
||||
var secondary = null;
|
||||
|
||||
if (_channelsIsConnecting()) {
|
||||
title.textContent = _channelsPhaseLabel(channelsSnapshot.phase) + '\u2026';
|
||||
|
|
@ -2103,12 +2212,24 @@ function _channelsRenderRoomEmpty(transcript) {
|
|||
button.dataset.channelAction = 'disconnect';
|
||||
button.textContent = 'Cancel';
|
||||
} else if (_channelsIsConnected()) {
|
||||
title.textContent = 'Connected. Join a channel.';
|
||||
copy.textContent = 'Choose a channel on ' + _channelsHubName(channelsSnapshot.hub) + '.';
|
||||
var profile = _channelsHubProfileModel();
|
||||
state.classList.add('channel-hub-home-state');
|
||||
title.textContent = profile.display_name;
|
||||
copy.textContent = 'Authenticated Reticulum Link' +
|
||||
(profile.hops == null
|
||||
? ''
|
||||
: ' \u00b7 ' + profile.hops +
|
||||
(profile.hops === 1 ? ' hop' : ' hops')) +
|
||||
'. ' + profile.directory.summary;
|
||||
button.dataset.channelAction = 'join';
|
||||
button.textContent = 'Join a channel';
|
||||
if (channelsSnapshot.hub_greeting) {
|
||||
transcript.appendChild(_channelsBuildHubGreeting(channelsSnapshot.hub_greeting));
|
||||
button.textContent = 'Browse & join';
|
||||
secondary = document.createElement('button');
|
||||
secondary.type = 'button';
|
||||
secondary.className = 'nr-btn nr-btn-secondary';
|
||||
secondary.dataset.channelAction = 'hub-info';
|
||||
secondary.textContent = 'Hub information';
|
||||
if (profile.greeting) {
|
||||
transcript.appendChild(_channelsBuildHubGreeting(profile.greeting));
|
||||
state.classList.add('has-hub-greeting');
|
||||
}
|
||||
} else if (channelsSnapshot.phase === 'error') {
|
||||
|
|
@ -2132,19 +2253,28 @@ function _channelsRenderRoomEmpty(transcript) {
|
|||
state.appendChild(mark);
|
||||
state.appendChild(title);
|
||||
state.appendChild(copy);
|
||||
state.appendChild(button);
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'channel-welcome-actions';
|
||||
actions.appendChild(button);
|
||||
if (secondary) actions.appendChild(secondary);
|
||||
state.appendChild(actions);
|
||||
transcript.appendChild(state);
|
||||
}
|
||||
|
||||
function _channelsBuildHubGreeting(item, compact) {
|
||||
var greeting = document.createElement('aside');
|
||||
greeting.className = 'channel-hub-greeting' + (compact ? ' compact' : '');
|
||||
greeting.setAttribute('aria-label', 'Hub greeting');
|
||||
greeting.dataset.delivery = item.delivery || 'notice';
|
||||
greeting.dataset.completeness = item.completeness || 'unframed';
|
||||
greeting.setAttribute(
|
||||
'aria-label',
|
||||
'Welcome and guidance from the authenticated channel hub'
|
||||
);
|
||||
var heading = document.createElement('div');
|
||||
heading.className = 'channel-hub-greeting-heading';
|
||||
var label = document.createElement('span');
|
||||
label.className = 'channel-hub-notice-label';
|
||||
label.textContent = 'Hub greeting';
|
||||
label.textContent = 'Welcome & guidance';
|
||||
var hub = document.createElement('span');
|
||||
hub.className = 'channel-hub-greeting-source';
|
||||
hub.textContent = _channelsHubName(channelsSnapshot.hub);
|
||||
|
|
@ -2155,6 +2285,14 @@ function _channelsBuildHubGreeting(item, compact) {
|
|||
heading.appendChild(hub);
|
||||
greeting.appendChild(heading);
|
||||
greeting.appendChild(body);
|
||||
var delivery = document.createElement('div');
|
||||
delivery.className = 'channel-hub-greeting-delivery';
|
||||
if (item.delivery === 'resource' && item.completeness === 'complete') {
|
||||
delivery.textContent = 'Complete bounded transfer \u00b7 authenticated Link';
|
||||
} else {
|
||||
delivery.textContent = 'NOTICE delivery \u00b7 completion not signaled by this hub';
|
||||
}
|
||||
greeting.appendChild(delivery);
|
||||
return greeting;
|
||||
}
|
||||
|
||||
|
|
@ -3868,27 +4006,251 @@ function channelsOpenJoinSheet(prefillRoom) {
|
|||
_channelsPresentSheet(built, roomInput);
|
||||
}
|
||||
|
||||
function _channelsHubProfileDetail(labelText, value, mono) {
|
||||
var row = _channelsRoomDetail(labelText, value);
|
||||
row.classList.add('channel-hub-profile-detail');
|
||||
if (mono) row.querySelector('strong').classList.add('mono');
|
||||
return row;
|
||||
}
|
||||
|
||||
function _channelsHubProfileSection(titleText, copyText) {
|
||||
var section = document.createElement('section');
|
||||
section.className = 'channel-hub-profile-section';
|
||||
var title = document.createElement('h3');
|
||||
title.textContent = titleText;
|
||||
section.appendChild(title);
|
||||
if (copyText) {
|
||||
var copy = document.createElement('p');
|
||||
copy.textContent = copyText;
|
||||
section.appendChild(copy);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function _channelsHubCapability(labelText, description, enabled) {
|
||||
var capability = document.createElement('div');
|
||||
capability.className = 'channel-hub-profile-capability';
|
||||
capability.dataset.enabled = enabled ? 'true' : 'false';
|
||||
var mark = document.createElement('span');
|
||||
mark.className = 'channel-hub-profile-capability-mark';
|
||||
mark.setAttribute('aria-hidden', 'true');
|
||||
mark.textContent = enabled ? '\u2713' : '\u2014';
|
||||
var copy = document.createElement('span');
|
||||
copy.className = 'channel-hub-profile-capability-copy';
|
||||
var label = document.createElement('strong');
|
||||
label.textContent = labelText;
|
||||
var detail = document.createElement('span');
|
||||
detail.textContent = enabled ? description : 'Not advertised by this hub';
|
||||
copy.appendChild(label);
|
||||
copy.appendChild(detail);
|
||||
capability.appendChild(mark);
|
||||
capability.appendChild(copy);
|
||||
return capability;
|
||||
}
|
||||
|
||||
function channelsOpenHubOptions() {
|
||||
if (!channelsSnapshot.hub || typeof _rsBuildSheet !== 'function') return;
|
||||
var hub = channelsSnapshot.hub;
|
||||
var built = _rsBuildSheet({ title: _channelsHubName(hub) }, function() {});
|
||||
var copy = document.createElement('p');
|
||||
copy.className = 'channel-sheet-copy mono';
|
||||
copy.textContent = hub.destination_hash;
|
||||
built.body.appendChild(copy);
|
||||
var details = document.createElement('p');
|
||||
details.className = 'channel-sheet-copy';
|
||||
var parts = [_channelsPhaseLabel(channelsSnapshot.phase)];
|
||||
if (hub.hops != null) parts.push(hub.hops + (hub.hops === 1 ? ' hop' : ' hops'));
|
||||
if (hub.version) parts.push('Hub ' + hub.version);
|
||||
details.textContent = parts.join(' \u00b7 ');
|
||||
built.body.appendChild(details);
|
||||
if (channelsSnapshot.hub_greeting) {
|
||||
built.body.appendChild(_channelsBuildHubGreeting(channelsSnapshot.hub_greeting, true));
|
||||
var profile = _channelsHubProfileModel();
|
||||
var hub = profile.hub;
|
||||
var built = _rsBuildSheet({ title: 'Hub information' }, function() {});
|
||||
built.body.classList.add('channel-hub-profile');
|
||||
|
||||
var hero = document.createElement('header');
|
||||
hero.className = 'channel-hub-profile-hero';
|
||||
hero.dataset.phase = profile.phase;
|
||||
var eyebrow = document.createElement('div');
|
||||
eyebrow.className = 'channel-hub-profile-eyebrow';
|
||||
var statusDot = document.createElement('span');
|
||||
statusDot.className = 'channel-hub-profile-status-dot';
|
||||
statusDot.setAttribute('aria-hidden', 'true');
|
||||
var status = document.createElement('span');
|
||||
status.textContent = profile.authenticated_session
|
||||
? 'Authenticated channel hub'
|
||||
: 'Channel hub connection';
|
||||
eyebrow.appendChild(statusDot);
|
||||
eyebrow.appendChild(status);
|
||||
var name = document.createElement('h2');
|
||||
name.textContent = profile.display_name;
|
||||
var summary = document.createElement('p');
|
||||
var summaryParts = [_channelsPhaseLabel(profile.phase)];
|
||||
if (profile.hops != null) {
|
||||
summaryParts.push(profile.hops + (profile.hops === 1 ? ' hop' : ' hops'));
|
||||
}
|
||||
if (profile.hub_version) summaryParts.push('Hub software ' + profile.hub_version);
|
||||
summary.textContent = summaryParts.join(' \u00b7 ');
|
||||
hero.appendChild(eyebrow);
|
||||
hero.appendChild(name);
|
||||
hero.appendChild(summary);
|
||||
built.body.appendChild(hero);
|
||||
|
||||
if (profile.name_mismatch) {
|
||||
var mismatch = document.createElement('div');
|
||||
mismatch.className = 'channel-hub-profile-mismatch';
|
||||
mismatch.setAttribute('role', 'note');
|
||||
var mismatchTitle = document.createElement('strong');
|
||||
mismatchTitle.textContent = 'Name differs from the recent announce';
|
||||
var mismatchCopy = document.createElement('span');
|
||||
mismatchCopy.textContent = 'The announce said \u201c' + profile.announced_name +
|
||||
'\u201d; this authenticated WELCOME says \u201c' +
|
||||
profile.authenticated_name + '\u201d.';
|
||||
mismatch.appendChild(mismatchTitle);
|
||||
mismatch.appendChild(mismatchCopy);
|
||||
built.body.appendChild(mismatch);
|
||||
}
|
||||
|
||||
if (profile.greeting) {
|
||||
built.body.appendChild(_channelsBuildHubGreeting(profile.greeting, true));
|
||||
} else {
|
||||
var noGuidance = document.createElement('div');
|
||||
noGuidance.className = 'channel-hub-profile-empty-guidance';
|
||||
noGuidance.textContent =
|
||||
'This hub did not send welcome or rules guidance for this Link session.';
|
||||
built.body.appendChild(noGuidance);
|
||||
}
|
||||
|
||||
var identitySection = _channelsHubProfileSection(
|
||||
'Connection identity',
|
||||
profile.authenticated_session
|
||||
? 'These values come from the destination and authenticated Link, not from a share label.'
|
||||
: 'Ratspeak has no active authenticated WELCOME for this hub right now.'
|
||||
);
|
||||
var identityDetails = document.createElement('div');
|
||||
identityDetails.className = 'channel-room-details channel-hub-profile-details';
|
||||
identityDetails.appendChild(_channelsHubProfileDetail(
|
||||
'Destination',
|
||||
profile.destination_hash || 'Unavailable',
|
||||
true
|
||||
));
|
||||
identityDetails.appendChild(_channelsHubProfileDetail(
|
||||
'Hub identity',
|
||||
profile.identity_hash || 'Unavailable',
|
||||
true
|
||||
));
|
||||
if (profile.authenticated_name) {
|
||||
identityDetails.appendChild(_channelsHubProfileDetail(
|
||||
'WELCOME name',
|
||||
profile.authenticated_name
|
||||
));
|
||||
}
|
||||
if (profile.announced_name) {
|
||||
identityDetails.appendChild(_channelsHubProfileDetail(
|
||||
'Recent announce',
|
||||
profile.announced_name
|
||||
));
|
||||
}
|
||||
if (profile.nickname) {
|
||||
identityDetails.appendChild(_channelsHubProfileDetail(
|
||||
'Your nickname',
|
||||
profile.nickname
|
||||
));
|
||||
}
|
||||
identityDetails.appendChild(_channelsHubProfileDetail(
|
||||
'Path',
|
||||
profile.hops == null
|
||||
? 'Hop count unavailable'
|
||||
: profile.hops + (profile.hops === 1 ? ' hop' : ' hops')
|
||||
));
|
||||
if (profile.link_mdu != null) {
|
||||
identityDetails.appendChild(_channelsHubProfileDetail(
|
||||
'Link MDU',
|
||||
profile.link_mdu + ' bytes'
|
||||
));
|
||||
}
|
||||
if (profile.protocol_version) {
|
||||
identityDetails.appendChild(_channelsHubProfileDetail(
|
||||
'RRC profile',
|
||||
profile.protocol_version
|
||||
));
|
||||
}
|
||||
identitySection.appendChild(identityDetails);
|
||||
built.body.appendChild(identitySection);
|
||||
|
||||
var directorySection = _channelsHubProfileSection(
|
||||
'Public directory',
|
||||
profile.directory.summary
|
||||
);
|
||||
var directoryNote = document.createElement('p');
|
||||
directoryNote.className = 'channel-hub-profile-note';
|
||||
directoryNote.textContent =
|
||||
'Only public channels disclosed on this Link appear here. Private or secret channels may be intentionally hidden.';
|
||||
directorySection.appendChild(directoryNote);
|
||||
built.body.appendChild(directorySection);
|
||||
|
||||
var capabilitySection = _channelsHubProfileSection(
|
||||
'Session capabilities',
|
||||
'Advertised by this hub in the authenticated WELCOME.'
|
||||
);
|
||||
var capabilityGrid = document.createElement('div');
|
||||
capabilityGrid.className = 'channel-hub-profile-capabilities';
|
||||
capabilityGrid.appendChild(_channelsHubCapability(
|
||||
'Action messages',
|
||||
'Supports action-style channel posts',
|
||||
profile.capabilities.actions
|
||||
));
|
||||
capabilityGrid.appendChild(_channelsHubCapability(
|
||||
'Direct notices',
|
||||
'Can address a hub notice to one session',
|
||||
profile.capabilities.direct_notices
|
||||
));
|
||||
capabilityGrid.appendChild(_channelsHubCapability(
|
||||
'Complete welcome transfer',
|
||||
'Can deliver bounded welcome guidance as a Resource',
|
||||
profile.capabilities.resource_envelopes
|
||||
));
|
||||
capabilityGrid.appendChild(_channelsHubCapability(
|
||||
'Invite rejoin grace',
|
||||
'May restore a short identity-bound invite after reconnect',
|
||||
profile.capabilities.rejoin_grace
|
||||
));
|
||||
capabilitySection.appendChild(capabilityGrid);
|
||||
built.body.appendChild(capabilitySection);
|
||||
|
||||
var limitRows = [];
|
||||
if (profile.limits.max_message_body_bytes != null) {
|
||||
limitRows.push(['Message body', profile.limits.max_message_body_bytes + ' UTF-8 bytes']);
|
||||
}
|
||||
if (profile.limits.max_rooms_per_session != null) {
|
||||
limitRows.push([
|
||||
'Joined channels',
|
||||
profile.limits.max_rooms_per_session + ' per session'
|
||||
]);
|
||||
}
|
||||
if (profile.limits.rate_messages_per_minute != null) {
|
||||
limitRows.push([
|
||||
'Message rate',
|
||||
profile.limits.rate_messages_per_minute + ' per minute'
|
||||
]);
|
||||
}
|
||||
if (profile.limits.max_nick_bytes != null) {
|
||||
limitRows.push(['Nickname', profile.limits.max_nick_bytes + ' UTF-8 bytes']);
|
||||
}
|
||||
if (profile.limits.max_room_name_bytes != null) {
|
||||
limitRows.push([
|
||||
'Channel name',
|
||||
profile.limits.max_room_name_bytes + ' UTF-8 bytes'
|
||||
]);
|
||||
}
|
||||
var limitsSection = _channelsHubProfileSection(
|
||||
'Hub limits',
|
||||
limitRows.length
|
||||
? 'Applied by the hub to this session.'
|
||||
: 'This hub did not advertise concrete session limits.'
|
||||
);
|
||||
if (limitRows.length) {
|
||||
var limitDetails = document.createElement('div');
|
||||
limitDetails.className = 'channel-room-details channel-hub-profile-details';
|
||||
limitRows.forEach(function(row) {
|
||||
limitDetails.appendChild(_channelsHubProfileDetail(row[0], row[1]));
|
||||
});
|
||||
limitsSection.appendChild(limitDetails);
|
||||
}
|
||||
built.body.appendChild(limitsSection);
|
||||
|
||||
var trust = document.createElement('div');
|
||||
trust.className = 'channel-sheet-trust-note';
|
||||
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"/></svg><span>This hub is authenticated and the Link is encrypted. The hub can still read and relay everything posted to its channels.</span>';
|
||||
trust.innerHTML = profile.authenticated_session
|
||||
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>The destination and Link are authenticated and encrypted. The hub operator can still read, moderate, and relay everything posted to its channels.</span>'
|
||||
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Ratspeak authenticates the destination and Link before any channel action. This view is not proof of an active session.</span>';
|
||||
built.body.appendChild(trust);
|
||||
|
||||
var copyButton = document.createElement('button');
|
||||
|
|
@ -4204,6 +4566,7 @@ function _channelsBindUI() {
|
|||
if (action === 'connect') channelsOpenConnectSheet();
|
||||
else if (action === 'open-owned-hub' && typeof channelHubOpenOwnHub === 'function') channelHubOpenOwnHub();
|
||||
else if (action === 'join') channelsOpenJoinSheet();
|
||||
else if (action === 'hub-info') channelsOpenHubOptions();
|
||||
else if (action === 'disconnect') channelsDisconnect();
|
||||
else if (action === 'retry-room') channelsOpenJoinSheet(actionEl.dataset.room || '');
|
||||
else if (action === 'leave-room') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue