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

'; + return; + } + + container.innerHTML = '
Searching...
'; + + try { + const response = await fetch(`/api/messages/search?q=${encodeURIComponent(query)}&limit=50`); + const data = await response.json(); + + if (!data.success) { + container.innerHTML = `
${escapeHtml(data.error)}
`; + return; + } + + if (data.results.length === 0) { + container.innerHTML = `

No results for "${escapeHtml(query)}"

`; + return; + } + + container.innerHTML = `
${data.count} result${data.count !== 1 ? 's' : ''}
`; + + const list = document.createElement('div'); + list.className = 'list-group'; + + data.results.forEach(r => { + const item = document.createElement('a'); + item.className = 'list-group-item list-group-item-action'; + item.style.cursor = 'pointer'; + + const time = formatTime(r.timestamp); + const snippet = highlightSearchTerm(escapeHtml(r.content), query); + + if (r.source === 'channel') { + item.innerHTML = ` +
+
+ #${escapeHtml(r.channel_name || '')} + ${r.is_own ? 'You' : escapeHtml(r.sender || '')} +
+ ${time} +
+
${snippet}
+ `; + item.addEventListener('click', () => { + // Navigate to channel + const selector = document.getElementById('channelSelector'); + if (selector) { + selector.value = r.channel_idx; + selector.dispatchEvent(new Event('change')); + } + bootstrap.Modal.getInstance(document.getElementById('searchModal'))?.hide(); + }); + } else { + item.innerHTML = ` +
+
+ DM + ${escapeHtml(r.contact_name || '')} + ${r.direction === 'out' ? '(sent)' : '(received)'} +
+ ${time} +
+
${snippet}
+ `; + item.addEventListener('click', () => { + // Navigate to DM conversation + window.location.href = `/dm?conversation=${encodeURIComponent(r.contact_pubkey)}`; + }); + } + + list.appendChild(item); + }); + + container.appendChild(list); + + } catch (error) { + console.error('Search error:', error); + container.innerHTML = '
Search failed. Please try again.
'; + } +} + +function highlightSearchTerm(html, query) { + if (!query) return html; + const normalizedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${normalizedQuery})`, 'gi'); + return html.replace(regex, '$1'); +} + +// Initialize search when DOM is ready +document.addEventListener('DOMContentLoaded', initializeSearch); + diff --git a/app/templates/base.html b/app/templates/base.html index b84e264..ba096ea 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -38,6 +38,9 @@ {% endif %}
+
@@ -352,6 +355,32 @@
+ + +