diff --git a/app/routes/api.py b/app/routes/api.py index f1a02f5..5690191 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1739,6 +1739,77 @@ def get_messages_updates(): }), 500 +# ============================================================================= +# Message Search +# ============================================================================= + +@api_bp.route('/messages/search', methods=['GET']) +def search_messages(): + """ + Full-text search across all channel and direct messages (FTS5). + + Query params: + q (str): Search query (required) + limit (int): Max results (default: 50) + + Returns: + JSON with search results sorted by timestamp descending. + """ + query = request.args.get('q', '').strip() + if not query: + return jsonify({'success': False, 'error': 'Missing search query'}), 400 + + limit = request.args.get('limit', 50, type=int) + limit = min(limit, 200) + + try: + dm = current_app.config.get('DEVICE_MANAGER') + db = dm.db if dm else None + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 503 + + # FTS5 query — wrap in quotes for phrase search if contains spaces + # and add * for prefix matching + fts_query = query + results = db.search_messages(fts_query, limit=limit) + + # Enrich results with channel names and contact info + success, channels_list = get_channels_cached() + channel_names = {ch['index']: ch['name'] for ch in channels_list} if success else {} + + enriched = [] + for r in results: + item = { + 'id': r.get('id'), + 'content': r.get('content', ''), + 'timestamp': r.get('timestamp', 0), + 'source': r.get('msg_source', 'channel'), + } + if r.get('msg_source') == 'channel': + item['sender'] = r.get('sender', '') + item['channel_idx'] = r.get('channel_idx') + item['channel_name'] = channel_names.get(r.get('channel_idx'), f"Channel {r.get('channel_idx')}") + item['is_own'] = bool(r.get('is_own')) + else: + item['contact_pubkey'] = r.get('contact_pubkey', '') + item['direction'] = r.get('direction', '') + # Look up contact name + contact = db.get_contact(r.get('contact_pubkey', '')) if r.get('contact_pubkey') else None + item['contact_name'] = contact.get('name', r.get('contact_pubkey', '')[:12]) if contact else r.get('contact_pubkey', '')[:12] + enriched.append(item) + + return jsonify({ + 'success': True, + 'results': enriched, + 'count': len(enriched), + 'query': query + }), 200 + + except Exception as e: + logger.error(f"Search error: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + # ============================================================================= # Direct Messages (DM) Endpoints # ============================================================================= diff --git a/app/static/css/style.css b/app/static/css/style.css index 1c09f0a..889d332 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1402,3 +1402,26 @@ main { font-size: 0.9rem; } } + +/* ============================================================================= + Global Search + ============================================================================= */ + +#searchResults { + max-height: 60vh; + overflow-y: auto; +} + +#searchResults .list-group-item { + border-left: 3px solid transparent; +} + +#searchResults .list-group-item:hover { + border-left-color: var(--bs-primary); +} + +#searchResults mark { + background-color: #fff3cd; + padding: 0 2px; + border-radius: 2px; +} diff --git a/app/static/js/app.js b/app/static/js/app.js index 41c4c85..ff06504 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -3625,3 +3625,134 @@ function clearFilterState() { } } +// ============================================================================= +// Global Message Search (FTS5) +// ============================================================================= + +let searchDebounceTimer = null; + +function initializeSearch() { + const input = document.getElementById('searchInput'); + const btn = document.getElementById('searchBtn'); + if (!input || !btn) return; + + // Search on Enter or button click + btn.addEventListener('click', () => performSearch(input.value)); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') performSearch(input.value); + }); + + // Debounced search as user types (300ms) + input.addEventListener('input', () => { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + if (input.value.trim().length >= 2) { + performSearch(input.value); + } + }, 300); + }); + + // Focus input when modal opens + document.getElementById('searchModal')?.addEventListener('shown.bs.modal', () => { + input.focus(); + }); +} + +async function performSearch(query) { + query = query.trim(); + const container = document.getElementById('searchResults'); + if (!container) return; + + if (query.length < 2) { + container.innerHTML = '
Type at least 2 characters to search
No results for "${escapeHtml(query)}"