Files
mc-webui/app/static/js/dm.js
T
MarekWoandClaude Opus 5 8f1334ee00 fix(map): reject impossible advert coordinates before they blank the map
A tester's cached-contacts map came up empty and Path Analyzer drew routes
across the globe. Two of his 2131 cached contacts carried garbage positions
from corrupted adverts - lat 1642 / lon -323 and lat 309 / lon -1768.

Leaflet clamps latitude when projecting but not longitude, so fitBounds()
spanned 1797 degrees, dropped to zoom 0 and centred on lon -870; all 2273
real markers were rendered several world-widths outside the viewport.
Reproduced against his DB: zoom 0, bounds -1431..-309. The device-only view
was fine because both bad rows are cache-only.

Four layers, so no single miss can blank a map again:

- app/geo.py: one definition of a usable coordinate, applied on write in
  Database.upsert_contact() so the cache stops storing garbage, and on API
  output so a cache poisoned by an older build heals itself with no
  migration. Corrupted advert types (61, 126, 152, 210 seen in the wild)
  are logged but kept raw - they already render as UNKNOWN.
- static/js/geo-utils.js: hasValidGps()/withValidGps()/fitMapToPoints()
  replace eight copies of the same check across six files. Path Analyzer
  had a looser variant that also accepted lon == 0, which put nodes off
  west Africa and stretched routes across the map; it now shares the rule.
- Per-marker try/catch and a bounds validity check, so an entry that still
  slips through costs its own pin rather than the whole map.

Verified against the tester's DB in a container: cached map 2273 markers,
lon span 1797 -> 19 degrees, centred on Poland; the route that drew the
world-spanning line (msg 29499, token 72AF) now renders as a real
Krakow-Kielce path with the bad hop left unresolved. Live local instance
regression-checked on all five map pages. tests/test_geo.py: 43 passed;
full suite 290 passed, 2 pre-existing failures unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:35:53 +02:00

3113 lines
113 KiB
JavaScript

/**
* mc-webui Direct Messages JavaScript
* Full-page DM view functionality
*/
// --- Settings caches (standalone page — no app.js on this route) ---
const DM_CHAT_SETTINGS_DEFAULTS = {
path_popup_timeout_sec: 8,
path_popup_no_autoclose: false
};
const DM_UI_SETTINGS_DEFAULTS = {
toast_timeout_sec: 2,
toast_no_autoclose: false,
toast_position: 'top-left'
};
const DM_TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const DM_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
window.chatSettingsCache = window.chatSettingsCache || { ...DM_CHAT_SETTINGS_DEFAULTS };
window.uiSettingsCache = window.uiSettingsCache || { ...DM_UI_SETTINGS_DEFAULTS };
function dmApplyToastPosition(position) {
const classes = DM_TOAST_POSITION_CLASSES[position] || DM_TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
DM_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
async function loadDmChatSettings() {
try {
const resp = await fetch('/api/chat/settings');
if (resp.ok) {
const data = await resp.json();
window.chatSettingsCache = { ...DM_CHAT_SETTINGS_DEFAULTS, ...data };
}
} catch (e) {
console.error('Failed to load chat settings:', e);
}
}
async function loadDmUiSettings() {
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
window.uiSettingsCache = { ...DM_UI_SETTINGS_DEFAULTS, ...data };
dmApplyToastPosition(window.uiSettingsCache.toast_position);
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
}
// State variables
let currentConversationId = null;
let currentRecipient = null;
let dmConversations = [];
let contactsList = []; // List of detailed contact objects from device
let contactsMap = {}; // Map of public_key -> contact object
let dmLastSeenTimestamps = {};
let autoRefreshInterval = null;
let lastMessageTimestamp = 0; // Track latest message timestamp for smart refresh
let chatSocket = null; // SocketIO connection to /chat namespace
let dmIsUserScrolling = false; // User scrolled up — don't yank them to the bottom
// "New messages" divider state — mirrors app.js, see unreadAnchorTs there.
let dmUnreadAnchorTs = null;
let renderedConversationId = null; // Conversation displayMessages last rendered
/**
* Get display-friendly name (truncate full pubkeys to short prefix)
*/
function displayName(name) {
if (!name) return t('common.unknown');
if (/^[0-9a-f]{12,64}$/i.test(name)) return name.substring(0, 8) + '...';
return name;
}
/**
* Check if a string looks like a hex pubkey (not a real name)
*/
function isPubkey(name) {
return !name || /^[0-9a-f]{8,64}$/i.test(name) || /^[0-9a-f]{6,}\.\.\./i.test(name);
}
/**
* Resolve the best display name for a conversation ID.
* Priority: contactsList (device) > conversations API > pubkey fallback.
*/
function resolveConversationName(conversationId) {
// Try device contacts first (most reliable source of names)
const contact = findCurrentContactByConvId(conversationId);
if (contact && contact.name && !isPubkey(contact.name)) return contact.name;
// Try conversations list
let conv = dmConversations.find(c => c.conversation_id === conversationId);
if (!conv && conversationId && conversationId.startsWith('pk_')) {
const prefix = conversationId.substring(3);
conv = dmConversations.find(c =>
c.conversation_id.startsWith('pk_') &&
(c.conversation_id.substring(3).startsWith(prefix) || prefix.startsWith(c.conversation_id.substring(3)))
);
}
if (conv && conv.display_name && !isPubkey(conv.display_name)) return conv.display_name;
// Fallback
if (conversationId && conversationId.startsWith('name_')) return conversationId.substring(5);
if (conversationId && conversationId.startsWith('pk_')) return conversationId.substring(3, 11) + '...';
return t('common.unknown');
}
let chatSocketEverConnected = false;
let resyncInFlight = false;
/**
* Re-read the DM lists from the server.
*
* This view is push-driven, so a socket that drops while the phone sleeps
* leaves it showing whatever arrived last - the 60s poll eventually catches up,
* but only once its timer un-throttles. Every path back from a gap comes here.
*/
async function resyncFromServer(reason) {
if (resyncInFlight) return;
resyncInFlight = true;
console.log(`DM: [resync] refreshing after ${reason}`);
try {
await loadConversations();
if (currentConversationId) await loadMessages();
await loadStatus();
} catch (error) {
console.error('DM: [resync] failed:', error);
} finally {
resyncInFlight = false;
}
}
/**
* Connect to SocketIO /chat namespace for real-time DM and ACK updates
*/
function connectChatSocket() {
if (typeof io === 'undefined') {
console.warn('SocketIO not available, falling back to polling only');
return;
}
const wsUrl = window.location.origin;
// Default transports — see the note in app.js connectChatSocket().
chatSocket = io(wsUrl + '/chat', {
reconnection: true,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000,
});
chatSocket.on('connect', () => {
console.log('DM: SocketIO connected to /chat');
// Whatever arrived while the socket was down was never pushed to us;
// only re-reading the lists brings it back (see resyncFromServer)
if (chatSocketEverConnected) resyncFromServer('socket reconnect');
chatSocketEverConnected = true;
});
chatSocket.on('disconnect', (reason) => {
console.log('DM: SocketIO disconnected:', reason);
});
// Real-time new DM message
chatSocket.on('new_message', (data) => {
if (data.type !== 'dm') return;
const msgPubkey = data.contact_pubkey || '';
const currentPubkey = currentConversationId ? currentConversationId.replace('pk_', '') : '';
if (currentPubkey && msgPubkey.startsWith(currentPubkey)) {
// Message is for current conversation — reload messages
loadMessages();
} else {
// Message is for a different conversation — refresh conversation list
loadConversations();
}
});
// Real-time ACK delivery confirmation
chatSocket.on('ack', (data) => {
if (!data.expected_ack) return;
// Find message with matching expected_ack in DOM and update status
const msgElements = document.querySelectorAll('#dmMessagesList .dm-message-wrapper.own');
msgElements.forEach(el => {
const statusEl = el.querySelector(`.dm-status[data-ack="${data.expected_ack}"]`);
if (statusEl) {
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(t('chat.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) {
wrapper.replaceWith(statusEl);
}
}
});
});
// Real-time DM retry progress
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 = t('dm.attempt', { n: data.attempt, max: data.max_attempts });
});
// DM retry exhausted — mark as failed, show final attempt count
chatSocket.on('dm_retry_failed', (data) => {
if (!data.dm_id) return;
// Update status icon
const wrapper = document.querySelector(`.dm-status-unknown[data-dm-id="${data.dm_id}"]`);
if (wrapper) {
const icon = wrapper.querySelector('.dm-status');
if (icon) {
icon.className = 'bi bi-x-circle dm-status timeout';
icon.title = t('dm.status.failed');
}
wrapper.removeAttribute('onclick');
wrapper.classList.remove('dm-status-unknown');
}
// Show final attempt count
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (info) {
if (data.attempt && data.max_attempts) {
info.textContent = t('dm.attempt', { n: data.attempt, max: data.max_attempts });
} else {
info.textContent = '';
}
}
});
// Real-time delivery info — show attempt count + route after successful delivery
chatSocket.on('dm_delivered_info', (data) => {
if (!data.dm_id) return;
// Find the message element containing this dm_id
const retryEl = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (!retryEl) return;
retryEl.textContent = '';
const msgDiv = retryEl.closest('.dm-message');
if (!msgDiv) return;
// Build delivery meta text
const parts = [];
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(t('chat.route', { route: hexRoute }));
if (parts.length > 0) {
let metaEl = msgDiv.querySelector('.dm-delivery-meta');
if (!metaEl) {
metaEl = document.createElement('div');
metaEl.className = 'dm-delivery-meta';
const contentDiv = msgDiv.querySelector('div:nth-child(2)');
if (contentDiv) contentDiv.after(metaEl);
}
metaEl.textContent = parts.join(', ');
}
});
// Real-time device status
chatSocket.on('device_status', (data) => {
updateStatus(data.connected ? 'connected' : 'disconnected');
});
// Real-time path change — always refresh contactsList, re-render modal if open
chatSocket.on('path_changed', async (data) => {
await refreshContactInfoPath();
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', async function() {
console.log('DM page initialized');
// Force viewport recalculation on PWA navigation
// This fixes the bottom bar visibility issue when navigating from main page
window.scrollTo(0, 0);
// Trigger resize event to force browser to recalculate viewport height
window.dispatchEvent(new Event('resize'));
// Force reflow to ensure proper layout calculation
document.body.offsetHeight;
// Load settings caches (path popup timeout + toast behavior/position)
loadDmChatSettings();
loadDmUiSettings();
// Load last seen timestamps from server
await loadDmLastSeenTimestampsFromServer();
// Setup event listeners
setupEventListeners();
// Setup emoji picker
setupEmojiPicker();
// Load conversations into dropdown
await loadConversations();
// Load connection status
await loadStatus();
// Check for initial conversation from URL parameter, or restore last active conversation
if (window.MC_CONFIG && window.MC_CONFIG.initialConversation) {
const convId = window.MC_CONFIG.initialConversation;
// Find the conversation in the list or use the ID directly
selectConversation(convId);
} else {
// Restore last selected conversation from localStorage
const savedConversation = localStorage.getItem('mc_active_dm_conversation');
if (savedConversation) {
selectConversation(savedConversation);
}
}
// Initialize filter functionality
initializeDmFilter();
// Initialize FAB toggle
initializeDmFabToggle();
// Settings FAB - open parent's settings modal
const dmSettingsFab = document.getElementById('dmSettingsFab');
if (dmSettingsFab) {
dmSettingsFab.addEventListener('click', () => {
if (window.parent && window.parent !== window) {
window.parent.postMessage({type: 'openSettings'}, '*');
}
});
}
// Load auto-retry config
loadAutoRetryConfig();
// Connect SocketIO for real-time updates
connectChatSocket();
// Setup auto-refresh (fallback, 60s interval since SocketIO handles primary updates)
setupAutoRefresh();
});
// Handle page restoration from cache (PWA back/forward navigation)
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
// Page was restored from cache, force viewport recalculation
console.log('Page restored from cache, recalculating viewport');
window.scrollTo(0, 0);
window.dispatchEvent(new Event('resize'));
document.body.offsetHeight;
}
});
// Handle app returning from background (PWA visibility change)
let hiddenSince = null;
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
hiddenSince = Date.now();
return;
}
// App became visible again, force viewport recalculation
console.log('App became visible, recalculating viewport');
setTimeout(() => {
window.scrollTo(0, 0);
window.dispatchEvent(new Event('resize'));
document.body.offsetHeight;
}, 100);
// Long enough away for the socket to have dropped updates - catch up
// rather than wait for the next 60s poll
const away = hiddenSince ? Date.now() - hiddenSince : 0;
hiddenSince = null;
if (chatSocket && !chatSocket.connected) chatSocket.connect();
if (away > 10000) resyncFromServer('back from background');
});
/**
* Setup event listeners
*/
function setupEventListeners() {
// Searchable contact input
const searchInput = document.getElementById('dmContactSearchInput');
const contactDropdown = document.getElementById('dmContactDropdown');
const searchWrapper = document.getElementById('dmContactSearchWrapper');
if (searchInput && contactDropdown) {
searchInput.addEventListener('focus', () => {
renderDropdownItems(searchInput.value);
contactDropdown.style.display = 'block';
});
searchInput.addEventListener('input', () => {
renderDropdownItems(searchInput.value);
contactDropdown.style.display = 'block';
});
// Prevent dropdown mousedown from stealing focus/closing dropdown
contactDropdown.addEventListener('mousedown', (e) => {
e.preventDefault();
});
// Close dropdown when clicking outside the wrapper
document.addEventListener('mousedown', (e) => {
if (searchWrapper && !searchWrapper.contains(e.target)) {
contactDropdown.style.display = 'none';
}
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
contactDropdown.style.display = 'none';
searchInput.blur();
} else if (e.key === 'Enter') {
e.preventDefault();
const active = contactDropdown.querySelector('.dm-contact-item.active');
const target = active || contactDropdown.querySelector('.dm-contact-item');
if (target) target.click();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const items = Array.from(contactDropdown.querySelectorAll('.dm-contact-item'));
if (items.length === 0) return;
const activeIdx = items.findIndex(el => el.classList.contains('active'));
items.forEach(el => el.classList.remove('active'));
let nextIdx;
if (e.key === 'ArrowDown') {
nextIdx = activeIdx < 0 ? 0 : Math.min(activeIdx + 1, items.length - 1);
} else {
nextIdx = activeIdx <= 0 ? 0 : activeIdx - 1;
}
items[nextIdx].classList.add('active');
items[nextIdx].scrollIntoView({ block: 'nearest' });
}
});
}
// Clear search button
const clearBtn = document.getElementById('dmClearSearchBtn');
if (clearBtn && searchInput && contactDropdown) {
clearBtn.addEventListener('click', () => {
searchInput.value = '';
renderDropdownItems('');
contactDropdown.style.display = 'block';
searchInput.focus();
});
}
// Contact info button
const infoBtn = document.getElementById('dmContactInfoBtn');
if (infoBtn) {
infoBtn.addEventListener('click', () => {
const modal = new bootstrap.Modal(document.getElementById('dmContactInfoModal'));
populateContactInfoModal();
loadPathSection();
modal.show();
});
}
// Send form
const sendForm = document.getElementById('dmSendForm');
if (sendForm) {
sendForm.addEventListener('submit', async function(e) {
e.preventDefault();
await sendMessage();
});
}
// Message input
const input = document.getElementById('dmMessageInput');
if (input) {
input.addEventListener('input', updateCharCounter);
// Enter key to send (desktop only - touch devices send via the button
// so an accidental Enter on the virtual keyboard doesn't fire a half-typed message)
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey && !isTouchPrimaryDevice()) {
e.preventDefault();
sendMessage();
}
});
}
// Scroll-to-bottom button
const messagesContainer = document.getElementById('dmMessagesContainer');
const scrollToBottomBtn = document.getElementById('dmScrollToBottomBtn');
if (messagesContainer && scrollToBottomBtn) {
messagesContainer.addEventListener('scroll', function() {
const isAtBottom = messagesContainer.scrollHeight - messagesContainer.scrollTop <= messagesContainer.clientHeight + 100;
dmIsUserScrolling = !isAtBottom;
if (isAtBottom) {
scrollToBottomBtn.classList.remove('visible');
} else {
scrollToBottomBtn.classList.add('visible');
}
});
scrollToBottomBtn.addEventListener('click', function() {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
scrollToBottomBtn.classList.remove('visible');
});
}
// DM Sidebar search input (lg+ screens)
const sidebarSearch = document.getElementById('dmSidebarSearch');
if (sidebarSearch) {
sidebarSearch.addEventListener('input', () => {
populateDmSidebar(sidebarSearch.value);
});
}
// Desktop info button (lg+ screens)
const desktopInfoBtn = document.getElementById('dmDesktopInfoBtn');
if (desktopInfoBtn) {
desktopInfoBtn.addEventListener('click', () => {
const modal = new bootstrap.Modal(document.getElementById('dmContactInfoModal'));
populateContactInfoModal();
loadPathSection();
modal.show();
});
}
}
/**
* Load contacts from device
*/
async function loadContacts() {
try {
const response = await fetch('/api/contacts/detailed');
const data = await response.json();
if (data.success) {
contactsList = (data.contacts || []).sort((a, b) =>
(a.name || '').localeCompare(b.name || ''));
contactsMap = {};
contactsList.forEach(c => {
if (c.public_key) contactsMap[c.public_key] = c;
});
console.log(`[DM] Loaded ${contactsList.length} device contacts`);
} else {
console.error('[DM] Failed to load contacts:', data.error);
contactsList = [];
contactsMap = {};
}
} catch (error) {
console.error('[DM] Error loading contacts:', error);
contactsList = [];
contactsMap = {};
}
}
/* -----------------------------------------------------------------------------
Share card environment (DM chat)
The DM panel is an iframe, so it cannot reach the parent page's contact
caches, channel list or map modal. It keeps its own small snapshot instead.
Contract and consumers live in message-utils.js.
-------------------------------------------------------------------------- */
// Cache-only contacts, the channel slots, and our own position — everything the
// cards need to describe themselves accurately.
let _dmShareCachedContacts = {}; // pubkey -> { name, source }
let _dmShareChannels = []; // [{ index, name, key }]
let _dmSharePosition = null; // { lat, lon } or null
async function loadDmShareEnvironment() {
try {
const [cachedResp, channelsResp, configResp] = await Promise.all([
fetch('/api/contacts/cached?format=full'),
fetch('/api/channels'),
fetch('/api/device/config')
]);
const cachedData = await cachedResp.json();
_dmShareCachedContacts = {};
if (cachedData.success && cachedData.contacts) {
cachedData.contacts.forEach(c => {
const pk = (c.public_key || '').toLowerCase();
if (pk) _dmShareCachedContacts[pk] = { name: c.name || '', source: c.source || 'advert' };
});
}
const channelsData = await channelsResp.json();
_dmShareChannels = (channelsData.success && channelsData.channels) || [];
// /api/device/config exposes the advertised coords as plain lat/lon.
const configData = await configResp.json();
const cfg = (configData.success && configData.config) || {};
_dmSharePosition = MCShare.isValidLatLon(cfg.lat, cfg.lon)
? { lat: cfg.lat, lon: cfg.lon } : null;
} catch (error) {
// Non-fatal: cards still render, they just fall back to "not known yet".
console.error('[DM] Error loading share environment:', error);
}
}
window.MCShareHooks = {
lookupContact(pubkey) {
if (!pubkey) return null;
const pk = pubkey.toLowerCase();
// Device memory wins over the cache snapshot.
const onDevice = contactsMap[pk];
if (onDevice) return { name: onDevice.name || '', source: 'device' };
return _dmShareCachedContacts[pk] || null;
},
lookupChannel({ name, secret }) {
if (secret) {
const bySecret = _dmShareChannels.find(
ch => (ch.key || '').toLowerCase() === secret.toLowerCase());
if (bySecret) return { index: bySecret.index, name: bySecret.name, matched: 'secret' };
}
const byName = _dmShareChannels.find(
ch => (ch.name || '').toLowerCase() === (name || '').toLowerCase());
if (byName) {
const matched = (!secret && MCShare.channelKindOf(name) === 'hashtag')
? 'secret' : 'name';
return { index: byName.index, name: byName.name, matched };
}
return null;
},
selfPosition() {
return _dmSharePosition;
},
/**
* Show a shared position on this panel's own map. Deliberately not a
* postMessage to the parent's map modal: the DM panel is itself displayed
* inside a modal, and stacking one Bootstrap modal over another across a
* frame boundary needs the backdrop z-index workaround. A local modal keeps
* it self-contained — the same reason contacts.js carries its own copy.
*/
openMap(label, lat, lon) {
showSharedLocationOnMap(label, lat, lon);
},
async onContactsChanged() {
await Promise.all([loadContacts(), loadDmShareEnvironment()]);
},
async onChannelJoined() {
// No channel UI in this panel; refresh the snapshot so the card updates.
await loadDmShareEnvironment();
}
// No applyChannelScope: region scopes are managed from the channel panel,
// and the toast would appear behind the DM modal anyway.
};
// Leaflet state for the shared-location map (separate from the repeater map).
let _shareMap = null;
let _shareMapMarker = null;
/**
* Center this panel's map modal on a shared position.
* Leaflet cannot measure a hidden container, hence the one-shot
* shown.bs.modal + invalidateSize() dance used elsewhere in the app.
*/
function showSharedLocationOnMap(label, lat, lon) {
const modalEl = document.getElementById('shareMapModal');
if (!modalEl) return;
const titleEl = document.getElementById('shareMapModalTitle');
if (titleEl) titleEl.textContent = label || t('share.card.open_in_map');
modalEl.addEventListener('shown.bs.modal', function onShown() {
modalEl.removeEventListener('shown.bs.modal', onShown);
if (!_shareMap) {
_shareMap = L.map('shareLocationMap');
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(_shareMap);
}
if (_shareMapMarker) _shareMap.removeLayer(_shareMapMarker);
_shareMapMarker = L.marker([lat, lon])
.addTo(_shareMap)
.bindPopup(`<b>${escapeHtml(label || '')}</b>`)
.openPopup();
_shareMap.setView([lat, lon], 14);
_shareMap.invalidateSize();
});
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
/**
* Load conversations from API
*/
async function loadConversations() {
try {
// Load conversations, contacts and the share-card environment in parallel
const [convResponse, _, __] = await Promise.all([
fetch('/api/dm/conversations?days=7'),
loadContacts(),
loadDmShareEnvironment()
]);
const convData = await convResponse.json();
if (convData.success) {
dmConversations = convData.conversations || [];
populateConversationSelector();
} else {
console.error('Failed to load conversations:', convData.error);
// Still populate selector with just contacts
populateConversationSelector();
}
} catch (error) {
console.error('Error loading conversations:', error);
}
}
/**
* Populate the searchable conversation dropdown data.
* Two sections: recent conversations (by recency) + device contacts (alphabetical).
*/
function populateConversationSelector() {
// Build conversation entries with contact data
const convPubkeyPrefixes = new Set();
const conversations = dmConversations.map(conv => {
// Extract pubkey prefix from conversation_id
let pkPrefix = '';
if (conv.conversation_id.startsWith('pk_')) {
pkPrefix = conv.conversation_id.substring(3);
}
convPubkeyPrefixes.add(pkPrefix);
const lastSeen = dmLastSeenTimestamps[conv.conversation_id] || 0;
const isUnread = conv.last_message_timestamp > lastSeen;
// Find matching device contact
const contact = pkPrefix
? contactsList.find(c => c.public_key && c.public_key.startsWith(pkPrefix))
: contactsList.find(c => c.name === conv.display_name);
return {
conversationId: conv.conversation_id,
name: conv.display_name,
isUnread,
contact: contact || null,
};
});
// Device contacts without existing conversations
const contacts = contactsList.filter(c => {
const prefix = (c.public_key_prefix || c.public_key?.substring(0, 12) || '');
return !convPubkeyPrefixes.has(prefix);
});
window._dmDropdownItems = { conversations, contacts };
renderDropdownItems('');
// Also populate DM sidebar (lg+ screens), preserving current search filter
const sidebarSearch = document.getElementById('dmSidebarSearch');
populateDmSidebar(sidebarSearch ? sidebarSearch.value : '');
// Update search input if conversation is selected — re-resolve name in case contacts loaded
if (currentConversationId) {
const bestName = resolveConversationName(currentConversationId);
if (!isPubkey(bestName)) currentRecipient = bestName;
const input = document.getElementById('dmContactSearchInput');
if (input) input.value = displayName(currentRecipient);
updateDmDesktopHeader();
}
}
/**
* Render dropdown items filtered by search query.
*/
function renderDropdownItems(query) {
const dropdown = document.getElementById('dmContactDropdown');
if (!dropdown) return;
dropdown.innerHTML = '';
const q = query.toLowerCase().trim();
// If query looks like a pubkey hex, don't filter — show all items instead
const qIsPubkey = /^[0-9a-f]{6,}\.{0,3}$/i.test(q);
const { conversations = [], contacts = [] } = window._dmDropdownItems || {};
const filteredConvs = (q && !qIsPubkey)
? conversations.filter(item => (item.name || '').toLowerCase().includes(q))
: conversations;
const filteredContacts = (q && !qIsPubkey)
? contacts.filter(c => (c.name || '').toLowerCase().includes(q))
: contacts;
if (filteredConvs.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-dropdown-separator';
sep.textContent = t('dm.dropdown.recent');
dropdown.appendChild(sep);
filteredConvs.forEach(item => {
dropdown.appendChild(createDropdownItem(
item.name, item.conversationId, item.isUnread, item.contact));
});
}
if (filteredContacts.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-dropdown-separator';
sep.textContent = t('dm.dropdown.contacts');
dropdown.appendChild(sep);
filteredContacts.forEach(contact => {
const prefix = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
const convId = `pk_${prefix}`;
dropdown.appendChild(createDropdownItem(
contact.name, convId, false, contact));
});
}
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
const empty = document.createElement('div');
empty.className = 'dm-dropdown-separator text-center';
empty.textContent = q ? t('dm.dropdown.no_matches') : t('dm.dropdown.no_contacts');
dropdown.appendChild(empty);
}
}
/**
* Create a single dropdown item element.
*/
function createDropdownItem(name, conversationId, isUnread, contact) {
const el = document.createElement('div');
el.className = 'dm-contact-item';
if (isUnread) {
const dot = document.createElement('span');
dot.style.cssText = 'color: #0d6efd; font-weight: bold;';
dot.textContent = '*';
el.appendChild(dot);
}
const nameSpan = document.createElement('span');
nameSpan.className = 'contact-name';
nameSpan.textContent = displayName(name);
el.appendChild(nameSpan);
if (contact && contact.type_label) {
const badge = document.createElement('span');
badge.className = 'badge';
const colors = { COM: 'bg-primary', REP: 'bg-success', ROOM: 'bg-info', SENS: 'bg-warning' };
badge.classList.add(colors[contact.type_label] || 'bg-secondary');
badge.textContent = contact.type_label;
el.appendChild(badge);
}
el.addEventListener('click', () => selectConversationFromDropdown(conversationId, name));
return el;
}
/**
* Populate the DM sidebar (visible on lg+ screens).
* Mirrors the dropdown data structure but renders as a persistent list.
*/
function populateDmSidebar(query) {
const list = document.getElementById('dmSidebarList');
if (!list) return;
list.innerHTML = '';
const q = (query || '').toLowerCase().trim();
const { conversations = [], contacts = [] } = window._dmDropdownItems || {};
const filteredConvs = q
? conversations.filter(item => (item.name || '').toLowerCase().includes(q))
: conversations;
const filteredContacts = q
? contacts.filter(c => (c.name || '').toLowerCase().includes(q))
: contacts;
if (filteredConvs.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
sep.textContent = t('dm.dropdown.recent');
list.appendChild(sep);
filteredConvs.forEach(item => {
list.appendChild(createSidebarItem(
item.name, item.conversationId, item.isUnread, item.contact));
});
}
if (filteredContacts.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
sep.textContent = t('dm.dropdown.contacts');
list.appendChild(sep);
filteredContacts.forEach(contact => {
const prefix = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
const convId = `pk_${prefix}`;
list.appendChild(createSidebarItem(
contact.name, convId, false, contact));
});
}
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
const empty = document.createElement('div');
empty.className = 'dm-sidebar-separator text-center';
empty.textContent = q ? t('dm.dropdown.no_matches') : t('dm.dropdown.no_contacts');
list.appendChild(empty);
}
}
/**
* Create a single sidebar item element for the DM sidebar.
*/
function createSidebarItem(name, conversationId, isUnread, contact) {
const el = document.createElement('div');
el.className = 'dm-sidebar-item';
el.dataset.conversationId = conversationId;
if (conversationId === currentConversationId) {
el.classList.add('active');
}
if (isUnread) {
const dot = document.createElement('span');
dot.className = 'sidebar-unread-dot';
el.appendChild(dot);
}
const nameSpan = document.createElement('span');
nameSpan.className = 'contact-name';
nameSpan.textContent = displayName(name);
el.appendChild(nameSpan);
if (contact && contact.type_label) {
const badge = document.createElement('span');
badge.className = 'badge';
const colors = { COM: 'bg-primary', REP: 'bg-success', ROOM: 'bg-info', SENS: 'bg-warning' };
badge.classList.add(colors[contact.type_label] || 'bg-secondary');
badge.textContent = contact.type_label;
el.appendChild(badge);
}
el.addEventListener('click', () => selectConversationFromSidebar(conversationId, name));
return el;
}
/**
* Handle selection from the DM sidebar.
*/
async function selectConversationFromSidebar(conversationId, name) {
await selectConversation(conversationId);
if (name && !isPubkey(name)) currentRecipient = name;
updateDmSidebarActive();
// Move focus to message input
const msgInput = document.getElementById('dmMessageInput');
if (msgInput && !msgInput.disabled) msgInput.focus();
}
/**
* Update active state on DM sidebar items.
*/
function updateDmSidebarActive() {
const list = document.getElementById('dmSidebarList');
if (!list) return;
list.querySelectorAll('.dm-sidebar-item').forEach(item => {
const convId = item.dataset.conversationId;
// Flexible matching: handle prefix upgrades
let isActive = convId === currentConversationId;
if (!isActive && currentConversationId && convId) {
// Match if one is a prefix of the other (pk_ based)
if (convId.startsWith('pk_') && currentConversationId.startsWith('pk_')) {
const a = convId.substring(3);
const b = currentConversationId.substring(3);
isActive = a.startsWith(b) || b.startsWith(a);
}
}
item.classList.toggle('active', isActive);
});
}
/**
* Update the desktop contact header (visible on lg+ screens).
*/
function updateDmDesktopHeader() {
const nameEl = document.getElementById('dmDesktopContactName');
const infoBtn = document.getElementById('dmDesktopInfoBtn');
if (!nameEl) return;
if (currentRecipient) {
nameEl.textContent = displayName(currentRecipient);
if (infoBtn) infoBtn.disabled = false;
} else {
nameEl.textContent = '';
if (infoBtn) infoBtn.disabled = true;
}
}
/**
* Handle selection from the searchable dropdown.
*/
async function selectConversationFromDropdown(conversationId, name) {
const dropdown = document.getElementById('dmContactDropdown');
if (dropdown) dropdown.style.display = 'none';
await selectConversation(conversationId);
// Override search input with the known name (selectConversation may not resolve it)
const input = document.getElementById('dmContactSearchInput');
if (input && name) input.value = displayName(name);
if (name && !isPubkey(name)) currentRecipient = name;
// Move focus to message input for immediate typing
const msgInput = document.getElementById('dmMessageInput');
if (msgInput && !msgInput.disabled) msgInput.focus();
}
/**
* Select a conversation
*/
async function selectConversation(conversationId) {
currentConversationId = conversationId;
// Save to localStorage for next visit
localStorage.setItem('mc_active_dm_conversation', conversationId);
// Upgrade to full conversation_id if prefix match found
if (conversationId.startsWith('pk_')) {
const prefix = conversationId.substring(3);
const conv = dmConversations.find(c =>
c.conversation_id.startsWith('pk_') &&
(c.conversation_id.substring(3).startsWith(prefix) || prefix.startsWith(c.conversation_id.substring(3)))
);
if (conv) {
conversationId = conv.conversation_id;
currentConversationId = conversationId;
localStorage.setItem('mc_active_dm_conversation', conversationId);
}
}
// Resolve name: prefer device contacts over backend data
currentRecipient = resolveConversationName(conversationId);
// Update search input
const searchInput = document.getElementById('dmContactSearchInput');
if (searchInput) searchInput.value = displayName(currentRecipient);
// Show clear button and enable info button
const clearBtn = document.getElementById('dmClearSearchBtn');
if (clearBtn) clearBtn.style.display = '';
const infoBtn = document.getElementById('dmContactInfoBtn');
if (infoBtn) infoBtn.disabled = false;
// Enable input
const input = document.getElementById('dmMessageInput');
const sendBtn = document.getElementById('dmSendBtn');
if (input) {
input.disabled = false;
input.placeholder = t('dm.msg_input_ph', { name: displayName(currentRecipient) });
}
if (sendBtn) {
sendBtn.disabled = false;
}
// Update desktop header and sidebar (lg+ screens)
updateDmDesktopHeader();
updateDmSidebarActive();
// Load messages
await loadMessages();
}
/**
* Clear conversation selection
*/
function clearConversation() {
currentConversationId = null;
currentRecipient = null;
// Clear from localStorage
localStorage.removeItem('mc_active_dm_conversation');
// Reset search input, hide clear button, disable info button
const searchInput = document.getElementById('dmContactSearchInput');
if (searchInput) searchInput.value = '';
const clearBtn = document.getElementById('dmClearSearchBtn');
if (clearBtn) clearBtn.style.display = 'none';
const infoBtn = document.getElementById('dmContactInfoBtn');
if (infoBtn) infoBtn.disabled = true;
// Disable input
const input = document.getElementById('dmMessageInput');
const sendBtn = document.getElementById('dmSendBtn');
if (input) {
input.disabled = true;
input.placeholder = t('chat.input_ph');
input.value = '';
}
if (sendBtn) {
sendBtn.disabled = true;
}
// Show empty state
const container = document.getElementById('dmMessagesList');
if (container) {
container.innerHTML = `
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">${tHtml('dm.empty.select')}</p>
<small class="text-muted">${tHtml('dm.empty.select_hint')}</small>
</div>
`;
}
// Update desktop header and sidebar
updateDmDesktopHeader();
updateDmSidebarActive();
updateCharCounter();
}
/**
* Find contact object matching a conversation ID.
*/
function findCurrentContactByConvId(convId) {
if (!convId) return null;
let pkPrefix = '';
if (convId.startsWith('pk_')) {
pkPrefix = convId.substring(3);
}
if (pkPrefix) {
return contactsList.find(c => c.public_key && c.public_key.startsWith(pkPrefix)) || null;
}
// Fallback: match by name
if (convId.startsWith('name_')) {
const name = convId.substring(5);
return contactsList.find(c => c.name === name) || null;
}
return null;
}
/**
* Find current contact from contactsList.
*/
function findCurrentContact() {
return findCurrentContactByConvId(currentConversationId);
}
// 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.
*/
function populateContactInfoModal() {
const body = document.getElementById('dmContactInfoBody');
if (!body) return;
const contact = findCurrentContact();
if (!contact) {
body.innerHTML = `<p class="text-muted">${tHtml('dm.no_contact_info')}</p>`;
return;
}
body.innerHTML = '';
// Name + type badge
const nameRow = document.createElement('div');
nameRow.className = 'd-flex align-items-center gap-2 mb-3';
const nameEl = document.createElement('h6');
nameEl.className = 'mb-0';
nameEl.textContent = contact.name;
nameRow.appendChild(nameEl);
if (contact.type_label) {
const badge = document.createElement('span');
badge.className = 'badge';
const colors = { COM: 'bg-primary', REP: 'bg-success', ROOM: 'bg-info', SENS: 'bg-warning' };
badge.classList.add(colors[contact.type_label] || 'bg-secondary');
badge.textContent = contact.type_label;
nameRow.appendChild(badge);
}
body.appendChild(nameRow);
// Public key
const keyDiv = document.createElement('div');
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 = t('dm.copy_pubkey_title');
keyDiv.onclick = () => {
const pk = contact.public_key || contact.public_key_prefix || '';
copyTextToClipboard(pk).then(() => {
showNotification(t('contacts.toast.pubkey_copied'), 'info');
}).catch(() => {});
};
body.appendChild(keyDiv);
// Last advert
if (contact.last_seen || contact.last_advert) {
const ts = contact.last_seen || contact.last_advert;
const diff = Math.floor(Date.now() / 1000) - ts;
let icon = '🔴';
if (diff < 300) icon = '🟢';
else if (diff < 3600) icon = '🟡';
const div = document.createElement('div');
div.className = 'small mb-2';
div.textContent = `${icon} ${t('contacts.last_advert', { time: formatTimeAgo(ts, { long: true }) })}`;
body.appendChild(div);
}
// Path/route (device path)
if (contact.path_or_mode) {
const div = document.createElement('div');
div.className = 'small mb-2 d-flex align-items-center gap-2';
const mode = contact.path_or_mode;
if (mode === 'Flood') {
div.innerHTML = '<span><i class="bi bi-broadcast"></i> Flood</span>';
} else if (mode === 'Direct') {
div.innerHTML = '<span><i class="bi bi-arrow-right-short"></i> Direct</span>';
} else {
const hops = mode.split('→').length;
const outPathLen = contact.out_path_len || 0;
const hashMode = contact.out_path_hash_mode;
const hashSize = (Number.isInteger(hashMode) && hashMode >= 0) ? hashMode + 1 : 1;
const hopCount = outPathLen > 0 ? outPathLen : 0;
const pathHex = contact.out_path ? contact.out_path.substring(0, hopCount * hashSize * 2) : '';
div.innerHTML = `
<span><i class="bi bi-signpost-split"></i> ${escapeHtml(mode)} <span class="text-muted">(${tn('contacts.hops', hops)})</span></span>
${pathHex ? `<button type="button" class="btn btn-outline-primary btn-sm py-0 px-1"
id="dmImportDevicePathBtn" title="${tHtml('dm.import_device_path_title')}"
style="font-size: 0.7rem; line-height: 1.3;">
<i class="bi bi-download"></i>
</button>` : ''}
`;
if (pathHex) {
// Defer event attachment to after DOM insertion
setTimeout(() => {
const importBtn = document.getElementById('dmImportDevicePathBtn');
if (importBtn) {
importBtn.addEventListener('click', async () => {
const pubkey = getCurrentContactPubkey();
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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
})
});
const data = await response.json();
if (data.success) {
await renderPathList(pubkey);
showNotification(t('dm.toast.device_path_imported'), 'info');
} else {
showNotification(data.error || t('dm.toast.import_failed'), 'danger');
}
} catch (e) {
showNotification(t('dm.toast.import_failed'), 'danger');
}
});
}
}, 0);
}
}
body.appendChild(div);
}
// GPS
if (hasValidGps(contact)) {
const div = document.createElement('div');
div.className = 'small mb-2';
div.innerHTML = `<i class="bi bi-geo-alt"></i> ${contact.adv_lat.toFixed(4)}, ${contact.adv_lon.toFixed(4)}`;
body.appendChild(div);
}
}
/**
* Refresh contact data from device and re-render Contact Info modal if open.
* Uses ?refresh=true to bypass server-side cache.
*/
async function refreshContactInfoPath() {
try {
const response = await fetch('/api/contacts/detailed?refresh=true');
const data = await response.json();
if (data.success) {
contactsList = (data.contacts || []).sort((a, b) =>
(a.name || '').localeCompare(b.name || ''));
contactsMap = {};
contactsList.forEach(c => {
if (c.public_key) contactsMap[c.public_key] = c;
});
}
} catch (e) {
console.error('[DM] refreshContactInfoPath fetch error:', e);
return;
}
// Re-populate modal if still open
const modalEl = document.getElementById('dmContactInfoModal');
if (modalEl && modalEl.classList.contains('show')) {
populateContactInfoModal();
loadPathSection();
}
}
/**
* Load messages for current conversation
*/
async function loadMessages() {
if (!currentConversationId) return;
const container = document.getElementById('dmMessagesList');
if (!container) return;
container.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div></div>';
try {
const response = await fetch(`/api/dm/messages?conversation_id=${encodeURIComponent(currentConversationId)}&limit=100`);
const data = await response.json();
if (data.success) {
displayMessages(data.messages);
// Update recipient if backend has a better (non-pubkey) name
if (data.display_name && !isPubkey(data.display_name)) {
currentRecipient = data.display_name;
}
// Always update placeholder with best known name
const msgInput = document.getElementById('dmMessageInput');
if (msgInput) {
msgInput.placeholder = t('dm.msg_input_ph', { name: displayName(currentRecipient) });
}
// Keep search input in sync
const searchInput = document.getElementById('dmContactSearchInput');
if (searchInput && !isPubkey(currentRecipient)) {
searchInput.value = displayName(currentRecipient);
}
// Mark as read
if (data.messages && data.messages.length > 0) {
const latestTs = Math.max(...data.messages.map(m => m.timestamp));
markAsRead(currentConversationId, latestTs);
}
updateLastRefresh();
} else {
container.innerHTML = `<div class="text-center text-danger py-4">${tHtml('dm.load_messages_error')}</div>`;
}
} catch (error) {
console.error('Error loading messages:', error);
container.innerHTML = `<div class="text-center text-danger py-4">${tHtml('chat.toast.load_failed')}</div>`;
}
}
/**
* Display messages in the container
*/
function displayMessages(messages) {
const container = document.getElementById('dmMessagesList');
if (!container) return;
const wasAtBottom = !dmIsUserScrolling;
// Freeze the unread boundary on conversation entry. loadMessages() calls
// markAsRead() right after this returns, so by the next render the stored
// last-seen value is already gone — hence the renderedConversationId guard.
if (renderedConversationId !== currentConversationId) {
const lastSeen = dmLastSeenTimestamps[currentConversationId] || 0;
dmUnreadAnchorTs = lastSeen > 0 ? lastSeen : null;
}
renderedConversationId = currentConversationId;
if (!messages || messages.length === 0) {
container.innerHTML = `
<div class="dm-empty-state">
<i class="bi bi-chat-dots"></i>
<p>${tHtml('chat.empty.no_messages')}</p>
<small class="text-muted">${tHtml('dm.empty.no_messages_hint')}</small>
</div>
`;
lastMessageTimestamp = 0;
return;
}
// Update last message timestamp for smart refresh
lastMessageTimestamp = Math.max(...messages.map(m => m.timestamp));
container.innerHTML = '';
let dividerEl = null;
messages.forEach(msg => {
const side = msg.is_own ? 'own' : 'other';
// "New messages" divider before the first unread incoming message
if (!dividerEl && dmUnreadAnchorTs !== null && !msg.is_own
&& msg.timestamp > dmUnreadAnchorTs) {
dividerEl = createDmUnreadDivider();
container.appendChild(dividerEl);
}
// Wrapper: time row + bubble
const wrapper = document.createElement('div');
wrapper.className = `dm-message-wrapper ${side}`;
// Status icon for own messages
let statusIcon = '';
if (msg.is_own) {
const ackAttr = msg.expected_ack ? ` data-ack="${msg.expected_ack}"` : '';
const dmIdAttr = msg.id ? ` data-dm-id="${msg.id}"` : '';
if (msg.status === 'delivered') {
let title = t('dm.status.delivered');
if (msg.delivery_attempt && msg.delivery_max_attempts) {
title += ` (${msg.delivery_attempt}/${msg.delivery_max_attempts})`;
}
const route = formatDmRoute(msg.delivery_path, msg.delivery_path_hash_size || msg.path_hash_size);
if (route) title += `, ${t('chat.route', { route: route })}`;
else if (msg.delivery_route) title += `, ${msg.delivery_route.replace('PATH_', '')}`;
if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) {
title += `, SNR: ${msg.delivery_snr.toFixed(1)} dB`;
}
if (msg.delivery_route) title += ` (${msg.delivery_route})`;
statusIcon = `<i class="bi bi-check2 dm-status delivered"${ackAttr} title="${title}"></i>`;
} else if (msg.status === 'failed') {
statusIcon = `<span${dmIdAttr}><i class="bi bi-x-circle dm-status timeout"${ackAttr} title="${tHtml('dm.status.failed')}"></i></span>`;
} else if (msg.status === 'pending') {
statusIcon = `<i class="bi bi-clock dm-status pending"${ackAttr} title="${tHtml('dm.status.sending')}"></i>`;
} else {
// No ACK received — show clickable "?" with retry counter
statusIcon = `<span class="dm-status-unknown"${dmIdAttr} onclick="showDeliveryInfo(this)"><i class="bi bi-question-circle dm-status unknown"${ackAttr}></i></span>`;
}
}
// Metadata for incoming messages
let meta = '';
if (!msg.is_own) {
const parts = [];
if (msg.snr !== null && msg.snr !== undefined) {
parts.push(`SNR: ${msg.snr.toFixed(1)}`);
}
if (parts.length > 0) {
meta = `<div class="dm-meta">${parts.join(' | ')}</div>`;
}
}
// Delivery info for delivered/failed messages (attempt count + route)
let deliveryMeta = '';
if (msg.is_own && (msg.status === 'delivered' || msg.status === 'failed')
&& msg.delivery_attempt) {
const parts = [];
if (msg.delivery_attempt && msg.delivery_max_attempts) {
parts.push(t('dm.attempt', { n: msg.delivery_attempt, max: msg.delivery_max_attempts }));
}
// Show route only for delivered messages (not failed)
if (msg.status === 'delivered') {
const routeHtml = buildDmRouteHtml(msg.delivery_path, msg.delivery_path_hash_size || msg.path_hash_size);
if (routeHtml) {
parts.push(routeHtml);
} else if (msg.delivery_route) {
parts.push(msg.delivery_route.replace('PATH_', ''));
}
}
deliveryMeta = `<div class="dm-delivery-meta">${parts.join(', ')}</div>`;
}
// Retry counter placeholder — pre-populate for in-progress sends
let retryInfo = '';
if (msg.is_own) {
const isPending = !msg.status || (msg.status !== 'delivered' && msg.status !== 'failed');
const initialText = isPending && msg.expected_ack ? t('dm.status.sending') : '';
retryInfo = `<div class="dm-delivery-meta dm-retry-info" data-dm-id="${msg.id || ''}">${initialText}</div>`;
}
// Edit-message button for own messages. DM auto-retry handles the
// "true resend" case at the protocol level, so this button stays as
// a paste-to-composer helper. Renamed/icon-swapped to match the
// channel UI's distinction between "edit" (pencil) and the channel-
// only raw resend (arrow-repeat).
const resendBtn = msg.is_own ? `
<div class="dm-actions">
<button class="btn btn-outline-secondary btn-sm dm-action-btn" onclick='resendMessage(${jsArg(msg.content)})' title="${tHtml('chat.edit_title')}">
<i class="bi bi-pencil-square"></i>
</button>
</div>
` : '';
// Time row above bubble
const timeRow = document.createElement('div');
timeRow.className = 'dm-time-row';
timeRow.innerHTML = `<span>${formatTime(msg.timestamp)}</span>${statusIcon}`;
wrapper.appendChild(timeRow);
// Message bubble
const bubble = document.createElement('div');
bubble.className = `dm-message ${side}`;
bubble.innerHTML = `
<div class="dm-content">${processMessageContent(msg.content, { isOwn: !!msg.is_own })}</div>
${deliveryMeta}
${retryInfo}
${meta}
${resendBtn}
`;
wrapper.appendChild(bubble);
container.appendChild(wrapper);
});
// Auto-scroll — but never yank a user who scrolled up to read history,
// which the 60s auto-refresh used to do on every tick.
if (wasAtBottom) {
// Divider in first position means everything loaded is unread; the list
// already starts there, so scrolling would imply history above it.
if (dividerEl && dividerEl !== container.firstElementChild) {
requestAnimationFrame(scrollToDmUnreadDivider);
} else {
scrollDmToBottom();
}
}
// Re-apply filter if active
clearDmFilterState();
}
// Keep in sync with UNREAD_DIVIDER_SCROLL_MARGIN in app.js (separate page, no
// shared module scope).
const DM_UNREAD_DIVIDER_SCROLL_MARGIN = 48;
/**
* Scroll the DM view to the bottom.
*/
function scrollDmToBottom() {
const scrollContainer = document.getElementById('dmMessagesContainer');
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
/**
* Build the "New messages" divider that separates read from unread.
*/
function createDmUnreadDivider() {
const divider = document.createElement('div');
divider.className = 'unread-divider dm-unread-divider';
divider.id = 'dmUnreadDivider';
divider.innerHTML = `<span>${tHtml('chat.unread_divider')}</span>`;
return divider;
}
/**
* Scroll the DM view to the first unread message.
*/
function scrollToDmUnreadDivider() {
const container = document.getElementById('dmMessagesContainer');
const divider = document.getElementById('dmUnreadDivider');
if (!container || !divider) {
scrollDmToBottom();
return;
}
const delta = divider.getBoundingClientRect().top
- container.getBoundingClientRect().top;
container.scrollTop = Math.max(
0,
container.scrollTop + delta - DM_UNREAD_DIVIDER_SCROLL_MARGIN
);
}
/**
* Send a message
*/
async function sendMessage() {
const input = document.getElementById('dmMessageInput');
if (!input) return;
const text = input.value.trim();
if (!text || !currentRecipient) return;
const sendBtn = document.getElementById('dmSendBtn');
if (sendBtn) sendBtn.disabled = true;
try {
const response = await fetch('/api/dm/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient: currentRecipient,
text: text
})
});
const data = await response.json();
if (data.success) {
input.value = '';
updateCharCounter();
showNotification(t('chat.toast.sent'), 'success');
// Sending is an explicit "I'm done catching up" — otherwise a user
// parked at the unread divider would send into a part of the list
// they can't see.
dmIsUserScrolling = false;
// Reload messages once to show sent message
// ACK delivery updates arrive via SocketIO in real-time
await loadMessages();
} else {
showNotification(t('chat.toast.send_failed', { error: data.error }), 'danger');
}
} catch (error) {
console.error('Error sending message:', error);
showNotification(t('chat.toast.send_error'), 'danger');
} finally {
if (sendBtn) sendBtn.disabled = false;
input.focus();
}
}
/**
* Setup intelligent auto-refresh
* Only refreshes UI when new messages arrive
*/
function setupAutoRefresh() {
const checkInterval = 60000; // 60 seconds (SocketIO handles real-time updates)
autoRefreshInterval = setInterval(async () => {
// Reload conversations to update unread indicators
await loadConversations();
// Update connection status
await loadStatus();
// If viewing a conversation, check for new messages
if (currentConversationId) {
await checkForNewMessages();
}
}, checkInterval);
console.log('Intelligent auto-refresh enabled');
}
/**
* Check for new messages without full reload
* Only reloads UI when new messages are detected
*/
async function checkForNewMessages() {
if (!currentConversationId) return;
try {
// Fetch only to check for updates
const response = await fetch(`/api/dm/messages?conversation_id=${encodeURIComponent(currentConversationId)}&limit=1`);
const data = await response.json();
if (data.success && data.messages && data.messages.length > 0) {
const latestTs = data.messages[data.messages.length - 1].timestamp;
// Only reload if there are newer messages
if (latestTs > lastMessageTimestamp) {
console.log('New DM messages detected, refreshing...');
await loadMessages();
}
}
} catch (error) {
console.error('Error checking for new messages:', error);
}
}
/**
* Update character counter (counts UTF-8 bytes, limit is 150)
*/
function updateCharCounter() {
const input = document.getElementById('dmMessageInput');
const counter = document.getElementById('dmCharCounter');
if (!input || !counter) return;
const encoder = new TextEncoder();
const byteLength = encoder.encode(input.value).length;
const maxBytes = 150;
counter.textContent = byteLength;
// Visual warning when approaching limit
if (byteLength >= maxBytes * 0.9) {
counter.classList.add('text-danger');
counter.classList.remove('text-warning', 'text-muted');
} else if (byteLength >= maxBytes * 0.75) {
counter.classList.remove('text-danger', 'text-muted');
counter.classList.add('text-warning');
} else {
counter.classList.remove('text-danger', 'text-warning');
counter.classList.add('text-muted');
}
}
/**
* Resend a message (paste content back to input)
* @param {string} content - Message content to resend
*/
function resendMessage(content) {
const input = document.getElementById('dmMessageInput');
if (!input) return;
input.value = content;
updateCharCounter();
input.focus();
}
/**
* Segment a hex path into hop-sized chunks based on hash_size.
* @param {string} hexPath - raw hex string (e.g. "5e0558d1")
* @param {number} hashSize - bytes per hop hash (1, 2, or 3)
* @returns {string[]} array of uppercase hex segments
*/
function segmentHexPath(hexPath, hashSize) {
if (!hexPath || !/^[0-9a-f]+$/i.test(hexPath)) return [];
const chunkLen = (hashSize || 1) * 2;
const segments = [];
for (let i = 0; i < hexPath.length; i += chunkLen) {
segments.push(hexPath.substring(i, i + chunkLen).toUpperCase());
}
return segments;
}
/**
* Format a hex path as route string (e.g. "5e34e761" → "5E→34→E7→61")
* Truncates if more than 4 segments. Returns '' for non-hex strings.
*/
function formatDmRoute(hexPath, hashSize) {
const segments = segmentHexPath(hexPath, hashSize || 1);
if (segments.length === 0) return '';
if (segments.length > 4) {
return `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`;
}
return segments.join('\u2192');
}
/**
* Build a clickable route span for DM delivery meta.
* Short routes are plain text; long routes (>4 hops) are clickable to show full path.
*/
function buildDmRouteHtml(hexPath, hashSize) {
const segments = segmentHexPath(hexPath, hashSize || 1);
if (segments.length === 0) return '';
const short = segments.length > 4
? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`
: segments.join('\u2192');
if (segments.length <= 4) return tHtml('chat.route', { route: short });
const hs = hashSize || 1;
const escaped = hexPath.replace(/'/g, "\\'");
return `<span class="dm-route-link" onclick="showDmRoutePopup(this, '${escaped}', ${hs})">${tHtml('chat.route', { route: short })}</span>`;
}
/**
* Show full route popup for DM delivery path (same style as channel path popup)
*/
function showDmRoutePopup(element, hexPath, hashSize) {
const existing = document.querySelector('.path-popup');
if (existing) existing.remove();
const segments = segmentHexPath(hexPath, hashSize || 1);
const fullRoute = segments.join(' \u2192 ');
const commaRoute = segments.join(',');
const popup = document.createElement('div');
popup.className = 'path-popup';
const entry = document.createElement('div');
entry.className = 'path-entry';
entry.innerHTML = `${fullRoute}<span class="path-detail">${tHtml('chat.route_hops', { count: segments.length })}</span>`;
entry.title = t('chat.copy_route_title');
entry.addEventListener('click', (e) => {
e.stopPropagation();
copyTextToClipboard(commaRoute).then(() => {
const orig = entry.innerHTML;
entry.innerHTML = `<span style="opacity:0.8">${tHtml('common.copied')}</span>`;
setTimeout(() => { entry.innerHTML = orig; }, 1000);
});
});
popup.appendChild(entry);
element.style.position = 'relative';
element.appendChild(popup);
const dismiss = () => popup.remove();
const cfg = window.chatSettingsCache || {};
const noAutoclose = !!cfg.path_popup_no_autoclose;
const timeoutSec = parseInt(cfg.path_popup_timeout_sec, 10);
if (!noAutoclose) {
const ms = (isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 8) * 1000;
setTimeout(dismiss, ms);
}
document.addEventListener('click', function handler(e) {
if (!element.contains(e.target)) {
dismiss();
document.removeEventListener('click', handler);
}
});
}
/**
* Show delivery info popup (mobile-friendly, same pattern as showPathPopup)
*/
function showDeliveryInfo(element) {
const existing = document.querySelector('.dm-delivery-popup');
if (existing) existing.remove();
const popup = document.createElement('div');
popup.className = 'dm-delivery-popup';
popup.textContent = t('dm.delivery_unknown');
element.style.position = 'relative';
element.appendChild(popup);
const dismiss = () => popup.remove();
setTimeout(dismiss, 5000);
document.addEventListener('click', function handler(e) {
if (!element.contains(e.target)) {
dismiss();
document.removeEventListener('click', handler);
}
});
}
/**
* Setup emoji picker
*/
function setupEmojiPicker() {
const emojiBtn = document.getElementById('dmEmojiBtn');
const emojiPickerPopup = document.getElementById('dmEmojiPickerPopup');
const messageInput = document.getElementById('dmMessageInput');
if (!emojiBtn || !emojiPickerPopup || !messageInput) {
console.log('Emoji picker elements not found');
return;
}
// Create emoji-picker element
const picker = document.createElement('emoji-picker');
// Use local emoji data instead of CDN
picker.dataSource = '/static/vendor/emoji-picker-element-data/en/emojibase/data.json';
emojiPickerPopup.appendChild(picker);
// Toggle emoji picker on button click
emojiBtn.addEventListener('click', function(e) {
e.stopPropagation();
emojiPickerPopup.classList.toggle('hidden');
});
// Insert emoji into input when selected
picker.addEventListener('emoji-click', function(event) {
const emoji = event.detail.unicode;
const cursorPos = messageInput.selectionStart;
const textBefore = messageInput.value.substring(0, cursorPos);
const textAfter = messageInput.value.substring(messageInput.selectionEnd);
// Insert emoji at cursor position
messageInput.value = textBefore + emoji + textAfter;
// Update cursor position (after emoji)
const newCursorPos = cursorPos + emoji.length;
messageInput.setSelectionRange(newCursorPos, newCursorPos);
// Update character counter
updateCharCounter();
// Focus back on input
messageInput.focus();
// Hide picker after selection
emojiPickerPopup.classList.add('hidden');
});
// Close emoji picker when clicking outside
document.addEventListener('click', function(e) {
if (!emojiPickerPopup.contains(e.target) && e.target !== emojiBtn && !emojiBtn.contains(e.target)) {
emojiPickerPopup.classList.add('hidden');
}
});
}
/**
* Load DM last seen timestamps from server
*/
async function loadDmLastSeenTimestampsFromServer() {
try {
const response = await fetch('/api/read_status');
const data = await response.json();
if (data.success && data.dm) {
dmLastSeenTimestamps = data.dm;
console.log('Loaded DM read status from server:', Object.keys(dmLastSeenTimestamps).length, 'conversations');
} else {
console.warn('Failed to load DM read status from server, using empty state');
dmLastSeenTimestamps = {};
}
} catch (error) {
console.error('Error loading DM read status from server:', error);
dmLastSeenTimestamps = {};
}
}
/**
* Save DM read status to server
*/
async function saveDmReadStatus(conversationId, timestamp) {
try {
const response = await fetch('/api/read_status/mark_read', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'dm',
conversation_id: conversationId,
timestamp: timestamp
})
});
const data = await response.json();
if (!data.success) {
console.error('Failed to save DM read status:', data.error);
}
} catch (error) {
console.error('Error saving DM read status:', error);
}
}
/**
* Mark conversation as read
*/
async function markAsRead(conversationId, timestamp) {
dmLastSeenTimestamps[conversationId] = timestamp;
await saveDmReadStatus(conversationId, timestamp);
// Update dropdown to remove unread indicator
populateConversationSelector();
}
/**
* Load connection status
*/
async function loadStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
if (data.success) {
updateStatus(data.connected ? 'connected' : 'disconnected');
}
} catch (error) {
console.error('Error loading status:', error);
updateStatus('disconnected');
}
}
/**
* Update status indicator
*/
function updateStatus(status) {
const statusEl = document.getElementById('dmStatusText');
if (!statusEl) return;
const icons = {
connected: `<i class="bi bi-circle-fill status-connected"></i> ${tHtml('common.connected')}`,
disconnected: `<i class="bi bi-circle-fill status-disconnected"></i> ${tHtml('common.disconnected')}`,
connecting: `<i class="bi bi-circle-fill status-connecting"></i> ${tHtml('common.connecting')}`
};
statusEl.innerHTML = icons[status] || icons.connecting;
}
/**
* Update last refresh time
*/
function updateLastRefresh() {
const el = document.getElementById('dmLastRefresh');
if (el) {
el.textContent = t('chat.updated', { time: new Date().toLocaleTimeString() });
}
}
/**
* Format timestamp to readable time.
* Uses the short date form ("5 Aug") — DM rows are narrower than channel messages.
*/
function formatTime(timestamp) {
return formatTimestamp(timestamp, { dateStyle: 'short' });
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show a toast notification
*/
function showNotification(message, type = 'info') {
const toastEl = document.getElementById('notificationToast');
if (!toastEl) return;
const toastBody = toastEl.querySelector('.toast-body');
if (toastBody) {
toastBody.textContent = message;
}
// Update toast header color based on type
const toastHeader = toastEl.querySelector('.toast-header');
if (toastHeader) {
toastHeader.className = 'toast-header';
if (type === 'success') {
toastHeader.classList.add('bg-success', 'text-white');
} else if (type === 'danger') {
toastHeader.classList.add('bg-danger', 'text-white');
} else if (type === 'warning') {
toastHeader.classList.add('bg-warning');
}
}
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
// =============================================================================
// DM Chat Filter Functionality
// =============================================================================
// Filter state
let dmFilterActive = false;
let currentDmFilterQuery = '';
let originalDmMessageContents = new Map();
/**
* Initialize DM FAB toggle (collapse/expand)
*/
function initializeDmFabToggle() {
const toggle = document.getElementById('dmFabToggle');
const container = document.getElementById('dmFabContainer');
if (!toggle || !container) return;
// Restore collapsed state (shared with main chat)
if (localStorage.getItem('mc-webui-fab-collapsed') === '1') {
container.classList.add('collapsed');
toggle.title = t('chat.fab.show');
}
toggle.addEventListener('click', () => {
container.classList.toggle('collapsed');
const isCollapsed = container.classList.contains('collapsed');
toggle.title = isCollapsed ? t('chat.fab.show') : t('chat.fab.hide');
localStorage.setItem('mc-webui-fab-collapsed', isCollapsed ? '1' : '0');
});
// Drag-and-drop support
initFabDrag('dmFabContainer', 'dmFabToggle', 'mc-webui-fab-pos-dm');
}
/**
* Initialize DM filter functionality
*/
function initializeDmFilter() {
const filterFab = document.getElementById('dmFilterFab');
const filterBar = document.getElementById('dmFilterBar');
const filterInput = document.getElementById('dmFilterInput');
const filterClearBtn = document.getElementById('dmFilterClearBtn');
const filterCloseBtn = document.getElementById('dmFilterCloseBtn');
if (!filterFab || !filterBar) return;
// Open filter bar when FAB clicked
filterFab.addEventListener('click', () => {
openDmFilterBar();
});
// Filter as user types (debounced)
let filterTimeout = null;
filterInput.addEventListener('input', () => {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
applyDmFilter(filterInput.value);
}, 150);
});
// Clear filter
filterClearBtn.addEventListener('click', () => {
filterInput.value = '';
applyDmFilter('');
filterInput.focus();
});
// Close filter bar
filterCloseBtn.addEventListener('click', () => {
closeDmFilterBar();
});
// Keyboard shortcuts
filterInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeDmFilterBar();
}
});
// Global keyboard shortcut: Ctrl+F to open filter
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
openDmFilterBar();
}
});
}
/**
* Open the DM filter bar
*/
function openDmFilterBar() {
const filterBar = document.getElementById('dmFilterBar');
const filterInput = document.getElementById('dmFilterInput');
filterBar.classList.add('visible');
dmFilterActive = true;
setTimeout(() => {
filterInput.focus();
}, 100);
}
/**
* Close the DM filter bar and reset filter
*/
function closeDmFilterBar() {
const filterBar = document.getElementById('dmFilterBar');
const filterInput = document.getElementById('dmFilterInput');
filterBar.classList.remove('visible');
dmFilterActive = false;
filterInput.value = '';
applyDmFilter('');
}
/**
* Apply filter to DM messages
* @param {string} query - Search query
*/
function applyDmFilter(query) {
currentDmFilterQuery = query.trim();
const container = document.getElementById('dmMessagesList');
const messages = container.querySelectorAll('.dm-message-wrapper');
const matchCountEl = document.getElementById('dmFilterMatchCount');
// The divider marks a position in the full list — meaningless once the
// list is filtered down to matches.
const unreadDivider = container.querySelector('.unread-divider');
if (unreadDivider) {
unreadDivider.classList.toggle('filter-hidden', !!currentDmFilterQuery);
}
// Remove any existing no-matches message
const existingNoMatches = container.querySelector('.filter-no-matches');
if (existingNoMatches) {
existingNoMatches.remove();
}
if (!currentDmFilterQuery) {
messages.forEach(msg => {
msg.classList.remove('filter-hidden');
restoreDmOriginalContent(msg);
});
matchCountEl.textContent = '';
return;
}
let matchCount = 0;
messages.forEach((msg, index) => {
// Get text content from DM message
const text = getDmMessageText(msg);
if (FilterUtils.textMatches(text, currentDmFilterQuery)) {
msg.classList.remove('filter-hidden');
matchCount++;
highlightDmMessageContent(msg, index);
} else {
msg.classList.add('filter-hidden');
restoreDmOriginalContent(msg);
}
});
matchCountEl.textContent = `${matchCount} / ${messages.length}`;
if (matchCount === 0 && messages.length > 0) {
const noMatchesDiv = document.createElement('div');
noMatchesDiv.className = 'filter-no-matches';
noMatchesDiv.innerHTML = `
<i class="bi bi-search"></i>
<p>${tHtml('chat.filter.no_matches', { query: currentDmFilterQuery })}</p>
`;
container.appendChild(noMatchesDiv);
}
}
/**
* Get text content from a DM message wrapper
* @param {HTMLElement} wrapperEl - DM message wrapper element
* @returns {string} - Text content
*/
function getDmMessageText(wrapperEl) {
const content = wrapperEl.querySelector('.dm-content');
return content ? content.textContent || '' : '';
}
/**
* Highlight matching text in a DM message
* @param {HTMLElement} wrapperEl - DM message wrapper element
* @param {number} index - Message index for tracking
*/
function highlightDmMessageContent(wrapperEl, index) {
const msgId = 'dm_msg_' + index;
const content = wrapperEl.querySelector('.dm-content');
if (!content) return;
if (!originalDmMessageContents.has(msgId)) {
originalDmMessageContents.set(msgId, content.innerHTML);
}
const originalHtml = originalDmMessageContents.get(msgId);
content.innerHTML = FilterUtils.highlightMatches(originalHtml, currentDmFilterQuery);
}
/**
* Restore original DM message content
* @param {HTMLElement} wrapperEl - DM message wrapper element
*/
function restoreDmOriginalContent(wrapperEl) {
const container = document.getElementById('dmMessagesList');
const wrappers = Array.from(container.querySelectorAll('.dm-message-wrapper'));
const index = wrappers.indexOf(wrapperEl);
const msgId = 'dm_msg_' + index;
if (!originalDmMessageContents.has(msgId)) return;
const content = wrapperEl.querySelector('.dm-content');
if (content) {
content.innerHTML = originalDmMessageContents.get(msgId);
}
}
/**
* Clear DM filter state when messages are reloaded
*/
function clearDmFilterState() {
originalDmMessageContents.clear();
if (dmFilterActive && currentDmFilterQuery) {
setTimeout(() => {
applyDmFilter(currentDmFilterQuery);
}, 50);
}
}
// =============================================================================
// Auto-retry configuration
// =============================================================================
/**
* Load auto-retry config from bridge and sync toggle state
*/
async function loadAutoRetryConfig() {
const toggle = document.getElementById('dmAutoRetryToggle');
if (!toggle) return;
try {
const response = await fetch('/api/dm/auto_retry');
const data = await response.json();
if (data.success) {
toggle.checked = data.enabled;
}
} catch (e) {
console.debug('Failed to load auto-retry config:', e);
}
// Setup change handler
toggle.addEventListener('change', async function() {
try {
const response = await fetch('/api/dm/auto_retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: this.checked })
});
const data = await response.json();
if (data.success) {
showNotification(
data.enabled ? t('dm.toast.auto_retry_on') : t('dm.toast.auto_retry_off'),
'info'
);
}
} catch (e) {
console.error('Failed to update auto-retry config:', e);
// Revert toggle on error
this.checked = !this.checked;
}
});
}
// ================================================================
// Path Management
// ================================================================
let _repeatersCache = null;
/**
* Get the current contact's full public key for path API calls.
*/
function getCurrentContactPubkey() {
const contact = findCurrentContact();
return contact?.public_key || currentConversationId || '';
}
/**
* Load and display the path section in Contact Info modal.
*/
async function loadPathSection() {
const section = document.getElementById('dmPathSection');
const pubkey = getCurrentContactPubkey();
if (!section || !pubkey) {
if (section) section.style.display = 'none';
return;
}
section.style.display = '';
await renderPathList(pubkey);
await loadNoAutoFloodToggle(pubkey);
setupPathFormHandlers(pubkey);
}
/**
* Render the list of configured paths for a contact.
*/
async function renderPathList(pubkey) {
const listEl = document.getElementById('dmPathList');
if (!listEl) return;
listEl.innerHTML = `<div class="text-muted small">${tHtml('common.loading')}</div>`;
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`);
const data = await response.json();
if (!data.success || !data.paths.length) {
listEl.innerHTML = `<div class="text-muted small mb-2">${tHtml('repeaters.paths.none')}</div>`;
return;
}
listEl.innerHTML = '';
data.paths.forEach((path, index) => {
const item = document.createElement('div');
item.className = 'path-list-item' + (path.is_primary ? ' primary' : '');
// Format path hex as hop→hop→hop
const chunk = path.hash_size * 2;
const hops = [];
for (let i = 0; i < path.path_hex.length; i += chunk) {
hops.push(path.path_hex.substring(i, i + chunk).toUpperCase());
}
const pathDisplay = hops.join('→');
const hashLabel = path.hash_size + 'B';
item.innerHTML = `
<span class="path-hex" title="${escapeHtml(path.path_hex)}">${pathDisplay}</span>
<span class="badge bg-secondary">${hashLabel}</span>
${path.label ? `<span class="path-label" title="${escapeHtml(path.label)}">${escapeHtml(path.label)}</span>` : ''}
<span class="path-actions">
<button class="btn btn-link p-0 ${path.is_primary ? 'text-warning' : 'text-muted'}"
title="${tHtml(path.is_primary ? 'repeaters.paths.is_primary_title' : 'repeaters.paths.set_primary_title')}"
data-action="primary" data-id="${path.id}">
<i class="bi bi-star${path.is_primary ? '-fill' : ''}"></i>
</button>
<button class="btn btn-link p-0 text-primary"
title="${tHtml('repeaters.paths.apply_title')}"
data-action="apply" data-id="${path.id}">
<i class="bi bi-upload"></i>
</button>
${index > 0 ? `<button class="btn btn-link p-0 text-muted" title="${tHtml('common.move_up')}" data-action="up" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-up"></i></button>` : ''}
${index < data.paths.length - 1 ? `<button class="btn btn-link p-0 text-muted" title="${tHtml('common.move_down')}" data-action="down" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-down"></i></button>` : ''}
<button class="btn btn-link p-0 text-danger" title="${tHtml('common.delete')}" data-action="delete" data-id="${path.id}">
<i class="bi bi-trash"></i>
</button>
</span>
`;
listEl.appendChild(item);
});
// Attach action handlers
listEl.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const pathId = parseInt(btn.dataset.id);
if (action === 'primary') {
await setPathPrimary(pubkey, pathId);
} else if (action === 'apply') {
await applyPathToDevice(pubkey, pathId);
} else if (action === 'delete') {
await deletePathItem(pubkey, pathId);
} else if (action === 'up' || action === 'down') {
await movePathItem(pubkey, data.paths, parseInt(btn.dataset.index), action);
}
});
});
} catch (e) {
listEl.innerHTML = `<div class="text-danger small">${tHtml('repeaters.paths.load_failed')}</div>`;
console.error('Failed to load paths:', e);
}
}
async function setPathPrimary(pubkey, pathId) {
try {
await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_primary: true })
});
await renderPathList(pubkey);
} catch (e) {
console.error('Failed to set primary path:', e);
}
}
async function applyPathToDevice(pubkey, pathId) {
try {
const response = await fetch(
`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}/apply`,
{ method: 'POST' }
);
const data = await response.json();
if (data.success) {
showNotification(t('repeaters.toast.path_updated'), 'info');
await refreshContactInfoPath();
} else {
showNotification(data.error || t('repeaters.toast.path_set_failed'), 'danger');
}
} catch (e) {
console.error('Failed to apply path to device:', e);
showNotification(t('repeaters.toast.path_set_failed'), 'danger');
}
}
async function deletePathItem(pubkey, pathId) {
try {
await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}`, {
method: 'DELETE'
});
await renderPathList(pubkey);
} catch (e) {
console.error('Failed to delete path:', e);
}
}
async function movePathItem(pubkey, paths, currentIndex, direction) {
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (newIndex < 0 || newIndex >= paths.length) return;
// Swap in the IDs array
const ids = paths.map(p => p.id);
[ids[currentIndex], ids[newIndex]] = [ids[newIndex], ids[currentIndex]];
try {
await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path_ids: ids })
});
await renderPathList(pubkey);
} catch (e) {
console.error('Failed to reorder paths:', e);
}
}
/**
* Setup handlers for the Add Path form.
*/
function setupPathFormHandlers(pubkey) {
const addBtn = document.getElementById('dmAddPathBtn');
const saveBtn = document.getElementById('dmSavePathBtn');
const pickBtn = document.getElementById('dmPickRepeaterBtn');
const picker = document.getElementById('dmRepeaterPicker');
const resetFloodBtn = document.getElementById('dmResetFloodBtn');
const setDirectBtn = document.getElementById('dmSetDirectBtn');
const clearPathsBtn = document.getElementById('dmClearPathsBtn');
const addPathModalEl = document.getElementById('addPathModal');
if (!addBtn || !addPathModalEl) return;
const addPathModal = new bootstrap.Modal(addPathModalEl);
// "+" button opens the Add Path modal
const newAddBtn = addBtn.cloneNode(true);
addBtn.parentNode.replaceChild(newAddBtn, addBtn);
newAddBtn.addEventListener('click', () => {
document.getElementById('dmPathHexInput').value = '';
document.getElementById('dmPathLabelInput').value = '';
document.getElementById('dmPathUniquenessWarning').style.display = 'none';
if (picker) picker.style.display = 'none';
addPathModal.show();
});
// Raise backdrop when Add Path modal opens (above Contact Info)
addPathModalEl.addEventListener('shown.bs.modal', () => {
const backdrops = document.querySelectorAll('.modal-backdrop');
if (backdrops.length > 1) {
backdrops[backdrops.length - 1].style.zIndex = '1060';
}
});
// Save button
const newSaveBtn = saveBtn.cloneNode(true);
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
newSaveBtn.addEventListener('click', async () => {
const pathHex = document.getElementById('dmPathHexInput').value.replace(/[,\s→]/g, '').trim();
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value);
const label = document.getElementById('dmPathLabelInput').value.trim();
if (!pathHex) {
showNotification(t('repeaters.toast.path_hex_required'), 'danger');
return;
}
// Check for duplicate hops in manually entered path
const chunk = hashSize * 2;
const hops = [];
for (let i = 0; i < pathHex.length; i += chunk) {
hops.push(pathHex.substring(i, i + chunk).toLowerCase());
}
if (hashSize === 1) {
// 1B: only block adjacent duplicates
const adjDupes = hops.filter((h, i) => i > 0 && hops[i - 1] === h);
if (adjDupes.length > 0) {
const uniqAdj = [...new Set(adjDupes)];
showNotification(tn('repeaters.toast.adjacent_dupes', uniqAdj.length,
{ hops: uniqAdj.map(d => d.toUpperCase()).join(', ') }), 'danger');
return;
}
} else {
// 2B/3B: block any duplicate
const dupes = hops.filter((h, i) => hops.indexOf(h) !== i);
if (dupes.length > 0) {
const uniqDupes = [...new Set(dupes)];
showNotification(tn('repeaters.toast.dupes', uniqDupes.length,
{ hops: uniqDupes.map(d => d.toUpperCase()).join(', ') }), 'danger');
return;
}
}
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path_hex: pathHex, hash_size: hashSize, label: label })
});
const data = await response.json();
if (data.success) {
addPathModal.hide();
await renderPathList(pubkey);
showNotification(t('repeaters.toast.path_added'), 'info');
} else {
showNotification(data.error || t('repeaters.toast.path_add_failed'), 'danger');
}
} catch (e) {
showNotification(t('repeaters.toast.path_add_failed'), 'danger');
}
});
// Repeater picker toggle
const newPickBtn = pickBtn.cloneNode(true);
pickBtn.parentNode.replaceChild(newPickBtn, pickBtn);
newPickBtn.addEventListener('click', () => {
if (!picker) return;
if (picker.style.display === 'none') {
picker.style.display = '';
loadRepeaterPicker(pubkey);
} else {
picker.style.display = 'none';
}
});
// Repeater search filter
const searchInput = document.getElementById('dmRepeaterSearch');
if (searchInput) {
const newSearch = searchInput.cloneNode(true);
searchInput.parentNode.replaceChild(newSearch, searchInput);
newSearch.addEventListener('input', () => {
filterRepeaterList();
});
}
// Repeater map picker button
const mapBtn = document.getElementById('dmPickRepeaterMapBtn');
if (mapBtn) {
const newMapBtn = mapBtn.cloneNode(true);
mapBtn.parentNode.replaceChild(newMapBtn, mapBtn);
newMapBtn.addEventListener('click', () => {
openRepeaterMapPicker();
});
}
// Reset to FLOOD button (device only, keeps configured paths)
if (resetFloodBtn) {
const newResetBtn = resetFloodBtn.cloneNode(true);
resetFloodBtn.parentNode.replaceChild(newResetBtn, resetFloodBtn);
newResetBtn.addEventListener('click', async () => {
if (!confirm(t('repeaters.confirm.reset_flood'))) {
return;
}
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/reset_flood`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showNotification(t('repeaters.toast.reset_flood_done'), 'info');
await refreshContactInfoPath();
} else {
showNotification(data.error || t('repeaters.toast.reset_failed'), 'danger');
}
} catch (e) {
showNotification(t('repeaters.toast.reset_failed'), 'danger');
}
});
}
// Set Direct button (device only, keeps configured paths)
if (setDirectBtn) {
const newDirectBtn = setDirectBtn.cloneNode(true);
setDirectBtn.parentNode.replaceChild(newDirectBtn, setDirectBtn);
newDirectBtn.addEventListener('click', async () => {
if (!confirm(t('repeaters.confirm.set_direct'))) {
return;
}
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/set_direct`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showNotification(t('repeaters.toast.set_direct_done'), 'info');
await refreshContactInfoPath();
} else {
showNotification(data.error || t('repeaters.toast.set_direct_failed'), 'danger');
}
} catch (e) {
showNotification(t('repeaters.toast.set_direct_failed'), 'danger');
}
});
}
// Clear all configured paths button (DB only)
if (clearPathsBtn) {
const newClearBtn = clearPathsBtn.cloneNode(true);
clearPathsBtn.parentNode.replaceChild(newClearBtn, clearPathsBtn);
newClearBtn.addEventListener('click', async () => {
if (!confirm(t('repeaters.confirm.clear_paths'))) {
return;
}
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/clear`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
await renderPathList(pubkey);
showNotification(tn('repeaters.toast.paths_cleared', data.paths_deleted || 0), 'info');
} else {
showNotification(data.error || t('repeaters.toast.clear_failed'), 'danger');
}
} catch (e) {
showNotification(t('repeaters.toast.clear_failed'), 'danger');
}
});
}
}
/**
* Load repeaters for the picker dropdown.
*/
async function loadRepeaterPicker(pubkey) {
const listEl = document.getElementById('dmRepeaterList');
if (!listEl) return;
if (!_repeatersCache) {
try {
const response = await fetch('/api/contacts/repeaters');
const data = await response.json();
if (data.success) {
_repeatersCache = data.repeaters;
}
} catch (e) {
listEl.innerHTML = `<div class="text-danger small p-2">${tHtml('repeaters.load_failed')}</div>`;
return;
}
}
renderRepeaterList(listEl, _repeatersCache, pubkey);
}
function getRepeaterSearchMode() {
const checked = document.querySelector('input[name="repeaterSearchMode"]:checked');
return checked ? checked.value : 'name';
}
function renderRepeaterList(listEl, repeaters, pubkey) {
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value);
const hexInput = document.getElementById('dmPathHexInput');
const searchVal = (document.getElementById('dmRepeaterSearch')?.value || '').toLowerCase().trim();
const searchMode = getRepeaterSearchMode();
const prefixLen = hashSize * 2; // hex chars to match for ID mode
const filtered = repeaters.filter(r => {
if (!searchVal) return true;
if (searchMode === 'id') {
// Match only against the first prefixLen hex chars of public_key
const idPrefix = r.public_key.substring(0, prefixLen).toLowerCase();
return idPrefix.startsWith(searchVal.substring(0, prefixLen));
}
return r.name.toLowerCase().includes(searchVal);
});
if (!filtered.length) {
listEl.innerHTML = `<div class="text-muted small p-2">${tHtml('repeaters.picker.none')}</div>`;
return;
}
listEl.innerHTML = '';
filtered.forEach(rpt => {
const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase();
// Check uniqueness: count repeaters with same prefix
const samePrefix = repeaters.filter(r =>
r.public_key.substring(0, hashSize * 2).toLowerCase() === prefix.toLowerCase()
).length;
const item = document.createElement('div');
item.className = 'repeater-picker-item';
item.innerHTML = `
<span class="badge ${samePrefix > 1 ? 'bg-warning text-dark' : 'bg-success'}">${prefix}</span>
<span class="flex-grow-1 text-truncate">${escapeHtml(rpt.name)}</span>
${samePrefix > 1 ? `<i class="bi bi-exclamation-triangle text-warning" title="${tn('repeaters.picker.shared_prefix', samePrefix)}"></i>` : ''}
`;
item.addEventListener('click', () => {
// Check for duplicate hop
const existingHops = getCurrentPathHops(hashSize);
const prefixLc = prefix.toLowerCase();
if (hashSize === 1) {
// 1B: only block if same as last hop (adjacent duplicate)
if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefixLc) {
showNotification(t('repeaters.toast.hop_adjacent_self', { hop: prefix }), 'warning');
return;
}
} else {
// 2B/3B: block any duplicate
if (existingHops.includes(prefixLc)) {
showNotification(t('repeaters.toast.hop_already_used', { hop: prefix }), 'warning');
return;
}
}
// Append hop to path hex input
const current = hexInput.value.replace(/[,\s→]/g, '').trim();
const newVal = current + prefix.toLowerCase();
// Format with commas for readability
const chunk = hashSize * 2;
const parts = [];
for (let i = 0; i < newVal.length; i += chunk) {
parts.push(newVal.substring(i, i + chunk));
}
hexInput.value = parts.join(',');
// Show uniqueness warning if applicable
checkUniquenessWarning(repeaters, hashSize);
});
listEl.appendChild(item);
});
}
function filterRepeaterList() {
if (!_repeatersCache) return;
const listEl = document.getElementById('dmRepeaterList');
const pubkey = getCurrentContactPubkey();
if (listEl) {
// Filtering is done inside renderRepeaterList based on search input + mode
renderRepeaterList(listEl, _repeatersCache, pubkey);
}
}
/**
* Get the list of hop prefixes currently in the path hex input.
*/
function getCurrentPathHops(hashSize) {
const hexInput = document.getElementById('dmPathHexInput');
if (!hexInput) return [];
const rawHex = hexInput.value.replace(/[,\s→]/g, '').trim().toLowerCase();
const chunk = hashSize * 2;
const hops = [];
for (let i = 0; i < rawHex.length; i += chunk) {
hops.push(rawHex.substring(i, i + chunk));
}
return hops;
}
function checkUniquenessWarning(repeaters, hashSize) {
const warningEl = document.getElementById('dmPathUniquenessWarning');
if (!warningEl) return;
const hexInput = document.getElementById('dmPathHexInput');
const rawHex = hexInput.value.replace(/[,\s→]/g, '').trim();
const chunk = hashSize * 2;
const hops = [];
for (let i = 0; i < rawHex.length; i += chunk) {
hops.push(rawHex.substring(i, i + chunk).toLowerCase());
}
const ambiguous = hops.filter(hop => {
const count = repeaters.filter(r =>
r.public_key.substring(0, chunk).toLowerCase() === hop
).length;
return count > 1;
});
if (ambiguous.length > 0) {
warningEl.textContent = tn('repeaters.toast.ambiguous_prefix', ambiguous.length,
{ hops: ambiguous.map(h => h.toUpperCase()).join(', ') });
warningEl.style.display = '';
} else {
warningEl.style.display = 'none';
}
}
// ================================================================
// Repeater Map Picker
// ================================================================
let _rptMap = null;
let _rptMapMarkers = null;
let _rptMapSelectedRepeater = null;
function openRepeaterMapPicker() {
_rptMapSelectedRepeater = null;
const modalEl = document.getElementById('repeaterMapModal');
if (!modalEl) return;
const addBtn = document.getElementById('rptMapAddBtn');
const selectedLabel = document.getElementById('rptMapSelected');
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
const modal = new bootstrap.Modal(modalEl);
const onShown = async function () {
// Raise backdrop z-index so it covers modals behind (Contact Info + Add Path)
const backdrops = document.querySelectorAll('.modal-backdrop');
if (backdrops.length > 0) {
backdrops[backdrops.length - 1].style.zIndex = '1075';
}
// Init map once
if (!_rptMap) {
_rptMap = L.map('rptLeafletMap').setView([52.0, 19.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(_rptMap);
_rptMapMarkers = L.layerGroup().addTo(_rptMap);
}
_rptMap.invalidateSize();
await loadRepeaterMapMarkers();
modalEl.removeEventListener('shown.bs.modal', onShown);
};
// Cached toggle
const cachedSwitch = document.getElementById('rptMapCachedSwitch');
if (cachedSwitch) {
cachedSwitch.onchange = () => loadRepeaterMapMarkers();
}
// Add button
if (addBtn) {
addBtn.onclick = () => {
if (!_rptMapSelectedRepeater) return;
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value);
const prefix = _rptMapSelectedRepeater.public_key.substring(0, hashSize * 2).toLowerCase();
// Check for duplicate hop
const existingHops = getCurrentPathHops(hashSize);
if (hashSize === 1) {
if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefix) {
showNotification(t('repeaters.toast.hop_adjacent_self', { hop: prefix.toUpperCase() }), 'warning');
return;
}
} else {
if (existingHops.includes(prefix)) {
showNotification(t('repeaters.toast.hop_already_used', { hop: prefix.toUpperCase() }), 'warning');
return;
}
}
const hexInput = document.getElementById('dmPathHexInput');
if (hexInput) {
const current = hexInput.value.replace(/[,\s→]/g, '').trim();
const newVal = current + prefix;
const chunk = hashSize * 2;
const parts = [];
for (let i = 0; i < newVal.length; i += chunk) {
parts.push(newVal.substring(i, i + chunk));
}
hexInput.value = parts.join(',');
if (_repeatersCache) {
checkUniquenessWarning(_repeatersCache, hashSize);
}
}
// Reset selection for next pick
_rptMapSelectedRepeater = null;
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
};
}
modalEl.addEventListener('shown.bs.modal', onShown);
modal.show();
}
async function loadRepeaterMapMarkers() {
if (!_rptMapMarkers) return;
_rptMapMarkers.clearLayers();
const cachedSwitch = document.getElementById('rptMapCachedSwitch');
const showCached = cachedSwitch && cachedSwitch.checked;
const countEl = document.getElementById('rptMapCount');
const addBtn = document.getElementById('rptMapAddBtn');
const selectedLabel = document.getElementById('rptMapSelected');
// Reset selection
_rptMapSelectedRepeater = null;
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
// Ensure repeaters cache is loaded
if (!_repeatersCache) {
try {
const response = await fetch('/api/contacts/repeaters');
const data = await response.json();
if (data.success) _repeatersCache = data.repeaters;
} catch (e) {
if (countEl) countEl.textContent = t('repeaters.load_failed');
return;
}
}
// Filter: only those with a usable GPS position
let repeaters = withValidGps(_repeatersCache);
if (!showCached) {
// Non-cached: only repeaters that are on the device (have recent advert)
// Use detailed contacts to check which are on device
try {
const response = await fetch('/api/contacts/detailed');
const data = await response.json();
if (data.success && data.contacts) {
const deviceKeys = new Set(data.contacts
.filter(c => c.type === 2)
.map(c => c.public_key.toLowerCase()));
repeaters = repeaters.filter(r => deviceKeys.has(r.public_key.toLowerCase()));
}
} catch (e) { /* show all on error */ }
}
if (countEl) countEl.textContent = tn('repeaters.count', repeaters.length);
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked')?.value || '1');
const bounds = [];
repeaters.forEach(rpt => {
const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase();
const lastSeen = rpt.last_advert ? formatTimeAgo(rpt.last_advert) : '';
const marker = L.circleMarker([rpt.adv_lat, rpt.adv_lon], {
radius: 10,
fillColor: '#4CAF50',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(_rptMapMarkers);
marker.bindPopup(
`<b>${escapeHtml(rpt.name)}</b><br>` +
`<code>${prefix}</code>` +
(lastSeen ? `<br><small class="text-muted">${tHtml('repeaters.map.last_seen', { time: lastSeen })}</small>` : '')
);
marker.on('click', () => {
_rptMapSelectedRepeater = rpt;
if (addBtn) addBtn.disabled = false;
if (selectedLabel) {
selectedLabel.innerHTML = `<code>${prefix}</code> ${escapeHtml(rpt.name)}`;
}
});
bounds.push([rpt.adv_lat, rpt.adv_lon]);
});
fitMapToPoints(_rptMap, bounds);
}
/**
* Load and setup the No Auto Flood toggle for current contact.
*/
async function loadNoAutoFloodToggle(pubkey) {
const toggle = document.getElementById('dmNoAutoFloodToggle');
if (!toggle || !pubkey) return;
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/no_auto_flood`);
const data = await response.json();
if (data.success) {
toggle.checked = data.no_auto_flood;
}
} catch (e) {
console.debug('Failed to load no_auto_flood:', e);
}
// Replace to avoid duplicate listeners
const newToggle = toggle.cloneNode(true);
toggle.parentNode.replaceChild(newToggle, toggle);
newToggle.id = 'dmNoAutoFloodToggle';
newToggle.addEventListener('change', async function () {
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/no_auto_flood`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ no_auto_flood: this.checked })
});
const data = await response.json();
if (data.success) {
showNotification(
data.no_auto_flood ? t('dm.toast.keep_path_on') : t('dm.toast.keep_path_off'),
'info'
);
}
} catch (e) {
console.error('Failed to update no_auto_flood:', e);
this.checked = !this.checked;
}
});
}
// Listen for hash size and search mode radio changes
document.addEventListener('change', (e) => {
if (e.target.name === 'pathHashSize') {
_repeatersCache = null; // Refresh to recalculate prefixes
const picker = document.getElementById('dmRepeaterPicker');
if (picker && picker.style.display !== 'none') {
loadRepeaterPicker(getCurrentContactPubkey());
}
// Clear path hex input when changing hash size
const hexInput = document.getElementById('dmPathHexInput');
if (hexInput) hexInput.value = '';
// Update ID search placeholder with new prefix length
updateRepeaterSearchPlaceholder();
}
if (e.target.name === 'repeaterSearchMode') {
updateRepeaterSearchPlaceholder();
const searchInput = document.getElementById('dmRepeaterSearch');
if (searchInput) searchInput.value = '';
filterRepeaterList();
}
});
function updateRepeaterSearchPlaceholder() {
const searchInput = document.getElementById('dmRepeaterSearch');
if (!searchInput) return;
const mode = getRepeaterSearchMode();
if (mode === 'id') {
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked')?.value || '1');
const chars = hashSize * 2;
searchInput.placeholder = t('repeaters.addpath.search_id_ph', { chars: chars });
} else {
searchInput.placeholder = t('repeaters.addpath.search_ph');
}
}