mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 08:53:41 +02:00
web: limit horizontal size of dropdown (#644)
* web: limit horizontal size of dropdown * address review comments
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
filterDisplayableFederationInstances,
|
||||
isSuppressedFederationSiteName,
|
||||
resolveFederationInstanceLabel,
|
||||
resolveFederationInstanceSortValue,
|
||||
resolveFederationSiteNameForDisplay,
|
||||
shouldDisplayFederationInstance,
|
||||
truncateFederationSiteName
|
||||
} from '../federation-instance-display.js';
|
||||
|
||||
test('isSuppressedFederationSiteName detects URL-like advertising names', () => {
|
||||
assert.equal(isSuppressedFederationSiteName('http://spam.example offer'), true);
|
||||
assert.equal(isSuppressedFederationSiteName('Visit www.spam.example today'), true);
|
||||
assert.equal(isSuppressedFederationSiteName('Mesh Collective'), false);
|
||||
assert.equal(isSuppressedFederationSiteName(''), false);
|
||||
assert.equal(isSuppressedFederationSiteName(null), false);
|
||||
});
|
||||
|
||||
test('truncateFederationSiteName shortens names longer than 32 characters', () => {
|
||||
assert.equal(truncateFederationSiteName('Short Mesh'), 'Short Mesh');
|
||||
assert.equal(
|
||||
truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz1234567890'),
|
||||
'abcdefghijklmnopqrstuvwxyz123...'
|
||||
);
|
||||
assert.equal(truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz123456').length, 32);
|
||||
assert.equal(truncateFederationSiteName(null), '');
|
||||
});
|
||||
|
||||
test('display helpers filter suppressed names and preserve original domains', () => {
|
||||
const entries = [
|
||||
{ name: 'Normal Mesh', domain: 'normal.mesh' },
|
||||
{ name: 'https://spam.example promo', domain: 'spam.mesh' },
|
||||
{ domain: 'unnamed.mesh' }
|
||||
];
|
||||
|
||||
assert.equal(shouldDisplayFederationInstance(entries[0]), true);
|
||||
assert.equal(shouldDisplayFederationInstance(entries[1]), false);
|
||||
assert.deepEqual(filterDisplayableFederationInstances(entries), [
|
||||
{ name: 'Normal Mesh', domain: 'normal.mesh' },
|
||||
{ domain: 'unnamed.mesh' }
|
||||
]);
|
||||
assert.equal(resolveFederationSiteNameForDisplay(entries[0]), 'Normal Mesh');
|
||||
assert.equal(resolveFederationInstanceLabel(entries[2]), 'unnamed.mesh');
|
||||
assert.equal(resolveFederationInstanceSortValue(entries[0]), 'Normal Mesh');
|
||||
});
|
||||
@@ -21,6 +21,74 @@ import { createDomEnvironment } from './dom-environment.js';
|
||||
import { initializeFederationPage } from '../federation-page.js';
|
||||
import { roleColors } from '../role-helpers.js';
|
||||
|
||||
function createBasicFederationPageHarness() {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
|
||||
const { document, createElement, registerElement } = env;
|
||||
|
||||
const mapEl = createElement('div', 'map');
|
||||
registerElement('map', mapEl);
|
||||
const statusEl = createElement('div', 'status');
|
||||
registerElement('status', statusEl);
|
||||
const tableEl = createElement('table', 'instances');
|
||||
const tbodyEl = createElement('tbody');
|
||||
registerElement('instances', tableEl);
|
||||
tableEl.appendChild(tbodyEl);
|
||||
const configEl = createElement('div');
|
||||
configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 }));
|
||||
|
||||
document.querySelector = selector => {
|
||||
if (selector === '[data-app-config]') return configEl;
|
||||
if (selector === '#instances tbody') return tbodyEl;
|
||||
return null;
|
||||
};
|
||||
|
||||
return { ...env, statusEl, tbodyEl };
|
||||
}
|
||||
|
||||
function createBasicLeafletStub(options = {}) {
|
||||
const { markerPopups = null, fitBounds = false } = options;
|
||||
|
||||
return {
|
||||
map() {
|
||||
return {
|
||||
setView() {},
|
||||
on() {},
|
||||
fitBounds: fitBounds ? () => {} : undefined,
|
||||
getPane() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
},
|
||||
tileLayer() {
|
||||
return {
|
||||
addTo() {
|
||||
return this;
|
||||
},
|
||||
getContainer() {
|
||||
return null;
|
||||
},
|
||||
on() {}
|
||||
};
|
||||
},
|
||||
layerGroup() {
|
||||
return {
|
||||
addLayer() {},
|
||||
addTo() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
},
|
||||
circleMarker() {
|
||||
return {
|
||||
bindPopup(html) {
|
||||
markerPopups?.push(html);
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('federation map centers on configured coordinates and follows theme filters', async () => {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: true });
|
||||
const { document, window, createElement, registerElement, cleanup } = env;
|
||||
@@ -603,57 +671,141 @@ test('federation legend toggle respects media query changes', async () => {
|
||||
});
|
||||
|
||||
test('federation page tolerates fetch failures', async () => {
|
||||
const { cleanup } = createBasicFederationPageHarness();
|
||||
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
|
||||
const leafletStub = createBasicLeafletStub();
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('federation page suppresses spammy site names and truncates long names in visible UI', async () => {
|
||||
const { cleanup, statusEl, tbodyEl } = createBasicFederationPageHarness();
|
||||
const markerPopups = [];
|
||||
const leafletStub = createBasicLeafletStub({ markerPopups, fitBounds: true });
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
domain: 'visible.mesh',
|
||||
name: 'abcdefghijklmnopqrstuvwxyz1234567890',
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 30
|
||||
},
|
||||
{
|
||||
domain: 'spam.mesh',
|
||||
name: 'www.spam.example buy now',
|
||||
latitude: 2,
|
||||
longitude: 2,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 60
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
|
||||
|
||||
assert.equal(statusEl.textContent, '1 instances');
|
||||
assert.equal(tbodyEl.childNodes.length, 1);
|
||||
assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
|
||||
assert.doesNotMatch(tbodyEl.childNodes[0].innerHTML, /spam\.mesh/);
|
||||
assert.equal(markerPopups.length, 1);
|
||||
assert.match(markerPopups[0], /abcdefghijklmnopqrstuvwxyz123\.\.\./);
|
||||
assert.doesNotMatch(markerPopups[0], /www\.spam\.example/);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('federation page sorts by full site names before truncating visible labels', async () => {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
|
||||
const { document, createElement, registerElement, cleanup } = env;
|
||||
const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123';
|
||||
|
||||
const mapEl = createElement('div', 'map');
|
||||
registerElement('map', mapEl);
|
||||
const statusEl = createElement('div', 'status');
|
||||
registerElement('status', statusEl);
|
||||
|
||||
const tableEl = createElement('table', 'instances');
|
||||
const tbodyEl = createElement('tbody');
|
||||
registerElement('instances', tableEl);
|
||||
tableEl.appendChild(tbodyEl);
|
||||
|
||||
const headerNameTh = createElement('th');
|
||||
const headerName = createElement('span');
|
||||
headerName.classList.add('sort-header');
|
||||
headerName.dataset.sortKey = 'name';
|
||||
headerName.dataset.sortLabel = 'Name';
|
||||
headerNameTh.appendChild(headerName);
|
||||
|
||||
const ths = [headerNameTh];
|
||||
const headers = [headerName];
|
||||
const headerHandlers = new Map();
|
||||
headers.forEach(header => {
|
||||
header.addEventListener = (event, handler) => {
|
||||
const existing = headerHandlers.get(header) || {};
|
||||
existing[event] = handler;
|
||||
headerHandlers.set(header, existing);
|
||||
};
|
||||
header.closest = () => ths.find(th => th.childNodes.includes(header));
|
||||
header.querySelector = () => null;
|
||||
});
|
||||
|
||||
tableEl.querySelectorAll = selector => {
|
||||
if (selector === 'thead .sort-header[data-sort-key]') return headers;
|
||||
if (selector === 'thead th') return ths;
|
||||
return [];
|
||||
};
|
||||
|
||||
const configEl = createElement('div');
|
||||
configEl.setAttribute('data-app-config', JSON.stringify({}));
|
||||
configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 }));
|
||||
|
||||
document.querySelector = selector => {
|
||||
if (selector === '[data-app-config]') return configEl;
|
||||
if (selector === '#instances tbody') return tbodyEl;
|
||||
return null;
|
||||
};
|
||||
|
||||
const leafletStub = {
|
||||
map() {
|
||||
return {
|
||||
setView() {},
|
||||
on() {},
|
||||
getPane() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
},
|
||||
tileLayer() {
|
||||
return {
|
||||
addTo() {
|
||||
return this;
|
||||
},
|
||||
getContainer() {
|
||||
return null;
|
||||
},
|
||||
on() {}
|
||||
};
|
||||
},
|
||||
layerGroup() {
|
||||
return { addLayer() {}, addTo() { return this; } };
|
||||
},
|
||||
circleMarker() {
|
||||
return { bindPopup() { return this; } };
|
||||
}
|
||||
};
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
domain: 'zeta.mesh',
|
||||
name: `${sharedPrefix}zeta suffix`,
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 30
|
||||
},
|
||||
{
|
||||
domain: 'alpha.mesh',
|
||||
name: `${sharedPrefix}alpha suffix`,
|
||||
latitude: 2,
|
||||
longitude: 2,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 60
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
try {
|
||||
await initializeFederationPage({
|
||||
config: {},
|
||||
fetchImpl,
|
||||
leaflet: createBasicLeafletStub({ fitBounds: true })
|
||||
});
|
||||
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
|
||||
cleanup();
|
||||
const nameHandlers = headerHandlers.get(headerName);
|
||||
nameHandlers.click();
|
||||
assert.match(tbodyEl.childNodes[0].innerHTML, /alpha\.mesh/);
|
||||
assert.match(tbodyEl.childNodes[1].innerHTML, /zeta\.mesh/);
|
||||
assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
|
||||
assert.match(tbodyEl.childNodes[1].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -154,6 +154,75 @@ test('initializeInstanceSelector populates options alphabetically and selects th
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector hides suppressed names and truncates long labels', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
const navLink = env.document.createElement('a');
|
||||
navLink.classList.add('js-federation-nav');
|
||||
navLink.textContent = 'Federation';
|
||||
env.document.body.appendChild(navLink);
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [
|
||||
{ name: 'Visit https://spam.example now', domain: 'spam.mesh' },
|
||||
{ name: 'abcdefghijklmnopqrstuvwxyz1234567890', domain: 'long.mesh' },
|
||||
{ name: 'Alpha Mesh', domain: 'alpha.mesh' }
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeInstanceSelector({
|
||||
selectElement: select,
|
||||
fetchImpl,
|
||||
windowObject: env.window,
|
||||
documentObject: env.document
|
||||
});
|
||||
|
||||
assert.equal(select.options.length, 3);
|
||||
assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
|
||||
assert.equal(select.options[2].textContent, 'Alpha Mesh');
|
||||
assert.equal(navLink.textContent, 'Federation (2)');
|
||||
assert.equal(select.options.some(option => option.value === 'spam.mesh'), false);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector sorts by full site names before truncating labels', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123';
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [
|
||||
{ name: `${sharedPrefix}zeta suffix`, domain: 'zeta.mesh' },
|
||||
{ name: `${sharedPrefix}alpha suffix`, domain: 'alpha.mesh' }
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeInstanceSelector({
|
||||
selectElement: select,
|
||||
fetchImpl,
|
||||
windowObject: env.window,
|
||||
documentObject: env.document
|
||||
});
|
||||
|
||||
assert.equal(select.options[1].value, 'alpha.mesh');
|
||||
assert.equal(select.options[2].value, 'zeta.mesh');
|
||||
assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
|
||||
assert.equal(select.options[2].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeInstanceSelector navigates to the chosen instance domain', async () => {
|
||||
const env = createDomEnvironment();
|
||||
const select = setupSelectElement(env.document);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const MAX_VISIBLE_SITE_NAME_LENGTH = 32;
|
||||
const TRUNCATION_SUFFIX = '...';
|
||||
const TRUNCATED_SITE_NAME_LENGTH = MAX_VISIBLE_SITE_NAME_LENGTH - TRUNCATION_SUFFIX.length;
|
||||
const SUPPRESSED_SITE_NAME_PATTERN = /(?:^|[^a-z0-9])(?:https?:\/\/|www\.)\S+/i;
|
||||
|
||||
/**
|
||||
* Read a federated instance site name as a trimmed string.
|
||||
*
|
||||
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Trimmed site name or an empty string when absent.
|
||||
*/
|
||||
function readSiteName(entry) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a federated instance domain as a trimmed string.
|
||||
*
|
||||
* @param {{ domain?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Trimmed domain or an empty string when absent.
|
||||
*/
|
||||
function readDomain(entry) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return typeof entry.domain === 'string' ? entry.domain.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a remote site name should be suppressed from frontend displays.
|
||||
*
|
||||
* @param {string} name Remote site name.
|
||||
* @returns {boolean} true when the name contains a URL-like advertising token.
|
||||
*/
|
||||
export function isSuppressedFederationSiteName(name) {
|
||||
if (typeof name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SUPPRESSED_SITE_NAME_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate an instance site name for frontend display without mutating source data.
|
||||
*
|
||||
* Names longer than 32 characters are shortened to stay within that 32-character
|
||||
* budget including the trailing ellipsis.
|
||||
*
|
||||
* @param {string} name Remote site name.
|
||||
* @returns {string} Display-ready site name.
|
||||
*/
|
||||
export function truncateFederationSiteName(name) {
|
||||
if (typeof name !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length <= MAX_VISIBLE_SITE_NAME_LENGTH) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${trimmed.slice(0, TRUNCATED_SITE_NAME_LENGTH)}${TRUNCATION_SUFFIX}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an instance should remain visible in frontend federation views.
|
||||
*
|
||||
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {boolean} true when the entry should be shown to users.
|
||||
*/
|
||||
export function shouldDisplayFederationInstance(entry) {
|
||||
return !isSuppressedFederationSiteName(readSiteName(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a frontend display name for a federation instance.
|
||||
*
|
||||
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Display-ready site name or an empty string when absent.
|
||||
*/
|
||||
export function resolveFederationSiteNameForDisplay(entry) {
|
||||
const siteName = readSiteName(entry);
|
||||
return siteName ? truncateFederationSiteName(siteName) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original trimmed site name for a federation instance.
|
||||
*
|
||||
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Full trimmed site name or an empty string when absent.
|
||||
*/
|
||||
export function resolveFederationSiteName(entry) {
|
||||
return readSiteName(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the full sort value for an instance selector entry.
|
||||
*
|
||||
* Sorting must use the original trimmed site name so truncation does not collapse
|
||||
* multiple entries into the same comparison key.
|
||||
*
|
||||
* @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Full trimmed site name falling back to the domain.
|
||||
*/
|
||||
export function resolveFederationInstanceSortValue(entry) {
|
||||
const siteName = resolveFederationSiteName(entry);
|
||||
return siteName || readDomain(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the most suitable display label for an instance list entry.
|
||||
*
|
||||
* @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry.
|
||||
* @returns {string} Display label falling back to the domain.
|
||||
*/
|
||||
export function resolveFederationInstanceLabel(entry) {
|
||||
const siteName = resolveFederationSiteNameForDisplay(entry);
|
||||
if (siteName) {
|
||||
return siteName;
|
||||
}
|
||||
|
||||
return readDomain(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a federation payload down to the instances that should remain visible.
|
||||
*
|
||||
* @param {Array<object>} entries Federation payload from the API.
|
||||
* @returns {Array<object>} Visible instances for frontend rendering.
|
||||
*/
|
||||
export function filterDisplayableFederationInstances(entries) {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries.filter(shouldDisplayFederationInstance);
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
MAX_VISIBLE_SITE_NAME_LENGTH,
|
||||
TRUNCATION_SUFFIX,
|
||||
TRUNCATED_SITE_NAME_LENGTH,
|
||||
readDomain,
|
||||
readSiteName,
|
||||
SUPPRESSED_SITE_NAME_PATTERN
|
||||
};
|
||||
@@ -15,6 +15,11 @@
|
||||
*/
|
||||
|
||||
import { readAppConfig } from './config.js';
|
||||
import {
|
||||
filterDisplayableFederationInstances,
|
||||
resolveFederationSiteName,
|
||||
resolveFederationSiteNameForDisplay
|
||||
} from './federation-instance-display.js';
|
||||
import { resolveLegendVisibility } from './map-legend-visibility.js';
|
||||
import { mergeConfig } from './settings.js';
|
||||
import { roleColors } from './role-helpers.js';
|
||||
@@ -274,7 +279,12 @@ export async function initializeFederationPage(options = {}) {
|
||||
? true
|
||||
: legendCollapsedValue.trim() !== 'false';
|
||||
const tableSorters = {
|
||||
name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
name: {
|
||||
getValue: inst => resolveFederationSiteName(inst),
|
||||
compare: compareString,
|
||||
hasValue: hasStringValue,
|
||||
defaultDirection: 'asc'
|
||||
},
|
||||
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
@@ -363,7 +373,8 @@ export async function initializeFederationPage(options = {}) {
|
||||
for (const instance of sorted) {
|
||||
const tr = document.createElement('tr');
|
||||
const url = buildInstanceUrl(instance.domain);
|
||||
const nameHtml = instance.name ? escapeHtml(instance.name) : '<em>—</em>';
|
||||
const displayName = resolveFederationSiteNameForDisplay(instance);
|
||||
const nameHtml = displayName ? escapeHtml(displayName) : '<em>—</em>';
|
||||
const domainHtml = url
|
||||
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
|
||||
: escapeHtml(instance.domain || '');
|
||||
@@ -529,7 +540,7 @@ export async function initializeFederationPage(options = {}) {
|
||||
credentials: 'omit'
|
||||
});
|
||||
if (response.ok) {
|
||||
instances = await response.json();
|
||||
instances = filterDisplayableFederationInstances(await response.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch federation instances', err);
|
||||
@@ -636,7 +647,8 @@ export async function initializeFederationPage(options = {}) {
|
||||
|
||||
bounds.push([lat, lon]);
|
||||
|
||||
const name = instance.name || instance.domain || 'Unknown';
|
||||
const displayName = resolveFederationSiteNameForDisplay(instance);
|
||||
const name = displayName || instance.domain || 'Unknown';
|
||||
const url = buildInstanceUrl(instance.domain);
|
||||
const nodeCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
|
||||
const popupLines = [
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
filterDisplayableFederationInstances,
|
||||
resolveFederationInstanceLabel,
|
||||
resolveFederationInstanceSortValue
|
||||
} from './federation-instance-display.js';
|
||||
|
||||
/**
|
||||
* Determine the most suitable label for an instance list entry.
|
||||
*
|
||||
@@ -21,17 +27,7 @@
|
||||
* @returns {string} Preferred display label falling back to the domain.
|
||||
*/
|
||||
function resolveInstanceLabel(entry) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||
if (name.length > 0) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const domain = typeof entry.domain === 'string' ? entry.domain.trim() : '';
|
||||
return domain;
|
||||
return resolveFederationInstanceLabel(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,23 +202,21 @@ export async function initializeInstanceSelector(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateFederationNavCount({ documentObject: doc, count: payload.length });
|
||||
const visibleEntries = filterDisplayableFederationInstances(payload);
|
||||
updateFederationNavCount({ documentObject: doc, count: visibleEntries.length });
|
||||
|
||||
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
|
||||
|
||||
const sortedEntries = payload
|
||||
const sortedEntries = visibleEntries
|
||||
.filter(entry => entry && typeof entry.domain === 'string' && entry.domain.trim() !== '')
|
||||
.map(entry => ({
|
||||
domain: entry.domain.trim(),
|
||||
label: resolveInstanceLabel(entry),
|
||||
sortLabel: resolveFederationInstanceSortValue(entry),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const labelA = a.label || a.domain;
|
||||
const labelB = b.label || b.domain;
|
||||
const labelA = a.sortLabel || a.domain;
|
||||
const labelB = b.sortLabel || b.domain;
|
||||
return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user