feat(messages): search in message content — Enter triggers server-side search with highlighted results
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-27 15:54:36 +01:00
parent bfc6707a65
commit f8badfccac
4 changed files with 212 additions and 4 deletions

View File

@ -462,3 +462,76 @@ def forward_message(message_id):
return jsonify({'error': 'Błąd przekazywania wiadomości'}), 500
finally:
db.close()
# ============================================================
# 6. GET /api/messages/search — Search message content
# ============================================================
@bp.route('/api/messages/search', methods=['GET'])
@login_required
@member_required
def search_messages():
"""Search messages across all user's conversations."""
query = request.args.get('q', '').strip()
if len(query) < 3:
return jsonify({'results': []})
db = SessionLocal()
try:
from sqlalchemy import func
# Get user's conversation IDs
user_conv_ids = [m.conversation_id for m in
db.query(ConversationMember.conversation_id).filter_by(
user_id=current_user.id
).all()]
if not user_conv_ids:
return jsonify({'results': []})
# Search in message content (strip HTML via regexp_replace)
search_term = f'%{query}%'
messages = db.query(ConvMessage).filter(
ConvMessage.conversation_id.in_(user_conv_ids),
ConvMessage.is_deleted == False, # noqa: E712
func.regexp_replace(ConvMessage.content, '<[^>]+>', '', 'g').ilike(search_term),
).order_by(ConvMessage.created_at.desc()).limit(20).all()
results = []
for msg in messages:
# Get conversation display name
conv = msg.conversation
conv_name = conv.name
if not conv.is_group:
other = [m for m in conv.members if m.user_id != current_user.id]
if other and other[0].user:
conv_name = other[0].user.name or other[0].user.email.split('@')[0]
# Build preview with context around match
plain = strip_html(msg.content)
lower_plain = plain.lower()
idx = lower_plain.find(query.lower())
if idx >= 0:
start = max(0, idx - 30)
end = min(len(plain), idx + len(query) + 50)
preview = ('...' if start > 0 else '') + plain[start:end] + ('...' if end < len(plain) else '')
else:
preview = plain[:80]
results.append({
'message_id': msg.id,
'conversation_id': msg.conversation_id,
'conversation_name': conv_name,
'sender_name': msg.sender.name if msg.sender else '',
'preview': preview,
'created_at': msg.created_at.isoformat() if msg.created_at else None,
})
return jsonify({'results': results})
except Exception as e:
logger.error(f"search_messages error: {e}")
return jsonify({'results': []})
finally:
db.close()

View File

@ -979,6 +979,59 @@
opacity: 1;
}
/* --- Search results section --- */
.search-results-section {
border-top: 2px solid var(--conv-accent, #2E4872);
margin-top: 8px;
padding-top: 4px;
}
.search-results-header {
padding: 8px 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--conv-accent, #2E4872);
}
.search-result-item {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid var(--conv-border, #e1dfdd);
transition: background 0.12s;
}
.search-result-item:hover {
background: var(--conv-primary-light, #EDF0F5);
}
.search-result-name {
font-size: 13px;
font-weight: 600;
color: var(--conv-text-primary);
margin-bottom: 2px;
}
.search-result-preview {
font-size: 12px;
color: var(--conv-text-secondary);
line-height: 1.4;
}
.search-result-preview mark {
background: #fef08a;
color: inherit;
padding: 0 2px;
border-radius: 2px;
}
.search-result-date {
font-size: 11px;
color: var(--conv-text-muted);
margin-top: 2px;
}
.input-hint {
font-size: 12px;
color: var(--conv-text-secondary, #605e5c);

View File

@ -1925,10 +1925,92 @@
var timer = null;
searchInput.addEventListener('input', function () {
clearTimeout(timer);
var q = searchInput.value.trim();
// Client-side filter for conversation names
timer = setTimeout(function () {
ConversationList.searchFilter(searchInput.value);
ConversationList.searchFilter(q);
// Clear server results when typing changes
if (q.length < 3) {
Search.clearResults();
}
}, 300);
});
// Enter = search in message content (server-side)
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
var q = searchInput.value.trim();
if (q.length >= 3) {
Search.searchContent(q);
}
}
});
},
searchContent: async function (query) {
try {
var data = await api('/api/messages/search?q=' + encodeURIComponent(query));
var results = data.results || [];
Search.showResults(results, query);
} catch (_) {}
},
showResults: function (results, query) {
Search.clearResults();
var container = document.getElementById('conversationList');
if (!container) return;
var section = el('div', 'search-results-section');
section.id = 'searchResultsSection';
var header = el('div', 'search-results-header');
header.textContent = results.length
? 'Znalezione w wiadomościach (' + results.length + '):'
: 'Brak wyników w treści wiadomości';
section.appendChild(header);
results.forEach(function (r) {
var item = el('div', 'search-result-item');
var nameRow = el('div', 'search-result-name');
nameRow.textContent = r.conversation_name + (r.sender_name ? ' — ' + r.sender_name : '');
item.appendChild(nameRow);
var previewRow = el('div', 'search-result-preview');
// Highlight query in preview
var escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
previewRow.innerHTML = r.preview.replace(
new RegExp('(' + escapedQuery + ')', 'gi'),
'<mark>$1</mark>'
);
item.appendChild(previewRow);
var dateRow = el('div', 'search-result-date');
dateRow.textContent = formatTime(r.created_at);
item.appendChild(dateRow);
item.addEventListener('click', function () {
Search.clearResults();
var input = document.getElementById('searchInput');
if (input) input.value = '';
ConversationList.searchFilter('');
ConversationList.selectConversation(r.conversation_id);
// Scroll to message after load
setTimeout(function () {
ChatView.scrollToMessage(r.message_id);
}, 500);
});
section.appendChild(item);
});
container.appendChild(section);
},
clearResults: function () {
var existing = document.getElementById('searchResultsSection');
if (existing) existing.remove();
},
};

View File

@ -5,7 +5,7 @@
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/conversations.css') }}?v=4">
<link rel="stylesheet" href="{{ url_for('static', filename='css/conversations.css') }}?v=5">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
<style>
footer { display: none !important; }
@ -27,7 +27,7 @@
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input type="text" id="searchInput" placeholder="Szukaj rozmów...">
<input type="text" id="searchInput" placeholder="Szukaj... (Enter = w treści)">
<button class="new-message-btn" id="newMessageBtn" title="Nowa wiadomość">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
@ -226,7 +226,7 @@ window.__CSRF_TOKEN__ = '{{ csrf_token() }}';
// Load conversations.js after data is set
(function() {
var s = document.createElement('script');
s.src = '{{ url_for("static", filename="js/conversations.js") }}?v=9';
s.src = '{{ url_for("static", filename="js/conversations.js") }}?v=10';
document.body.appendChild(s);
})();
{% endblock %}