diff --git a/app/static/js/console.js b/app/static/js/console.js
index 782ec63..4ef48ae 100644
--- a/app/static/js/console.js
+++ b/app/static/js/console.js
@@ -60,7 +60,7 @@ function connectWebSocket() {
updateStatus('disconnected');
enableInput(false);
// Transient session event — show inline but don't persist to transcript
- addMessage(t('console.status.disconnected'), 'error', false);
+ addMessage(t('common.disconnected'), 'error', false);
// Clear pending command indicator
if (pendingCommandDiv) {
@@ -245,15 +245,15 @@ function updateStatus(status) {
switch (status) {
case 'connected':
- text.textContent = t('console.status.connected');
+ text.textContent = t('common.connected');
text.className = 'text-success';
break;
case 'disconnected':
- text.textContent = t('console.status.disconnected');
+ text.textContent = t('common.disconnected');
text.className = 'text-danger';
break;
case 'connecting':
- text.textContent = t('console.status.connecting');
+ text.textContent = t('common.connecting');
text.className = 'text-warning';
break;
}
diff --git a/app/static/js/dm.js b/app/static/js/dm.js
index 55b6556..599af83 100644
--- a/app/static/js/dm.js
+++ b/app/static/js/dm.js
@@ -76,7 +76,7 @@ let chatSocket = null; // SocketIO connection to /chat namespace
* Get display-friendly name (truncate full pubkeys to short prefix)
*/
function displayName(name) {
- if (!name) return 'Unknown';
+ if (!name) return t('common.unknown');
if (/^[0-9a-f]{12,64}$/i.test(name)) return name.substring(0, 8) + '...';
return name;
}
@@ -111,7 +111,7 @@ function resolveConversationName(conversationId) {
// Fallback
if (conversationId && conversationId.startsWith('name_')) return conversationId.substring(5);
if (conversationId && conversationId.startsWith('pk_')) return conversationId.substring(3, 11) + '...';
- return 'Unknown';
+ return t('common.unknown');
}
let chatSocketEverConnected = false;
@@ -196,8 +196,8 @@ function connectChatSocket() {
statusEl.className = 'bi bi-check2 dm-status delivered';
const tooltip = [];
if (data.snr != null) tooltip.push(`SNR: ${data.snr}`);
- if (data.route_type) tooltip.push(`Route: ${data.route_type}`);
- statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : 'Delivered';
+ if (data.route_type) tooltip.push(t('dm.route', { route: data.route_type }));
+ statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : t('dm.status.delivered');
// Unwrap status icon from wrapper span
const wrapper = statusEl.closest('[data-dm-id]');
if (wrapper) {
@@ -211,7 +211,7 @@ function connectChatSocket() {
chatSocket.on('dm_retry_status', (data) => {
if (!data.dm_id) return;
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
- if (info) info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`;
+ if (info) info.textContent = t('dm.attempt', { n: data.attempt, max: data.max_attempts });
});
// DM retry exhausted — mark as failed, show final attempt count
@@ -223,7 +223,7 @@ function connectChatSocket() {
const icon = wrapper.querySelector('.dm-status');
if (icon) {
icon.className = 'bi bi-x-circle dm-status timeout';
- icon.title = 'Delivery failed — all retries exhausted';
+ icon.title = t('dm.status.failed');
}
wrapper.removeAttribute('onclick');
wrapper.classList.remove('dm-status-unknown');
@@ -232,7 +232,7 @@ function connectChatSocket() {
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (info) {
if (data.attempt && data.max_attempts) {
- info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`;
+ info.textContent = t('dm.attempt', { n: data.attempt, max: data.max_attempts });
} else {
info.textContent = '';
}
@@ -250,9 +250,9 @@ function connectChatSocket() {
if (!msgDiv) return;
// Build delivery meta text
const parts = [];
- if (data.attempt && data.max_attempts) parts.push(`Attempt ${data.attempt}/${data.max_attempts}`);
+ if (data.attempt && data.max_attempts) parts.push(t('dm.attempt', { n: data.attempt, max: data.max_attempts }));
const hexRoute = formatDmRoute(data.path, data.hash_size);
- if (hexRoute) parts.push(`Route: ${hexRoute}`);
+ if (hexRoute) parts.push(t('dm.route', { route: hexRoute }));
if (parts.length > 0) {
let metaEl = msgDiv.querySelector('.dm-delivery-meta');
if (!metaEl) {
@@ -660,7 +660,7 @@ function renderDropdownItems(query) {
if (filteredConvs.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-dropdown-separator';
- sep.textContent = 'Recent conversations';
+ sep.textContent = t('dm.dropdown.recent');
dropdown.appendChild(sep);
filteredConvs.forEach(item => {
@@ -672,7 +672,7 @@ function renderDropdownItems(query) {
if (filteredContacts.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-dropdown-separator';
- sep.textContent = 'Contacts';
+ sep.textContent = t('dm.dropdown.contacts');
dropdown.appendChild(sep);
filteredContacts.forEach(contact => {
@@ -686,7 +686,7 @@ function renderDropdownItems(query) {
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
const empty = document.createElement('div');
empty.className = 'dm-dropdown-separator text-center';
- empty.textContent = q ? 'No matches' : 'No contacts available';
+ empty.textContent = q ? t('dm.dropdown.no_matches') : t('dm.dropdown.no_contacts');
dropdown.appendChild(empty);
}
}
@@ -747,7 +747,7 @@ function populateDmSidebar(query) {
if (filteredConvs.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
- sep.textContent = 'Recent conversations';
+ sep.textContent = t('dm.dropdown.recent');
list.appendChild(sep);
filteredConvs.forEach(item => {
@@ -759,7 +759,7 @@ function populateDmSidebar(query) {
if (filteredContacts.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
- sep.textContent = 'Contacts';
+ sep.textContent = t('dm.dropdown.contacts');
list.appendChild(sep);
filteredContacts.forEach(contact => {
@@ -773,7 +773,7 @@ function populateDmSidebar(query) {
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
const empty = document.createElement('div');
empty.className = 'dm-sidebar-separator text-center';
- empty.textContent = q ? 'No matches' : 'No contacts available';
+ empty.textContent = q ? t('dm.dropdown.no_matches') : t('dm.dropdown.no_contacts');
list.appendChild(empty);
}
}
@@ -923,7 +923,7 @@ async function selectConversation(conversationId) {
const sendBtn = document.getElementById('dmSendBtn');
if (input) {
input.disabled = false;
- input.placeholder = `Message ${displayName(currentRecipient)}...`;
+ input.placeholder = t('dm.msg_input_ph', { name: displayName(currentRecipient) });
}
if (sendBtn) {
sendBtn.disabled = false;
@@ -960,7 +960,7 @@ function clearConversation() {
const sendBtn = document.getElementById('dmSendBtn');
if (input) {
input.disabled = true;
- input.placeholder = 'Type a message...';
+ input.placeholder = t('chat.input_ph');
input.value = '';
}
if (sendBtn) {
@@ -973,8 +973,8 @@ function clearConversation() {
container.innerHTML = `
-
Select a conversation
- Choose from the list or start a new chat from channel messages
+
${tHtml('dm.empty.select')}
+ ${tHtml('dm.empty.select_hint')}
`;
}
@@ -1013,17 +1013,9 @@ function findCurrentContact() {
return findCurrentContactByConvId(currentConversationId);
}
-/**
- * Minimal relative time formatter.
- */
-function formatRelativeTimeDm(timestamp) {
- if (!timestamp) return 'Never';
- const diff = Math.floor(Date.now() / 1000) - timestamp;
- if (diff < 60) return 'Just now';
- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
- return `${Math.floor(diff / 86400)}d ago`;
-}
+// Relative time comes from datetime-utils.js as formatTimeAgo(). This was the last of
+// the four local copies. The contact-info row uses the long form to match the contacts
+// page; the map popup uses the short form to match the My Repeaters map picker.
/**
* Populate the Contact Info modal body.
@@ -1034,7 +1026,7 @@ function populateContactInfoModal() {
const contact = findCurrentContact();
if (!contact) {
- body.innerHTML = '
No contact information available.
';
+ body.innerHTML = `
${tHtml('dm.no_contact_info')}
`;
return;
}
@@ -1063,11 +1055,11 @@ function populateContactInfoModal() {
keyDiv.className = 'text-muted small font-monospace mb-2';
keyDiv.style.cursor = 'pointer';
keyDiv.textContent = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
- keyDiv.title = 'Click to copy full public key';
+ keyDiv.title = t('dm.copy_pubkey_title');
keyDiv.onclick = () => {
const pk = contact.public_key || contact.public_key_prefix || '';
navigator.clipboard.writeText(pk).then(() => {
- showNotification('Public key copied', 'info');
+ showNotification(t('contacts.toast.pubkey_copied'), 'info');
}).catch(() => {});
};
body.appendChild(keyDiv);
@@ -1081,7 +1073,7 @@ function populateContactInfoModal() {
else if (diff < 3600) icon = '🟡';
const div = document.createElement('div');
div.className = 'small mb-2';
- div.textContent = `${icon} Last advert: ${formatRelativeTimeDm(ts)}`;
+ div.textContent = `${icon} ${t('contacts.last_advert', { time: formatTimeAgo(ts, { long: true }) })}`;
body.appendChild(div);
}
@@ -1103,9 +1095,9 @@ function populateContactInfoModal() {
const pathHex = contact.out_path ? contact.out_path.substring(0, hopCount * hashSize * 2) : '';
div.innerHTML = `
- ${mode} (${hops} hops)
+ ${escapeHtml(mode)} (${tn('contacts.hops', hops)})
${pathHex ? `` : ''}
@@ -1125,6 +1117,9 @@ function populateContactInfoModal() {
body: JSON.stringify({
path_hex: pathHex,
hash_size: hashSize,
+ // Stored in the DB, not rendered chrome - it stays
+ // English so the row does not change meaning when
+ // the operator switches the UI language later.
label: 'Device path',
is_primary: true
})
@@ -1132,12 +1127,12 @@ function populateContactInfoModal() {
const data = await response.json();
if (data.success) {
await renderPathList(pubkey);
- showNotification('Device path imported', 'info');
+ showNotification(t('dm.toast.device_path_imported'), 'info');
} else {
- showNotification(data.error || 'Import failed', 'danger');
+ showNotification(data.error || t('dm.toast.import_failed'), 'danger');
}
} catch (e) {
- showNotification('Import failed', 'danger');
+ showNotification(t('dm.toast.import_failed'), 'danger');
}
});
}
@@ -1209,7 +1204,7 @@ async function loadMessages() {
// Always update placeholder with best known name
const msgInput = document.getElementById('dmMessageInput');
if (msgInput) {
- msgInput.placeholder = `Message ${displayName(currentRecipient)}...`;
+ msgInput.placeholder = t('dm.msg_input_ph', { name: displayName(currentRecipient) });
}
// Keep search input in sync
const searchInput = document.getElementById('dmContactSearchInput');
@@ -1225,11 +1220,11 @@ async function loadMessages() {
updateLastRefresh();
} else {
- container.innerHTML = '