diff --git a/app/static/js/repeaters.js b/app/static/js/repeaters.js
index e5bbe5b..35fec42 100644
--- a/app/static/js/repeaters.js
+++ b/app/static/js/repeaters.js
@@ -91,6 +91,15 @@ function esc(s) {
.replaceAll('"', '"');
}
+// 'AB→CD→EF' offers the browser no line-break opportunity, so a multi-hop path
+// runs off a narrow screen. A zero-width space after each arrow lets it wrap by
+// hop. Apply after esc() — the result is HTML.
+const RPT_ZWSP = String.fromCharCode(0x200B);
+
+function wrapPath(s) {
+ return String(s ?? '').replaceAll('→', '→' + RPT_ZWSP);
+}
+
// Relative time comes from datetime-utils.js as formatTimeAgo(). The local copy this
// replaced also had a "Never" branch for a falsy timestamp, which was unreachable — the
// single call site already guards it. contacts.js and dm.js have since been folded in
@@ -165,11 +174,13 @@ function renderRepeaterRows() {
? ''
: '';
+ // The path line wraps (no text-truncate) so a long path stays readable
+ // on a phone; the other two variants are short and stay on one line.
const statusLine = isLoggingIn
- ? `${tHtml('repeaters.row.logging_in')}`
+ ? `${tHtml('repeaters.row.logging_in')}`
: (r.on_device
- ? `${esc(r.path_or_mode || '—')}`
- : `${tHtml('repeaters.row.not_on_device')}`);
+ ? `${wrapPath(esc(r.path_or_mode || '—'))}`
+ : `${tHtml('repeaters.row.not_on_device')}`);
// "last login" goes on its own line so a long path keeps the full row
// width to itself (on narrow phones it otherwise gets truncated hard).
const roleLine = (r.on_device && !isLoggingIn && r.last_login_at)
@@ -181,7 +192,7 @@ function renderRepeaterRows() {
${icon}
${esc(r.name || r.public_key.substring(0, 12))}
-
${statusLine}
+ ${statusLine}
${roleLine}
@@ -461,7 +472,7 @@ function renderDeviceRepeaterList() {
${esc(c.name || c.public_key_prefix)}
-
${esc(c.public_key_prefix)} · ${esc(c.path_or_mode || '')}
+
${esc(c.public_key_prefix)} · ${wrapPath(esc(c.path_or_mode || ''))}
${added
? `
${tHtml('repeaters.added_badge')}`
@@ -503,7 +514,7 @@ function openPathModal(pubkey) {
if (!r) return;
_pathPubkey = pubkey;
document.getElementById('pathModalName').textContent = r.name || pubkey.substring(0, 12);
- document.getElementById('pathModalCurrent').textContent = r.path_or_mode || '—';
+ document.getElementById('pathModalCurrent').textContent = wrapPath(r.path_or_mode || '—');
_pathModal.show();
renderPathList(pubkey);
}
@@ -514,7 +525,7 @@ async function refreshDevicePathDisplay() {
await loadRepeaters();
const r = findRepeater(_pathPubkey);
const el = document.getElementById('pathModalCurrent');
- if (el && r) el.textContent = r.path_or_mode || '—';
+ if (el && r) el.textContent = wrapPath(r.path_or_mode || '—');
}
async function renderPathList(pubkey) {
diff --git a/app/templates/repeaters.html b/app/templates/repeaters.html
index 275f4a5..33ca517 100644
--- a/app/templates/repeaters.html
+++ b/app/templates/repeaters.html
@@ -87,10 +87,18 @@
opacity: 0.65;
}
+ /* Paths wrap per hop (zero-width space from wrapPath()); this is the
+ fallback for a single hop token too long for the line. */
.rpt-path {
font-size: 0.8rem;
}
+ .rpt-path,
+ .rpt-path-wrap,
+ #pathModalCurrent {
+ overflow-wrap: anywhere;
+ }
+
.empty-state {
text-align: center;
color: var(--text-secondary, #6c757d);