From bd0a6b492eed796c3b93c7d6f182a819272830ba Mon Sep 17 00:00:00 2001 From: MarekWo Date: Wed, 15 Apr 2026 08:12:51 +0200 Subject: [PATCH] feat: configurable route popup + toast display time and position Users complained that the route popup under group-chat messages and the top-of-page notification toasts auto-close before they can read them, and some users wanted to move the toasts out of the top-left corner. Adds to Settings modal: - Group Chat tab: route popup auto-close timeout + "don't close" switch (applies to both channel popups and DM route popups) - New Interface tab: toast auto-close timeout, "don't close" switch, and five position options (top-left/top-right/bottom-left/bottom-right/center) Persisted as chat_settings (extended) and a new ui_settings row in the app_settings table, with /api/chat/settings and /api/ui/settings endpoints. Default toast delay bumped from 1.5s to 2s. Co-Authored-By: Claude Opus 4.6 --- app/routes/api.py | 102 ++++++++++++++++++++- app/static/js/app.js | 151 +++++++++++++++++++++++++++++-- app/static/js/contacts.js | 49 +++++++++- app/static/js/dm.js | 79 +++++++++++++++- app/templates/base.html | 64 ++++++++++++- app/templates/contacts.html | 4 +- app/templates/contacts_base.html | 4 +- app/templates/dm.html | 4 +- 8 files changed, 429 insertions(+), 28 deletions(-) diff --git a/app/routes/api.py b/app/routes/api.py index 515155a..4d8a26a 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -272,9 +272,26 @@ DM_RETRY_DEFAULTS = { } CHAT_SETTINGS_DEFAULTS = { - 'quote_max_bytes': 20, # max UTF-8 bytes for truncated quote + 'quote_max_bytes': 20, # max UTF-8 bytes for truncated quote + 'path_popup_timeout_sec': 8, # auto-close timeout for route popup (channel + DM) + 'path_popup_no_autoclose': False, # when True, route popup stays open until click-outside } +# Keys in chat_settings that carry a boolean value +CHAT_SETTINGS_BOOL_KEYS = {'path_popup_no_autoclose'} + +# ============================================================================= +# UI Settings (app-wide interface behavior — toast notifications, etc.) +# ============================================================================= + +UI_SETTINGS_DEFAULTS = { + 'toast_timeout_sec': 2.0, # auto-hide delay for notification toasts + 'toast_no_autoclose': False, # when True, toasts stay until dismissed + 'toast_position': 'top-left', # one of TOAST_POSITIONS +} + +TOAST_POSITIONS = {'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'} + def get_dm_retry_settings() -> dict: """Get DM retry settings from database.""" @@ -320,6 +337,28 @@ def save_chat_settings(settings: dict) -> bool: return False +def get_ui_settings() -> dict: + """Get UI (interface) settings from database.""" + db = _get_db() + if db: + saved = db.get_setting_json('ui_settings', {}) + return {**UI_SETTINGS_DEFAULTS, **saved} + return dict(UI_SETTINGS_DEFAULTS) + + +def save_ui_settings(settings: dict) -> bool: + """Save UI settings to database.""" + db = _get_db() + if not db: + return False + try: + db.set_setting_json('ui_settings', settings) + return True + except Exception as e: + logger.error(f"Failed to save UI settings: {e}") + return False + + @api_bp.route('/messages', methods=['GET']) def get_messages(): """ @@ -2560,9 +2599,17 @@ def set_chat_config(): valid_keys = set(CHAT_SETTINGS_DEFAULTS.keys()) settings = {} for key in valid_keys: - if key in data: - val = data[key] - if not isinstance(val, (int, float)) or val < 1: + if key not in data: + continue + val = data[key] + if key in CHAT_SETTINGS_BOOL_KEYS: + if not isinstance(val, bool): + return jsonify({'success': False, 'error': f'Invalid value for {key}'}), 400 + settings[key] = val + else: + if isinstance(val, bool) or not isinstance(val, (int, float)) or val < 1: + return jsonify({'success': False, 'error': f'Invalid value for {key}'}), 400 + if key == 'path_popup_timeout_sec' and val > 60: return jsonify({'success': False, 'error': f'Invalid value for {key}'}), 400 settings[key] = int(val) @@ -2576,6 +2623,53 @@ def set_chat_config(): return jsonify({'success': False, 'error': str(e)}), 500 +@api_bp.route('/ui/settings', methods=['GET']) +def get_ui_config(): + """Get UI (interface) settings.""" + try: + return jsonify(get_ui_settings()), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/ui/settings', methods=['POST']) +def set_ui_config(): + """Update UI (interface) settings.""" + try: + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'Missing JSON body'}), 400 + + settings = {} + + if 'toast_timeout_sec' in data: + val = data['toast_timeout_sec'] + if not isinstance(val, (int, float)) or isinstance(val, bool) or val < 1 or val > 60: + return jsonify({'success': False, 'error': 'Invalid value for toast_timeout_sec'}), 400 + settings['toast_timeout_sec'] = float(val) + + if 'toast_no_autoclose' in data: + val = data['toast_no_autoclose'] + if not isinstance(val, bool): + return jsonify({'success': False, 'error': 'Invalid value for toast_no_autoclose'}), 400 + settings['toast_no_autoclose'] = val + + if 'toast_position' in data: + val = data['toast_position'] + if val not in TOAST_POSITIONS: + return jsonify({'success': False, 'error': 'Invalid value for toast_position'}), 400 + settings['toast_position'] = val + + if not settings: + return jsonify({'success': False, 'error': 'No valid settings provided'}), 400 + + if save_ui_settings(settings): + return jsonify({**get_ui_settings(), 'success': True}), 200 + return jsonify({'success': False, 'error': 'Failed to save settings'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/dm/updates', methods=['GET']) def get_dm_updates(): """ diff --git a/app/static/js/app.js b/app/static/js/app.js index fdc0306..969a2af 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1541,9 +1541,15 @@ function showPathsPopup(element, encodedPaths) { popup.style.left = '0'; } - // Auto-dismiss after 8 seconds or on outside tap + // Auto-dismiss after configured timeout (unless disabled) or on outside tap const dismiss = () => popup.remove(); - setTimeout(dismiss, 8000); + 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(); @@ -2077,20 +2083,32 @@ function openCoordPicker() { // --- Chat Settings --- const CHAT_SETTINGS_DEFAULTS = { - quote_max_bytes: 20 + quote_max_bytes: 20, + path_popup_timeout_sec: 8, + path_popup_no_autoclose: false }; -const CHAT_SETTINGS_FIELDS = { - quote_max_bytes: 'settQuoteMaxBytes' +const CHAT_SETTINGS_INT_FIELDS = { + quote_max_bytes: 'settQuoteMaxBytes', + path_popup_timeout_sec: 'settPathPopupTimeout' +}; + +const CHAT_SETTINGS_BOOL_FIELDS = { + path_popup_no_autoclose: 'settPathPopupNoAutoclose' }; let chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS }; +window.chatSettingsCache = chatSettingsCache; function populateChatSettingsForm(data) { - for (const [key, elId] of Object.entries(CHAT_SETTINGS_FIELDS)) { + for (const [key, elId] of Object.entries(CHAT_SETTINGS_INT_FIELDS)) { const el = document.getElementById(elId); if (el) el.value = data[key] ?? CHAT_SETTINGS_DEFAULTS[key]; } + for (const [key, elId] of Object.entries(CHAT_SETTINGS_BOOL_FIELDS)) { + const el = document.getElementById(elId); + if (el) el.checked = !!(data[key] ?? CHAT_SETTINGS_DEFAULTS[key]); + } } async function loadChatSettings() { @@ -2099,6 +2117,7 @@ async function loadChatSettings() { if (resp.ok) { const data = await resp.json(); chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS, ...data }; + window.chatSettingsCache = chatSettingsCache; populateChatSettingsForm(chatSettingsCache); } } catch (e) { @@ -2108,7 +2127,7 @@ async function loadChatSettings() { async function saveChatSettings() { const payload = {}; - for (const [key, elId] of Object.entries(CHAT_SETTINGS_FIELDS)) { + for (const [key, elId] of Object.entries(CHAT_SETTINGS_INT_FIELDS)) { const el = document.getElementById(elId); const val = parseInt(el.value, 10); if (isNaN(val) || val < parseInt(el.min) || val > parseInt(el.max)) { @@ -2118,6 +2137,10 @@ async function saveChatSettings() { } payload[key] = val; } + for (const [key, elId] of Object.entries(CHAT_SETTINGS_BOOL_FIELDS)) { + const el = document.getElementById(elId); + if (el) payload[key] = !!el.checked; + } try { const resp = await fetch('/api/chat/settings', { method: 'POST', @@ -2127,6 +2150,95 @@ async function saveChatSettings() { if (resp.ok) { const data = await resp.json(); chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS, ...data }; + window.chatSettingsCache = chatSettingsCache; + showNotification('Settings saved', 'success'); + } else { + const err = await resp.json(); + showNotification(err.error || 'Failed to save', 'danger'); + } + } catch (e) { + showNotification('Failed to save settings', 'danger'); + } +} + +// --- UI (Interface) Settings --- + +const UI_SETTINGS_DEFAULTS = { + toast_timeout_sec: 2, + toast_no_autoclose: false, + toast_position: 'top-left' +}; + +const 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 ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle']; + +let uiSettingsCache = { ...UI_SETTINGS_DEFAULTS }; +window.uiSettingsCache = uiSettingsCache; + +function applyToastPosition(position) { + const classes = TOAST_POSITION_CLASSES[position] || TOAST_POSITION_CLASSES['top-left']; + document.querySelectorAll('[data-toast-container]').forEach(el => { + ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c)); + classes.forEach(c => el.classList.add(c)); + }); +} +window.applyToastPosition = applyToastPosition; + +function populateUiSettingsForm(data) { + const t = document.getElementById('settToastTimeout'); + if (t) t.value = data.toast_timeout_sec ?? UI_SETTINGS_DEFAULTS.toast_timeout_sec; + const noClose = document.getElementById('settToastNoAutoclose'); + if (noClose) noClose.checked = !!(data.toast_no_autoclose ?? UI_SETTINGS_DEFAULTS.toast_no_autoclose); + const pos = document.getElementById('settToastPosition'); + if (pos) pos.value = data.toast_position ?? UI_SETTINGS_DEFAULTS.toast_position; +} + +async function loadUiSettings() { + try { + const resp = await fetch('/api/ui/settings'); + if (resp.ok) { + const data = await resp.json(); + uiSettingsCache = { ...UI_SETTINGS_DEFAULTS, ...data }; + window.uiSettingsCache = uiSettingsCache; + applyToastPosition(uiSettingsCache.toast_position); + populateUiSettingsForm(uiSettingsCache); + } + } catch (e) { + console.error('Failed to load UI settings:', e); + } +} + +async function saveUiSettings() { + const timeoutEl = document.getElementById('settToastTimeout'); + const timeout = parseFloat(timeoutEl.value); + if (isNaN(timeout) || timeout < 1 || timeout > 60) { + showNotification('Invalid auto-close duration', 'danger'); + timeoutEl.focus(); + return; + } + const payload = { + toast_timeout_sec: timeout, + toast_no_autoclose: !!document.getElementById('settToastNoAutoclose').checked, + toast_position: document.getElementById('settToastPosition').value + }; + try { + const resp = await fetch('/api/ui/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (resp.ok) { + const data = await resp.json(); + uiSettingsCache = { ...UI_SETTINGS_DEFAULTS, ...data }; + window.uiSettingsCache = uiSettingsCache; + applyToastPosition(uiSettingsCache.toast_position); showNotification('Settings saved', 'success'); } else { const err = await resp.json(); @@ -2212,6 +2324,7 @@ document.addEventListener('DOMContentLoaded', () => { loadDeviceConfig(); loadDmRetrySettings(); loadChatSettings(); + loadUiSettings(); }); settingsModal.addEventListener('shown.bs.modal', () => { settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { @@ -2244,6 +2357,18 @@ document.addEventListener('DOMContentLoaded', () => { populateChatSettingsForm(CHAT_SETTINGS_DEFAULTS); }); + const uiSettingsForm = document.getElementById('uiSettingsForm'); + if (uiSettingsForm) { + uiSettingsForm.addEventListener('submit', (e) => { + e.preventDefault(); + saveUiSettings(); + }); + } + + document.getElementById('uiSettingsResetBtn')?.addEventListener('click', () => { + populateUiSettingsForm(UI_SETTINGS_DEFAULTS); + }); + // --- Device Settings --- const devicePublicInfoForm = document.getElementById('devicePublicInfoForm'); if (devicePublicInfoForm) { @@ -2279,8 +2404,9 @@ document.addEventListener('DOMContentLoaded', () => { bootstrap.Modal.getInstance(document.getElementById('coordPickerModal'))?.hide(); }); - // Load chat settings cache on startup (for quote dialog) + // Load settings caches on startup (for quote dialog, path popup, toast behavior) loadChatSettings(); + loadUiSettings(); }); /** @@ -2648,9 +2774,14 @@ function showNotification(message, type = 'info') { toastBody.textContent = message; toastEl.className = `toast bg-${type} text-white`; + 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: true, - delay: 1500 + autohide: !noAutoclose, + delay: delay }); toast.show(); } diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index dafb33f..2725dba 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -10,6 +10,44 @@ * - Mobile-first design */ +// --- UI settings bootstrap (for standalone contact pages that don't load app.js) --- + +(function initContactsUiSettings() { + const 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 ALL_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle']; + + function apply(position) { + const classes = TOAST_POSITION_CLASSES[position] || TOAST_POSITION_CLASSES['top-left']; + document.querySelectorAll('[data-toast-container]').forEach(el => { + ALL_CLASSES.forEach(c => el.classList.remove(c)); + classes.forEach(c => el.classList.add(c)); + }); + } + + document.addEventListener('DOMContentLoaded', async () => { + // If app.js is on the page, it owns settings loading and will update all + // [data-toast-container] elements (including ours) once its fetch resolves. + if (typeof window.applyToastPosition === 'function') return; + + try { + const resp = await fetch('/api/ui/settings'); + if (resp.ok) { + const data = await resp.json(); + window.uiSettingsCache = data; + apply(data.toast_position || 'top-left'); + } + } catch (e) { + console.error('Failed to load UI settings:', e); + } + }); +})(); + // ============================================================================= // Global Navigation Helper // ============================================================================= @@ -1783,10 +1821,15 @@ function showToast(message, type = 'info') { toastEl.classList.add('bg-info', 'text-white'); } - // Show toast + // Show toast (honors ui_settings: timeout + no-autoclose; position handled by toast-container classes) + 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: true, - delay: 1500 + autohide: !noAutoclose, + delay: delay }); toast.show(); } diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 5e6ccc1..52bd91a 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -3,6 +3,64 @@ * 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; @@ -202,6 +260,10 @@ document.addEventListener('DOMContentLoaded', async function() { // 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(); @@ -1478,7 +1540,13 @@ function showDmRoutePopup(element, hexPath, hashSize) { element.appendChild(popup); const dismiss = () => popup.remove(); - setTimeout(dismiss, 8000); + 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(); @@ -1725,9 +1793,14 @@ function showNotification(message, type = 'info') { } } + 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: true, - delay: 1500 + autohide: !noAutoclose, + delay: delay }); toast.show(); } diff --git a/app/templates/base.html b/app/templates/base.html index 45dcd90..1d64f91 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -379,6 +379,9 @@ + @@ -563,12 +566,69 @@ + +
Route popup
+

The popup shown when tapping "SNR | Hops" under a message. Also applies to DMs.

+ + + + + + + + + + + +
Auto-close after (s)
Don't close automatically +
+ +
+
+
+
+
Notifications
+

Controls the small toasts shown after actions (e.g. "Advert Sent", errors).

+ + + + + + + + + + + + + + + +
Auto-close after (s)
Don't close automatically +
+ +
+
Position on screen + +
+
+ + +
+
+
Theme
@@ -798,8 +858,8 @@
- -
+ +
- -
+ +
- -
+ +
- -
+ +