mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-10-30 23:51:57 +01:00
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>
3113 lines
113 KiB
JavaScript
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: '© <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');
|
|
}
|
|
}
|