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

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

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

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

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

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

439 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{ t('dm.title') }} - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var theme = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Sidebar breakpoint: apply, react to resize, and react to changes from parent (Settings -> Interface) -->
<script>
(function() {
function readBp() {
try {
var s = parseInt(localStorage.getItem('mc-webui-sidebar-breakpoint'), 10);
return (isNaN(s) || s < 600 || s > 2000) ? 992 : s;
} catch (e) { return 992; }
}
function apply() {
document.documentElement.classList.toggle('layout-wide', window.innerWidth >= readBp());
}
apply();
var raf = null;
window.addEventListener('resize', function() {
if (raf) return;
raf = requestAnimationFrame(function() { raf = null; apply(); });
});
// Live update when the user changes the breakpoint in the parent window's Settings
window.addEventListener('storage', function(e) {
if (e.key === 'mc-webui-sidebar-breakpoint' || e.key === null) apply();
});
})();
</script>
{% include "_head_i18n.html" %}
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS (for repeater map picker) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<!-- Emoji Picker (local) -->
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
<!-- Inline styles removed - now in style.css -->
</head>
<body>
<!-- Main Content -->
<main>
<div class="container-fluid d-flex flex-column" style="height: 100vh;">
<!-- Main content: sidebar + chat -->
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
<!-- DM Sidebar (visible on lg+ screens) -->
<div id="dmSidebar" class="dm-sidebar">
<div class="dm-sidebar-header">
<input type="text"
id="dmSidebarSearch"
class="form-control form-control-sm"
placeholder="{{ t('dm.sidebar.search_ph') }}"
autocomplete="off">
</div>
<div class="dm-sidebar-list" id="dmSidebarList">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Chat Area -->
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
<!-- Conversation Selector Bar (mobile only, hidden on lg+) -->
<div class="dm-mobile-selector border-bottom bg-light">
<div class="p-2">
<div class="d-flex align-items-center gap-2">
<!-- Searchable contact selector -->
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
<input type="text"
id="dmContactSearchInput"
class="form-control"
placeholder="{{ t('dm.select_chat_ph') }}"
autocomplete="off">
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
</div>
<!-- Clear search button -->
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmClearSearchBtn"
title="{{ t('dm.clear_selection_title') }}"
style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
<!-- Contact info button -->
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmContactInfoBtn"
title="{{ t('dm.contact_info_title') }}"
disabled>
<i class="bi bi-info-circle"></i>
</button>
</div>
</div>
</div>
<!-- Desktop contact header (visible on lg+ when sidebar is shown) -->
<div class="dm-desktop-header border-bottom bg-light">
<div class="p-2 d-flex align-items-center gap-2">
<span id="dmDesktopContactName" class="fw-medium flex-grow-1 text-truncate"></span>
<button type="button"
class="btn btn-outline-secondary btn-sm flex-shrink-0"
id="dmDesktopInfoBtn"
title="{{ t('dm.contact_info_title') }}"
disabled>
<i class="bi bi-info-circle"></i>
</button>
</div>
</div>
<!-- Messages Container -->
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
<!-- Filter bar overlay -->
<div id="dmFilterBar" class="filter-bar">
<div class="filter-bar-inner">
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="{{ t('chat.filter.ph') }}" autocomplete="off">
<span id="dmFilterMatchCount" class="filter-match-count"></span>
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="{{ t('chat.filter.clear_title') }}">
<i class="bi bi-x"></i>
</button>
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="{{ t('common.close') }}">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="dmMessagesList">
<!-- Placeholder shown when no conversation selected -->
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">{{ t('dm.empty.select') }}</p>
<small class="text-muted">{{ t('dm.empty.select_hint') }}</small>
</div>
</div>
</div>
<!-- Scroll to bottom button -->
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="{{ t('chat.scroll_bottom_title') }}">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
<!-- Send Message Form -->
<div class="border-top bg-light">
<form id="dmSendForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="dmMessageInput"
class="form-control"
placeholder="{{ t('chat.input_ph') }}"
rows="2"
maxlength="500"
disabled
></textarea>
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="{{ t('chat.emoji_title') }}">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
<i class="bi bi-send"></i>
</button>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="dmEmojiPickerPopup" class="emoji-picker-popup hidden"></div>
</div>
<div class="d-flex justify-content-end">
<small class="text-muted"><span id="dmCharCounter">0</span> / 150</small>
</div>
</form>
</div>
<!-- Status Bar -->
<div class="border-top">
<div class="p-2 small text-muted d-flex flex-wrap gap-2 justify-content-between align-items-center">
<span id="dmStatusText" class="text-nowrap">
<i class="bi bi-circle-fill text-secondary"></i> {{ t('common.connecting') }}
</span>
<span id="dmLastRefresh" class="text-nowrap">{{ t('chat.updated', time=t('common.never')) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Floating Action Buttons -->
<div class="fab-container" id="dmFabContainer">
<button class="fab fab-toggle" id="dmFabToggle" title="{{ t('chat.fab.hide') }}">
<i class="bi bi-chevron-right"></i>
</button>
<button class="fab fab-filter" id="dmFilterFab" title="{{ t('chat.filter.fab_title') }}">
<i class="bi bi-funnel-fill"></i>
</button>
<button class="fab fab-settings" id="dmSettingsFab" title="{{ t('common.settings') }}">
<i class="bi bi-gear-fill"></i>
</button>
</div>
</main>
<!-- Contact Info Modal -->
<div class="modal fade" id="dmContactInfoModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title"><i class="bi bi-person-circle"></i> {{ t('dm.contact_info') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="dmContactInfoBody"></div>
<!-- Path management section (populated dynamically) -->
<div class="modal-body border-top pt-2 pb-1" id="dmPathSection" style="display: none;">
<div class="path-section-header">
<h6><i class="bi bi-signpost-split"></i> {{ t('repeaters.paths.title') }}</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="dmAddPathBtn" title="{{ t('repeaters.paths.add_title') }}">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div id="dmPathList"></div>
<!-- Path action buttons -->
<div class="d-flex flex-wrap justify-content-end gap-2 mt-1">
<button type="button" class="btn btn-outline-secondary btn-sm" id="dmClearPathsBtn"
title="{{ t('repeaters.paths.clear_title') }}">
<i class="bi bi-trash"></i> {{ t('repeaters.paths.clear') }}
</button>
{# Direct and FLOOD are the device's own mode names — kept as-is in every language. #}
<button type="button" class="btn btn-outline-primary btn-sm" id="dmSetDirectBtn"
title="{{ t('repeaters.paths.set_direct_title') }}">
<i class="bi bi-arrow-right-short"></i> {{ t('repeaters.paths.set_direct') }}
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="dmResetFloodBtn"
title="{{ t('repeaters.paths.reset_flood_title') }}">
<i class="bi bi-broadcast"></i> {{ t('repeaters.paths.reset_flood') }}
</button>
</div>
</div>
<div class="modal-footer">
<div class="d-flex align-items-center justify-content-between w-100">
<div class="d-flex gap-3">
<div class="form-check form-switch" title="{{ t('dm.auto_retry_title') }}">
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
<label class="form-check-label small" for="dmAutoRetryToggle">{{ t('dm.auto_retry') }}</label>
</div>
<div class="form-check form-switch" title="{{ t('dm.keep_path_title') }}">
<input class="form-check-input" type="checkbox" id="dmNoAutoFloodToggle">
<label class="form-check-label small" for="dmNoAutoFloodToggle">{{ t('dm.keep_path') }}</label>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('common.close') }}</button>
</div>
</div>
</div>
</div>
</div>
<!-- Repeater Map Picker Modal -->
<div class="modal fade" id="repeaterMapModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> {{ t('repeaters.map.title') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
<label class="form-check-label small" for="rptMapCachedSwitch">{{ t('repeaters.map.cached') }}</label>
</div>
<span class="text-muted small ms-auto" id="rptMapCount"></span>
</div>
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small text-muted" id="rptMapSelected">{{ t('repeaters.map.hint') }}</span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>{{ t('common.add') }}</button>
</div>
</div>
</div>
</div>
<!-- Shared Location Map Modal
Local to this panel on purpose: the DM panel is an iframe inside a
modal, so reaching for the parent page's map would mean stacking modals
across a frame boundary. -->
<div class="modal fade" id="shareMapModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="shareMapModalTitle"><i class="bi bi-geo-alt"></i> {{ t('share.card.open_in_map') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="shareLocationMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
</div>
</div>
</div>
</div>
<!-- Add Path Modal -->
<div class="modal fade" id="addPathModal" tabindex="-1" style="z-index: 1070;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> {{ t('repeaters.addpath.title') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="dmAddPathForm">
<div class="mb-2">
<label class="form-label small mb-1">{{ t('repeaters.addpath.hash_size') }}</label>
<div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
<label class="btn btn-outline-secondary" for="pathHash1">{{ t('repeaters.addpath.hash_1b') }}</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
<label class="btn btn-outline-secondary" for="pathHash2">{{ t('repeaters.addpath.hash_2b') }}</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
<label class="btn btn-outline-secondary" for="pathHash3">{{ t('repeaters.addpath.hash_3b') }}</label>
</div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">{{ t('repeaters.addpath.path_hex') }}</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control font-monospace" id="dmPathHexInput"
placeholder="{{ t('repeaters.addpath.path_hex_ph') }}" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterBtn"
title="{{ t('repeaters.addpath.pick_list_title') }}">
<i class="bi bi-plus-circle"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterMapBtn"
title="{{ t('repeaters.addpath.pick_map_title') }}">
<i class="bi bi-geo-alt"></i>
</button>
</div>
<div id="dmPathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
</div>
<!-- Repeater picker (hidden by default) -->
<div id="dmRepeaterPicker" style="display: none;" class="border rounded mb-2">
<div class="d-flex border-bottom">
<div class="btn-group btn-group-sm flex-shrink-0" role="group">
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchName" value="name" checked>
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">{{ t('repeaters.addpath.by_name') }}</label>
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchId" value="id">
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">{{ t('repeaters.addpath.by_id') }}</label>
</div>
<input type="text" class="form-control form-control-sm border-0"
id="dmRepeaterSearch" placeholder="{{ t('repeaters.addpath.search_ph') }}" autocomplete="off">
</div>
<div id="dmRepeaterList" style="max-height: 180px; overflow-y: auto;"></div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">{{ t('repeaters.addpath.label') }}</label>
<input type="text" class="form-control form-control-sm" id="dmPathLabelInput"
placeholder="{{ t('repeaters.addpath.label_ph') }}" maxlength="50">
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="dmCancelPathBtn" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-sm btn-primary" id="dmSavePathBtn">{{ t('repeaters.addpath.title') }}</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">mc-webui</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- Bootstrap 5 JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Share token formats (must load before message-utils.js, which renders them) -->
<script src="{{ url_for('static', filename='js/share-tokens.js') }}"></script>
<!-- Message Content Processing Utilities (must load before dm.js) -->
<script src="{{ url_for('static', filename='js/message-utils.js') }}"></script>
<!-- Share composer: the "+" button and its pickers (needs message-utils.js) -->
<script src="{{ url_for('static', filename='js/share-composer.js') }}"></script>
<!-- Filter Utilities (must load before dm.js) -->
<script src="{{ url_for('static', filename='js/filter-utils.js') }}"></script>
<!-- Leaflet JS (for repeater map picker) -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- FAB Utilities (drag, sizing) -->
<script src="{{ url_for('static', filename='js/fab-utils.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/dm.js') }}"></script>
<script>
// Pass configuration from Flask to JavaScript
window.MC_CONFIG = {
deviceName: "{{ device_name }}",
initialConversation: "{{ initial_conversation or '' }}"
};
</script>
</body>
</html>