diff --git a/app/static/css/style.css b/app/static/css/style.css index 8cc94c7..1f69a92 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1067,3 +1067,141 @@ main { color: #75b798; background-color: rgba(117, 183, 152, 0.15); } + +/* ============================================================================= + Chat Filter + ============================================================================= */ + +/* Filter FAB button (gray gradient) */ +.fab-filter { + background: linear-gradient(135deg, #6c757d 0%, #495057 100%); + color: white; +} + +/* Filter bar overlay - slides down from top of chat area */ +.filter-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1002; /* Above mentions popup (1001) */ + background-color: #ffffff; + border-bottom: 1px solid #dee2e6; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 0.75rem; + transform: translateY(-100%); + opacity: 0; + visibility: hidden; + transition: transform 0.3s ease, opacity 0.3s ease, visibility 0.3s ease; +} + +.filter-bar.visible { + transform: translateY(0); + opacity: 1; + visibility: visible; +} + +/* Filter bar inner layout */ +.filter-bar-inner { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.filter-bar-input { + flex: 1; + border-radius: 0.375rem; + border: 1px solid #ced4da; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; +} + +.filter-bar-input:focus { + outline: none; + border-color: #86b7fe; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +/* Filter bar buttons */ +.filter-bar-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.filter-bar-btn-clear { + background-color: #f8f9fa; + color: #6c757d; +} + +.filter-bar-btn-clear:hover { + background-color: #e9ecef; +} + +.filter-bar-btn-close { + background-color: #dc3545; + color: white; +} + +.filter-bar-btn-close:hover { + background-color: #bb2d3b; +} + +/* Filter match count indicator */ +.filter-match-count { + font-size: 0.75rem; + color: #6c757d; + white-space: nowrap; + padding: 0 0.5rem; +} + +/* Highlighted text in filtered messages */ +.filter-highlight { + background-color: #fff3cd; + border-radius: 0.2rem; + padding: 0 0.1rem; +} + +/* Hidden messages when filtering */ +.message-wrapper.filter-hidden, +.dm-message.filter-hidden { + display: none !important; +} + +/* No matches message */ +.filter-no-matches { + text-align: center; + padding: 2rem; + color: #6c757d; +} + +.filter-no-matches i { + font-size: 2rem; + margin-bottom: 0.5rem; + display: block; +} + +/* Mobile responsive filter bar */ +@media (max-width: 576px) { + .filter-bar { + padding: 0.5rem; + } + + .filter-bar-input { + font-size: 0.85rem; + padding: 0.4rem 0.6rem; + } + + .filter-bar-btn { + width: 32px; + height: 32px; + font-size: 0.9rem; + } +} diff --git a/app/static/js/app.js b/app/static/js/app.js index a6ded90..e4610ea 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -321,6 +321,9 @@ document.addEventListener('DOMContentLoaded', async function() { // Update notification toggle UI updateNotificationToggleUI(); + // Initialize filter functionality + initializeFilter(); + // Setup auto-refresh immediately after messages are displayed // Don't wait for geo cache - it's not needed for auto-refresh setupAutoRefresh(); @@ -691,6 +694,9 @@ function displayMessages(messages) { const latestTimestamp = Math.max(...messages.map(m => m.timestamp)); markChannelAsRead(currentChannelIdx, latestTimestamp); } + + // Re-apply filter if active + clearFilterState(); } /** @@ -2740,3 +2746,236 @@ async function loadContactsForMentions() { } } +// ============================================================================= +// Chat Filter Functionality +// ============================================================================= + +// Filter state +let filterActive = false; +let currentFilterQuery = ''; +let originalMessageContents = new Map(); + +/** + * Initialize filter functionality + */ +function initializeFilter() { + const filterFab = document.getElementById('filterFab'); + const filterBar = document.getElementById('filterBar'); + const filterInput = document.getElementById('filterInput'); + const filterClearBtn = document.getElementById('filterClearBtn'); + const filterCloseBtn = document.getElementById('filterCloseBtn'); + + if (!filterFab || !filterBar) return; + + // Open filter bar when FAB clicked + filterFab.addEventListener('click', () => { + openFilterBar(); + }); + + // Filter as user types (debounced) + let filterTimeout = null; + filterInput.addEventListener('input', () => { + clearTimeout(filterTimeout); + filterTimeout = setTimeout(() => { + applyFilter(filterInput.value); + }, 150); + }); + + // Clear filter + filterClearBtn.addEventListener('click', () => { + filterInput.value = ''; + applyFilter(''); + filterInput.focus(); + }); + + // Close filter bar + filterCloseBtn.addEventListener('click', () => { + closeFilterBar(); + }); + + // Keyboard shortcuts + filterInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeFilterBar(); + } + }); + + // Global keyboard shortcut: Ctrl+F to open filter + document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); + openFilterBar(); + } + }); +} + +/** + * Open the filter bar + */ +function openFilterBar() { + const filterBar = document.getElementById('filterBar'); + const filterInput = document.getElementById('filterInput'); + + filterBar.classList.add('visible'); + filterActive = true; + + // Focus input after animation + setTimeout(() => { + filterInput.focus(); + }, 100); +} + +/** + * Close the filter bar and reset filter + */ +function closeFilterBar() { + const filterBar = document.getElementById('filterBar'); + const filterInput = document.getElementById('filterInput'); + + filterBar.classList.remove('visible'); + filterActive = false; + + // Reset filter + filterInput.value = ''; + applyFilter(''); +} + +/** + * Apply filter to messages + * @param {string} query - Search query + */ +function applyFilter(query) { + currentFilterQuery = query.trim(); + const container = document.getElementById('messagesList'); + const messages = container.querySelectorAll('.message-wrapper'); + const matchCountEl = document.getElementById('filterMatchCount'); + + // Remove any existing no-matches message + const existingNoMatches = container.querySelector('.filter-no-matches'); + if (existingNoMatches) { + existingNoMatches.remove(); + } + + if (!currentFilterQuery) { + // No filter - show all messages, restore original content + messages.forEach(msg => { + msg.classList.remove('filter-hidden'); + restoreOriginalContent(msg); + }); + matchCountEl.textContent = ''; + return; + } + + let matchCount = 0; + + messages.forEach(msg => { + // Get text content from message + const text = FilterUtils.getMessageText(msg, '.message-content'); + const senderEl = msg.querySelector('.message-sender'); + const senderText = senderEl ? senderEl.textContent : ''; + + // Check if message matches (content or sender) + const matches = FilterUtils.textMatches(text, currentFilterQuery) || + FilterUtils.textMatches(senderText, currentFilterQuery); + + if (matches) { + msg.classList.remove('filter-hidden'); + matchCount++; + + // Highlight matches in content + highlightMessageContent(msg); + } else { + msg.classList.add('filter-hidden'); + restoreOriginalContent(msg); + } + }); + + // Update match count + matchCountEl.textContent = `${matchCount} / ${messages.length}`; + + // Show no matches message if needed + if (matchCount === 0 && messages.length > 0) { + const noMatchesDiv = document.createElement('div'); + noMatchesDiv.className = 'filter-no-matches'; + noMatchesDiv.innerHTML = ` + +
No messages match "${escapeHtml(currentFilterQuery)}"
+ `; + container.appendChild(noMatchesDiv); + } +} + +/** + * Highlight matching text in a message element + * @param {HTMLElement} messageEl - Message wrapper element + */ +function highlightMessageContent(messageEl) { + const contentEl = messageEl.querySelector('.message-content'); + if (!contentEl) return; + + // Store original content if not already stored + const msgId = getMessageId(messageEl); + if (!originalMessageContents.has(msgId)) { + originalMessageContents.set(msgId, contentEl.innerHTML); + } + + // Get original content and apply highlighting + const originalHtml = originalMessageContents.get(msgId); + contentEl.innerHTML = FilterUtils.highlightMatches(originalHtml, currentFilterQuery); + + // Also highlight sender name if present + const senderEl = messageEl.querySelector('.message-sender'); + if (senderEl) { + const senderMsgId = msgId + '_sender'; + if (!originalMessageContents.has(senderMsgId)) { + originalMessageContents.set(senderMsgId, senderEl.innerHTML); + } + const originalSenderHtml = originalMessageContents.get(senderMsgId); + senderEl.innerHTML = FilterUtils.highlightMatches(originalSenderHtml, currentFilterQuery); + } +} + +/** + * Restore original content of a message element + * @param {HTMLElement} messageEl - Message wrapper element + */ +function restoreOriginalContent(messageEl) { + const contentEl = messageEl.querySelector('.message-content'); + const senderEl = messageEl.querySelector('.message-sender'); + const msgId = getMessageId(messageEl); + + if (contentEl && originalMessageContents.has(msgId)) { + contentEl.innerHTML = originalMessageContents.get(msgId); + } + + if (senderEl && originalMessageContents.has(msgId + '_sender')) { + senderEl.innerHTML = originalMessageContents.get(msgId + '_sender'); + } +} + +/** + * Generate a unique ID for a message element + * @param {HTMLElement} messageEl - Message element + * @returns {string} - Unique identifier + */ +function getMessageId(messageEl) { + const parent = messageEl.parentNode; + const children = Array.from(parent.children).filter(el => el.classList.contains('message-wrapper')); + return 'msg_' + children.indexOf(messageEl); +} + +/** + * Clear filter state when messages are reloaded + * Called from displayMessages() + */ +function clearFilterState() { + originalMessageContents.clear(); + + // Re-apply filter if active + if (filterActive && currentFilterQuery) { + setTimeout(() => { + applyFilter(currentFilterQuery); + }, 50); + } +} + diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 316f463..a5b503a 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -52,6 +52,9 @@ document.addEventListener('DOMContentLoaded', async function() { } } + // Initialize filter functionality + initializeDmFilter(); + // Setup auto-refresh setupAutoRefresh(); }); @@ -469,6 +472,9 @@ function displayMessages(messages) { if (scrollContainer) { scrollContainer.scrollTop = scrollContainer.scrollHeight; } + + // Re-apply filter if active + clearDmFilterState(); } /** @@ -872,3 +878,232 @@ function checkDmNotifications(conversations) { previousDmTotalUnread = currentDmTotalUnread; } + +// ============================================================================= +// DM Chat Filter Functionality +// ============================================================================= + +// Filter state +let dmFilterActive = false; +let currentDmFilterQuery = ''; +let originalDmMessageContents = new Map(); + +/** + * 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'); + const matchCountEl = document.getElementById('dmFilterMatchCount'); + + // 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 = ` + +No messages match "${escapeHtml(currentDmFilterQuery)}"
+ `; + container.appendChild(noMatchesDiv); + } +} + +/** + * Get text content from a DM message + * DM structure: timestamp div, then content div, then meta/actions + * @param {HTMLElement} msgEl - DM message element + * @returns {string} - Text content + */ +function getDmMessageText(msgEl) { + // The message content is in a div that is not the timestamp row, meta, or actions + const children = msgEl.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + // Skip timestamp row (has d-flex class), meta, and actions + if (!child.classList.contains('d-flex') && + !child.classList.contains('dm-meta') && + !child.classList.contains('dm-actions')) { + return child.textContent || ''; + } + } + return ''; +} + +/** + * Highlight matching text in a DM message + * @param {HTMLElement} msgEl - DM message element + * @param {number} index - Message index for tracking + */ +function highlightDmMessageContent(msgEl, index) { + const msgId = 'dm_msg_' + index; + + // Find content div (not timestamp, not meta, not actions) + const children = Array.from(msgEl.children); + for (const child of children) { + if (!child.classList.contains('d-flex') && + !child.classList.contains('dm-meta') && + !child.classList.contains('dm-actions')) { + + if (!originalDmMessageContents.has(msgId)) { + originalDmMessageContents.set(msgId, child.innerHTML); + } + + const originalHtml = originalDmMessageContents.get(msgId); + child.innerHTML = FilterUtils.highlightMatches(originalHtml, currentDmFilterQuery); + break; + } + } +} + +/** + * Restore original DM message content + * @param {HTMLElement} msgEl - DM message element + */ +function restoreDmOriginalContent(msgEl) { + const container = document.getElementById('dmMessagesList'); + const messages = Array.from(container.querySelectorAll('.dm-message')); + const index = messages.indexOf(msgEl); + const msgId = 'dm_msg_' + index; + + if (!originalDmMessageContents.has(msgId)) return; + + const children = Array.from(msgEl.children); + for (const child of children) { + if (!child.classList.contains('d-flex') && + !child.classList.contains('dm-meta') && + !child.classList.contains('dm-actions')) { + child.innerHTML = originalDmMessageContents.get(msgId); + break; + } + } +} + +/** + * Clear DM filter state when messages are reloaded + */ +function clearDmFilterState() { + originalDmMessageContents.clear(); + + if (dmFilterActive && currentDmFilterQuery) { + setTimeout(() => { + applyDmFilter(currentDmFilterQuery); + }, 50); + } +} diff --git a/app/static/js/filter-utils.js b/app/static/js/filter-utils.js new file mode 100644 index 0000000..e461215 --- /dev/null +++ b/app/static/js/filter-utils.js @@ -0,0 +1,176 @@ +/** + * Chat Filter Utilities + * Handles message filtering with diacritic-insensitive search and text highlighting + */ + +/** + * Diacritic normalization map for Polish and common accented characters + * Maps accented characters to their base forms + */ +const DIACRITIC_MAP = { + 'ą': 'a', 'á': 'a', 'à': 'a', 'â': 'a', 'ä': 'a', 'ã': 'a', 'å': 'a', + 'ć': 'c', 'č': 'c', 'ç': 'c', + 'ę': 'e', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', + 'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i', + 'ł': 'l', + 'ń': 'n', 'ñ': 'n', + 'ó': 'o', 'ò': 'o', 'ô': 'o', 'ö': 'o', 'õ': 'o', 'ő': 'o', 'ø': 'o', + 'ś': 's', 'š': 's', 'ß': 'ss', + 'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ű': 'u', + 'ý': 'y', 'ÿ': 'y', + 'ź': 'z', 'ż': 'z', 'ž': 'z' +}; + +/** + * Normalize text by removing diacritics and converting to lowercase + * @param {string} text - Text to normalize + * @returns {string} - Normalized text + */ +function normalizeText(text) { + if (!text) return ''; + + let normalized = text.toLowerCase(); + + // Replace diacritics using map + for (const [diacritic, base] of Object.entries(DIACRITIC_MAP)) { + normalized = normalized.split(diacritic).join(base); + } + + return normalized; +} + +/** + * Check if text matches search query (diacritic-insensitive, case-insensitive) + * @param {string} text - Text to search in + * @param {string} query - Search query + * @returns {boolean} - True if text contains query + */ +function textMatches(text, query) { + if (!query) return true; + if (!text) return false; + + const normalizedText = normalizeText(text); + const normalizedQuery = normalizeText(query); + + return normalizedText.includes(normalizedQuery); +} + +/** + * Find all match positions in original text for highlighting + * Uses normalized comparison but returns positions in original text + * @param {string} originalText - Original text + * @param {string} query - Search query + * @returns {Array<{start: number, end: number}>} - Array of match positions + */ +function findMatchPositions(originalText, query) { + if (!query || !originalText) return []; + + const normalizedText = normalizeText(originalText); + const normalizedQuery = normalizeText(query); + const positions = []; + + let index = 0; + while ((index = normalizedText.indexOf(normalizedQuery, index)) !== -1) { + positions.push({ + start: index, + end: index + normalizedQuery.length + }); + index += 1; + } + + return positions; +} + +/** + * Highlight matching text in HTML content + * Preserves existing HTML structure while highlighting text nodes + * @param {string} htmlContent - HTML content to highlight + * @param {string} query - Search query + * @returns {string} - HTML with highlighted matches + */ +function highlightMatches(htmlContent, query) { + if (!query || !htmlContent) return htmlContent; + + // Create a temporary div to work with the DOM + const temp = document.createElement('div'); + temp.innerHTML = htmlContent; + + // Process text nodes recursively + highlightTextNodes(temp, query); + + return temp.innerHTML; +} + +/** + * Recursively highlight text in text nodes + * @param {Node} node - DOM node to process + * @param {string} query - Search query + */ +function highlightTextNodes(node, query) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent; + const positions = findMatchPositions(text, query); + + if (positions.length > 0) { + // Create a document fragment with highlighted text + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + + positions.forEach(pos => { + // Add text before match + if (pos.start > lastIndex) { + fragment.appendChild( + document.createTextNode(text.substring(lastIndex, pos.start)) + ); + } + + // Add highlighted match + const span = document.createElement('span'); + span.className = 'filter-highlight'; + span.textContent = text.substring(pos.start, pos.end); + fragment.appendChild(span); + + lastIndex = pos.end; + }); + + // Add remaining text + if (lastIndex < text.length) { + fragment.appendChild( + document.createTextNode(text.substring(lastIndex)) + ); + } + + // Replace the text node with the fragment + node.parentNode.replaceChild(fragment, node); + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + // Skip certain elements that shouldn't be highlighted + const skipTags = ['SCRIPT', 'STYLE', 'BUTTON', 'INPUT', 'TEXTAREA']; + if (!skipTags.includes(node.tagName)) { + // Process child nodes (copy to array first since we may modify) + const children = Array.from(node.childNodes); + children.forEach(child => highlightTextNodes(child, query)); + } + } +} + +/** + * Get plain text content from a message element + * @param {HTMLElement} messageEl - Message element + * @param {string} contentSelector - CSS selector for content element + * @returns {string} - Plain text content + */ +function getMessageText(messageEl, contentSelector) { + const contentEl = messageEl.querySelector(contentSelector); + return contentEl ? contentEl.textContent : ''; +} + +// Export functions for use in other modules +window.FilterUtils = { + normalizeText, + textMatches, + findMatchPositions, + highlightMatches, + highlightTextNodes, + getMessageText +}; diff --git a/app/templates/base.html b/app/templates/base.html index b6c8f34..3e666e3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -371,6 +371,9 @@ + + + diff --git a/app/templates/dm.html b/app/templates/dm.html index ddf1fb3..42c33bb 100644 --- a/app/templates/dm.html +++ b/app/templates/dm.html @@ -75,6 +75,19 @@