feat: add reusable new discussion modal with @mention autocompletion support

This commit is contained in:
kdeterme 2026-08-12 11:46:29 +02:00
parent 1edfb85a0e
commit fb9664b65a
14 changed files with 669 additions and 73 deletions

View file

@ -16,6 +16,34 @@
<div class="background-layer"></div>
<div class="bg-image">
<div class="container content-box">
{% if unread_notifications_count %}
<div id="home-unread-notifications-alert" class="row justify-content-center mt-3 mb-2">
<div class="col-md-10 col-lg-8">
<div class="alert alert-warning border-0 shadow-sm d-flex align-items-center justify-content-between p-3 rounded-3" role="alert" style="background-color: rgba(255, 243, 205, 0.95);">
<div class="d-flex align-items-center text-start me-3">
<div class="rounded-circle bg-warning bg-opacity-25 p-2 me-3 d-flex align-items-center justify-content-center" style="width: 42px; height: 42px; flex-shrink: 0;">
<i class="bi bi-bell-fill fs-5 text-warning-emphasis"></i>
</div>
<div>
<div class="fw-bold text-dark">
{% translate "Notifications non lues" %}
</div>
<div class="small text-secondary">
{% blocktranslate count count=unread_notifications_count %}
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notification non lue.
{% plural %}
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notifications non lues.
{% endblocktranslate %}
</div>
</div>
</div>
<a href="{% url 'notifications:inbox' %}" class="btn btn-warning btn-sm px-3 py-2 fw-semibold text-nowrap rounded-2 text-dark">
<i class="bi bi-envelope-open me-1"></i>{% translate "Consulter" %}
</a>
</div>
</div>
</div>
{% endif %}
<!-- <h1 class="display-4 m-3 halo-text"><strong>Brussels Mobility</strong></h1> -->
<div class="row justify-content-center mt-2 mb-3">
<div class="col-md-12">
@ -56,6 +84,9 @@
<i class="bi bi-currency-euro me-2"></i>{% translate "Nouvelle commande" %}
</a>
{% endif %}
<button class="btn btn-light main-button my-1 ms-1" type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
<i class="bi bi-chat-dots me-2"></i>{% translate "Nouvelle discussion" %}
</button>
</div>
</div>
@ -101,10 +132,18 @@
{% empty %}
{% include "home/widgets/_default.html" %}
{% endfor %}
</div>
</div>
<!-- Floating Discussion FAB -->
<button id="home-discussion-fab" class="btn btn-primary rounded-circle shadow-lg d-flex align-items-center justify-content-center"
type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal"
style="position: fixed; bottom: 28px; right: 28px; width: 58px; height: 58px; z-index: 1050; font-size: 1.5rem;"
title="{% translate 'Nouvelle discussion' %}" aria-label="{% translate 'Nouvelle discussion' %}">
<i class="bi bi-chat-dots-fill"></i>
</button>
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content%}

View file

@ -267,3 +267,29 @@ class HomeIndexViewTests(TestCase):
self.assertContains(response, reverse('inspections:quick_inspection_map'))
self.assertNotContains(response, 'data-bs-target="#newInspectionModal"')
def test_home_index_unread_notifications_banner(self):
from django.urls import reverse
from django.contrib.auth import get_user_model
from common.models import UserConfig
from notifications.models import Notification, NotificationRecipient
User = get_user_model()
user = User.objects.create_user('notif_user', password='pass')
UserConfig.objects.create(user=user)
self.client.force_login(user)
# 1. No unread notifications -> banner not shown
response = self.client.get(reverse('home:index'))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'id="home-unread-notifications-alert"')
# 2. Add unread notification -> banner shown with count
notif = Notification.objects.create(title="Test notification")
NotificationRecipient.objects.create(notification=notif, user=user)
response = self.client.get(reverse('home:index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="home-unread-notifications-alert"')
self.assertContains(response, 'Vous avez <strong class="home-unread-notifications-count">1</strong> notification non lue.')

View file

@ -812,9 +812,11 @@ document.addEventListener('DOMContentLoaded', function () {
let mentionStart = -1;
let mentionQuery = null;
let searchTimer = null;
let currentResults = [];
let selectedIndex = -1;
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function syncReal() {
@ -842,10 +844,25 @@ document.addEventListener('DOMContentLoaded', function () {
mentions = mentions.filter(m => m.end <= start || m.start >= end);
}
function highlightIndex(idx) {
const items = dropdown.querySelectorAll('.list-group-item');
if (!items || !items.length) return;
items.forEach((btn, i) => {
if (i === idx) {
btn.classList.add('active');
btn.scrollIntoView({ block: 'nearest' });
} else {
btn.classList.remove('active');
}
});
}
function showDropdown(results) {
dropdown.innerHTML = '';
currentResults = results || [];
selectedIndex = -1;
if (!results || !results.length) { hideDropdown(); return; }
results.forEach(r => {
results.forEach((r, idx) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
@ -858,8 +875,15 @@ document.addEventListener('DOMContentLoaded', function () {
e.preventDefault();
insertMention(r);
});
btn.addEventListener('mouseenter', function () {
selectedIndex = idx;
highlightIndex(selectedIndex);
});
dropdown.appendChild(btn);
});
selectedIndex = 0;
highlightIndex(0);
dropdown.style.display = 'block';
}
@ -868,6 +892,8 @@ document.addEventListener('DOMContentLoaded', function () {
dropdown.innerHTML = '';
mentionStart = -1;
mentionQuery = null;
currentResults = [];
selectedIndex = -1;
}
function insertMention(r) {
@ -890,6 +916,35 @@ document.addEventListener('DOMContentLoaded', function () {
hideDropdown();
}
textarea.addEventListener('keydown', function (e) {
if (dropdown.style.display === 'block' && currentResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % currentResults.length;
highlightIndex(selectedIndex);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
highlightIndex(selectedIndex);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
e.preventDefault();
insertMention(currentResults[selectedIndex]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideDropdown();
return;
}
}
});
function searchMentions(q) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {

View file

@ -5,6 +5,33 @@
{% block content %}
<div class="row g-3">
{% if unread_notifications_count %}
<div id="home-unread-notifications-alert" class="col-12">
<div class="alert alert-warning border-0 shadow-sm d-flex align-items-center justify-content-between p-3 mb-0 rounded-4" role="alert" style="background-color: rgba(255, 243, 205, 0.95);">
<div class="d-flex align-items-center text-start me-2">
<div class="rounded-circle bg-warning bg-opacity-25 p-2 me-2.5 d-flex align-items-center justify-content-center" style="width: 38px; height: 38px; flex-shrink: 0;">
<i class="bi bi-bell-fill fs-5 text-warning-emphasis"></i>
</div>
<div>
<div class="fw-bold text-dark small">
{% translate "Notifications non lues" %}
</div>
<div class="small text-secondary" style="font-size: 0.825rem;">
{% blocktranslate count count=unread_notifications_count %}
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notification non lue.
{% plural %}
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notifications non lues.
{% endblocktranslate %}
</div>
</div>
</div>
<a href="{% url 'mobile:notifications_mobile' %}" class="btn btn-warning btn-sm px-3 py-2 fw-semibold text-nowrap rounded-3 text-dark">
{% translate "Consulter" %}
</a>
</div>
</div>
{% endif %}
<!-- New items / Quick Actions -->
{% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" or is_inspector or can_add_structures_repair or can_add_intervention_mobile %}
<div class="col-12">
@ -40,6 +67,11 @@
<i class="bi bi-plus-lg"></i>
</a>
{% endif %}
<button type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">
<span><i class="bi bi-chat-dots me-2"></i>{% translate "Nouvelle discussion" %}</span>
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
</div>
@ -129,4 +161,5 @@
</div>
{% endif %}
</div>
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content %}

View file

@ -0,0 +1,266 @@
{% load i18n %}
<!-- Modal Nouvelle Discussion -->
<div class="modal fade" id="newDiscussionModal" tabindex="-1" aria-labelledby="newDiscussionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow rounded-4">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="newDiscussionModalLabel">
<i class="bi bi-chat-left-text-fill text-primary me-2"></i>{% translate "Nouvelle discussion" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form method="post" action="{% url 'notifications:discussion_create' %}" enctype="multipart/form-data" id="new-discussion-form">
{% csrf_token %}
<div class="modal-body pt-3">
<div class="mb-3">
<label for="new-discussion-title" class="form-label small fw-semibold text-secondary">
{% translate "Sujet / Titre (optionnel)" %}
</label>
<input type="text" class="form-control rounded-3" id="new-discussion-title" name="title"
placeholder="{% translate 'Ex: Question sur le dossier...' %}">
</div>
<div class="mb-3 position-relative">
<label for="new-discussion-content-display" class="form-label small fw-semibold text-secondary">
{% translate "Message *" %}
</label>
<div class="form-text small text-muted mb-1">
<i class="bi bi-info-circle me-1"></i>{% translate "Tapez @ pour mentionner/tagger des utilisateurs ou des équipes." %}
</div>
<input type="hidden" name="content" id="new-discussion-content-real">
<textarea class="form-control rounded-3 p-3" id="new-discussion-content-display" rows="4"
placeholder="{% translate 'Rédigez votre message... (@ pour tagger quelqu\'un)' %}" required style="resize:none;"></textarea>
<!-- Autocomplete dropdown pour @mentions -->
<div id="new-discussion-mention-dropdown" class="list-group position-absolute shadow-lg border-0 rounded-3"
style="display:none; z-index:1060; max-height:200px; overflow-y:auto; width:100%; top: 100%;"></div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold text-secondary d-block">
{% translate "Pièces jointes" %}
</label>
<label class="btn btn-outline-secondary btn-sm rounded-pill px-3 mb-1" for="new-discussion-file-input">
<i class="bi bi-paperclip me-1"></i>{% translate "Joindre des fichiers" %}
</label>
<input type="file" name="attachments" multiple class="d-none" id="new-discussion-file-input">
<div id="new-discussion-file-preview" class="d-flex flex-wrap gap-2 mt-2" style="display:none;"></div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light rounded-pill px-3" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="submit" class="btn btn-primary rounded-pill px-4">
<i class="bi bi-send-fill me-1.5"></i>{% translate "Envoyer" %}
</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
document.addEventListener('DOMContentLoaded', function () {
const modalEl = document.getElementById('newDiscussionModal');
if (modalEl && modalEl.parentElement !== document.body) {
document.body.appendChild(modalEl);
}
const textarea = document.getElementById('new-discussion-content-display');
const realInput = document.getElementById('new-discussion-content-real');
const dropdown = document.getElementById('new-discussion-mention-dropdown');
const form = document.getElementById('new-discussion-form');
const fileInput = document.getElementById('new-discussion-file-input');
const filePreview = document.getElementById('new-discussion-file-preview');
if (!textarea || !dropdown || !realInput || !form) return;
let mentions = [];
let mentionStart = -1;
let mentionQuery = null;
let searchTimer = null;
let currentResults = [];
let selectedIndex = -1;
function escHtml(str) {
return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function syncReal() {
const dv = textarea.value;
if (!mentions.length) { realInput.value = dv; return; }
let real = '';
let pos = 0;
const sorted = [...mentions].sort((a, b) => a.start - b.start);
for (const m of sorted) {
if (m.start > pos) real += dv.slice(pos, m.start);
real += m.marker;
pos = m.end;
}
real += dv.slice(pos);
realInput.value = real;
}
function shiftMentions(fromPos, delta) {
for (const m of mentions) {
if (m.start >= fromPos) { m.start += delta; m.end += delta; }
}
}
function removeMentionsIn(start, end) {
mentions = mentions.filter(m => m.end <= start || m.start >= end);
}
function highlightIndex(idx) {
const items = dropdown.querySelectorAll('.list-group-item');
if (!items || !items.length) return;
items.forEach((btn, i) => {
if (i === idx) {
btn.classList.add('active');
btn.scrollIntoView({ block: 'nearest' });
} else {
btn.classList.remove('active');
}
});
}
function showDropdown(results) {
dropdown.innerHTML = '';
currentResults = results || [];
selectedIndex = -1;
if (!results || !results.length) { hideDropdown(); return; }
results.forEach((r, idx) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
const icon = r.type === 'user' ? 'bi-person-fill' : 'bi-people-fill';
const badgeCls = r.type === 'user'
? 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle'
: 'badge bg-info-subtle text-info-emphasis border border-info-subtle';
btn.innerHTML = `<span class="${badgeCls}"><i class="bi ${icon}"></i></span><span>${escHtml(r.display)}</span>`;
btn.addEventListener('mousedown', function (e) {
e.preventDefault();
insertMention(r);
});
btn.addEventListener('mouseenter', function () {
selectedIndex = idx;
highlightIndex(selectedIndex);
});
dropdown.appendChild(btn);
});
selectedIndex = 0;
highlightIndex(0);
dropdown.style.display = 'block';
}
function hideDropdown() {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
mentionStart = -1;
mentionQuery = null;
currentResults = [];
selectedIndex = -1;
}
function insertMention(r) {
const shortTag = `@[${r.display}]`;
const marker = r.type === 'user'
? `@[${r.display}](u:${r.id})`
: `@[${r.display}](t:${r.id})`;
const dv = textarea.value;
const qLen = mentionQuery ? mentionQuery.length : 0;
const before = dv.slice(0, mentionStart);
const after = dv.slice(mentionStart + 1 + qLen);
textarea.value = before + shortTag + ' ' + after;
removeMentionsIn(mentionStart, mentionStart + 1 + qLen);
shiftMentions(mentionStart + 1 + qLen, shortTag.length + 1 - (1 + qLen));
mentions.push({ start: mentionStart, end: mentionStart + shortTag.length, marker });
syncReal();
const pos = mentionStart + shortTag.length + 1;
textarea.setSelectionRange(pos, pos);
textarea.focus();
hideDropdown();
}
textarea.addEventListener('keydown', function (e) {
if (dropdown.style.display === 'block' && currentResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % currentResults.length;
highlightIndex(selectedIndex);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
highlightIndex(selectedIndex);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
e.preventDefault();
insertMention(currentResults[selectedIndex]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideDropdown();
return;
}
}
});
textarea.addEventListener('input', function () {
const val = textarea.value;
const cursorPos = textarea.selectionStart;
const lastAt = val.lastIndexOf('@', cursorPos - 1);
if (lastAt !== -1) {
const query = val.slice(lastAt + 1, cursorPos);
if (!/\s/.test(query) && query.length <= 30) {
mentionStart = lastAt;
mentionQuery = query;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
fetch("{% url 'notifications:api_mention_search' %}?q=" + encodeURIComponent(query))
.then(r => r.json())
.then(data => showDropdown(data.results))
.catch(() => hideDropdown());
}, 150);
syncReal();
return;
}
}
hideDropdown();
syncReal();
});
form.addEventListener('submit', function () {
syncReal();
});
// Aperçu pièces jointes
if (fileInput && filePreview) {
fileInput.addEventListener('change', function () {
filePreview.innerHTML = '';
const files = fileInput.files;
if (!files || files.length === 0) {
filePreview.style.display = 'none';
return;
}
filePreview.style.display = 'flex';
Array.from(files).forEach((file) => {
const chip = document.createElement('span');
chip.className = 'badge bg-light text-dark border px-2 py-1 small';
chip.innerHTML = `<i class="bi bi-paperclip me-1"></i>${escHtml(file.name)}`;
filePreview.appendChild(chip);
});
});
}
});
})();
</script>

View file

@ -1,35 +1,41 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load mention_tags %}
{% block head %}
<style>
.thread-container {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 1.25rem;
}
.msg-bubble {
max-width: 80%;
max-width: 85%;
display: inline-block;
word-break: break-word;
line-height: 1.5;
font-size: 0.935rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.msg-mine .msg-bubble {
background: var(--brand-color, #2f3e8b);
color: #fff;
border-radius: 16px 16px 4px 16px;
border-radius: 18px 18px 4px 18px;
}
.msg-other .msg-bubble {
background: #f1f3f5;
color: #212529;
border-radius: 16px 16px 16px 4px;
border-radius: 18px 18px 18px 4px;
border: 1px solid #e9ecef;
}
.msg-mine { align-items: flex-end; }
.msg-other { align-items: flex-start; }
.avatar-xs {
width: 32px;
height: 32px;
font-size: 0.75rem;
width: 34px;
height: 34px;
font-size: 0.8rem;
font-weight: 600;
border-radius: 50%;
display: flex;
align-items: center;
@ -37,83 +43,111 @@
flex-shrink: 0;
background: #6c757d;
color: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
#thread-scroll {
max-height: 60vh;
max-height: 65vh;
overflow-y: auto;
background-color: #fafbfc;
}
.attachment-thumb {
width: 60px;
height: 60px;
width: 70px;
height: 70px;
object-fit: cover;
border-radius: 6px;
border-radius: 8px;
border: 1px solid #dee2e6;
transition: transform 0.15s;
}
.attachment-thumb:hover {
transform: scale(1.05);
}
.participant-pill {
font-size: 0.825rem;
font-weight: 500;
padding: 0.35rem 0.65rem;
background-color: #ffffff;
color: #334155;
border: 1px solid #cbd5e1;
border-radius: 9999px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4" style="max-width:860px;">
<div class="container py-4" style="max-width:880px;">
<!-- Breadcrumb / Header -->
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
<div class="d-flex align-items-center gap-3 mb-3">
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-sm btn-outline-secondary rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;">
<i class="bi bi-arrow-left fs-6"></i>
</a>
<div class="flex-grow-1">
<h1 class="h4 mb-0">{{ discussion.title|default:discussion }}</h1>
{% if discussion.object_label %}
<a href="{{ discussion.object_url }}" class="small text-muted">
<i class="bi bi-link-45deg me-1"></i>{{ discussion.object_label }}
</a>
{% endif %}
<div class="flex-grow-1 min-w-0">
<div class="d-flex align-items-center gap-2 flex-wrap">
<h1 class="h4 mb-0 fw-bold text-dark">{{ discussion.title|default:discussion }}</h1>
{% if discussion.object_label %}
<a href="{{ discussion.object_url }}" class="badge bg-primary bg-opacity-10 text-primary border border-primary-subtle text-decoration-none py-1.5 px-2.5 rounded-pill">
<i class="bi bi-link-45deg me-1"></i>{{ discussion.object_label }}
</a>
{% endif %}
{% if discussion.is_closed %}
<span class="badge bg-secondary rounded-pill py-1.5 px-2.5"><i class="bi bi-lock me-1"></i>{% translate "Clôturée" %}</span>
{% endif %}
</div>
</div>
{% if discussion.is_closed %}
<span class="badge bg-secondary"><i class="bi bi-lock me-1"></i>{% translate "Clôturée" %}</span>
{% endif %}
</div>
<!-- Participants -->
<div class="mb-3 d-flex flex-wrap gap-1">
<div class="mb-3 d-flex flex-wrap gap-1.5 align-items-center">
<span class="small text-muted me-1"><i class="bi bi-people me-1"></i>{% translate "Participants" %} :</span>
{% for p in discussion.participants.all %}
<span class="badge bg-light text-dark border">
<i class="bi bi-person me-1"></i>{{ p.get_full_name|default:p.username }}
<span class="participant-pill">
<i class="bi bi-person-fill text-primary opacity-75"></i>
{{ p.get_full_name|default:p.username }}
</span>
{% endfor %}
</div>
<!-- Messages -->
<div class="card shadow-sm mb-3">
<div class="card-body p-3" id="thread-scroll">
<div class="card shadow-sm border-0 mb-3 rounded-3 overflow-hidden">
<div class="card-body p-3 p-md-4" id="thread-scroll">
{% if not thread_messages %}
<p class="text-muted text-center py-4 mb-0">
<i class="bi bi-chat-square d-block fs-2 mb-2"></i>
{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}
</p>
<div class="text-muted text-center py-5">
<i class="bi bi-chat-square-dots d-block fs-1 mb-2 text-primary opacity-50"></i>
<p class="mb-0">{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}</p>
</div>
{% else %}
<div class="thread-container">
{% for msg in thread_messages %}
<div class="d-flex flex-column {% if msg.author == request.user %}msg-mine{% else %}msg-other{% endif %}">
{% if msg.author != request.user %}
<div class="d-flex align-items-end gap-2">
<div class="avatar-xs">
<div class="d-flex align-items-start gap-2">
<div class="avatar-xs mt-1">
{{ msg.author.get_short_name|first|upper|default:msg.author.username|first|upper }}
</div>
{% endif %}
<div>
{% if msg.author != request.user %}
<div class="small text-muted mb-1">
<div class="small text-muted mb-1 ms-1">
<strong>{{ msg.author.get_full_name|default:msg.author.username }}</strong>
· {{ msg.created_at|date:"d/m/Y H:i" }}
</div>
{% else %}
<div class="small text-muted mb-1 text-end">
<div class="small text-muted mb-1 text-end me-1">
{{ msg.created_at|date:"d/m/Y H:i" }}
</div>
{% endif %}
<div class="msg-bubble p-3">
{{ msg.content|linebreaksbr }}
{% if msg.author == request.user %}
{{ msg.content|render_mentions:True }}
{% else %}
{{ msg.content|render_mentions }}
{% endif %}
{% if msg.attachments.exists %}
<div class="mt-2 d-flex flex-wrap gap-2">
<div class="mt-2.5 pt-2 border-top border-opacity-25 d-flex flex-wrap gap-2">
{% for att in msg.attachments.all %}
{% if att.is_image %}
<a href="{{ att.file.url }}" target="_blank">
@ -121,7 +155,7 @@
</a>
{% else %}
<a href="{{ att.file.url }}" target="_blank"
class="btn btn-sm {% if msg.author == request.user %}btn-outline-light{% else %}btn-outline-secondary{% endif %}">
class="btn btn-sm {% if msg.author == request.user %}btn-light text-dark{% else %}btn-outline-secondary{% endif %}">
<i class="bi bi-paperclip me-1"></i>{{ att.name|truncatechars:24 }}
</a>
{% endif %}
@ -144,26 +178,28 @@
{% if not discussion.is_closed %}
<form method="post" enctype="multipart/form-data" action="">
{% csrf_token %}
<div class="card shadow-sm">
<div class="card shadow-sm border-0 rounded-3">
<div class="card-body p-3">
<textarea name="content" class="form-control mb-2" rows="3"
placeholder="{% translate 'Répondre...' %}" required id="reply-textarea"></textarea>
<div class="d-flex justify-content-between align-items-center">
<label class="btn btn-outline-secondary btn-sm mb-0" id="attach-label">
<textarea name="content" class="form-control mb-2 border-0 bg-light rounded-3 p-3" rows="3"
placeholder="{% translate 'Écrire un message... (@ pour mentionner)' %}" required id="reply-textarea" style="resize:none;"></textarea>
<div class="d-flex justify-content-between align-items-center pt-2">
<label class="btn btn-outline-secondary btn-sm mb-0 rounded-pill px-3" id="attach-label">
<i class="bi bi-paperclip me-1"></i>{% translate "Joindre" %}
<input type="file" name="attachments" multiple class="d-none" id="attach-input"
onchange="document.getElementById('attach-count').textContent = this.files.length ? '(' + this.files.length + ')' : ''">
</label>
<span id="attach-count" class="small text-muted"></span>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>{% translate "Envoyer" %}
</button>
<div class="d-flex align-items-center gap-2">
<span id="attach-count" class="small text-muted"></span>
<button type="submit" class="btn btn-primary px-4 rounded-pill">
<i class="bi bi-send-fill me-1.5"></i>{% translate "Envoyer" %}
</button>
</div>
</div>
</div>
</div>
</form>
{% else %}
<div class="alert alert-secondary"><i class="bi bi-lock me-2"></i>{% translate "Cette discussion est clôturée." %}</div>
<div class="alert alert-secondary border-0 shadow-sm rounded-3"><i class="bi bi-lock me-2"></i>{% translate "Cette discussion est clôturée." %}</div>
{% endif %}
</div>

View file

@ -36,9 +36,14 @@
<h1 class="h3 mb-0">
<i class="bi bi-chat-dots me-2 text-primary"></i>{% translate "Discussions" %}
</h1>
<a href="{% url 'notifications:inbox' %}" class="btn btn-outline-secondary">
<i class="bi bi-bell me-1"></i>{% translate "Notifications" %}
</a>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
<i class="bi bi-chat-plus-fill me-1.5"></i>{% translate "Nouvelle discussion" %}
</button>
<a href="{% url 'notifications:inbox' %}" class="btn btn-outline-secondary">
<i class="bi bi-bell me-1"></i>{% translate "Notifications" %}
</a>
</div>
</div>
{% if not items %}
@ -108,4 +113,5 @@
{% endif %}
</div>
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content %}

View file

@ -177,7 +177,7 @@
<i class="bi bi-hourglass me-1"></i>{% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }}
</span>
{% endif %}
{% if notif.action_url %}
{% if notif.action_url and notif.action_url != request.path and notif.action_url != '/notifications/' and notif.action_url != '/notifications' %}
<a href="{{ notif.action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
{{ notif.action_label|default:notif.action_url }}
<i class="bi bi-arrow-right ms-1"></i>

View file

@ -98,7 +98,7 @@
{% if item.team_name %}
<span class="badge bg-secondary notif-type-pill">{{ item.team_name }}</span>
{% endif %}
{% if item.mobile_action_url %}
{% if item.mobile_action_url and item.mobile_action_url != request.path and item.mobile_action_url != '/notifications/' and item.mobile_action_url != '/notifications' and item.mobile_action_url != '/mobile/notifications/' and item.mobile_action_url != '/mobile/notifications' %}
<a href="{{ item.mobile_action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
{{ notif.action_label|default:item.mobile_action_url }}
</a>

View file

@ -11,17 +11,19 @@ MENTION_PATTERN = re.compile(r'@\[([^\]]+)\]\(([ut]):(\d+)\)')
@register.filter
def render_mentions(value):
def render_mentions(value, is_mine=False):
"""
Renders @[Name](u:id) / @[Name](t:id) mention markers as styled badge spans,
and converts newlines to <br> (like linebreaksbr).
- u: user mention → primary badge
- t: team mention → info badge
- u: user mention → badge
- t: team mention → badge
"""
if not value:
return ''
is_mine_bool = bool(is_mine and str(is_mine).lower() not in ('false', '0', ''))
parts = []
last_end = 0
@ -32,16 +34,24 @@ def render_mentions(value):
name = m.group(1)
kind = m.group(2)
if kind == 'u':
css = 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle fw-semibold'
icon = 'bi-person-fill'
if is_mine_bool:
if kind == 'u':
css = 'badge bg-white text-primary shadow-sm fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
icon = 'bi-person-fill text-primary'
else:
css = 'badge bg-white text-info shadow-sm fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
icon = 'bi-people-fill text-info'
else:
css = 'badge bg-info-subtle text-info-emphasis border border-info-subtle fw-semibold'
icon = 'bi-people-fill'
if kind == 'u':
css = 'badge bg-primary bg-opacity-10 text-primary border border-primary-subtle fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
icon = 'bi-person-fill'
else:
css = 'badge bg-info bg-opacity-10 text-info border border-info-subtle fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
icon = 'bi-people-fill'
parts.append(
f'<span class="{css}">'
f'<i class="bi {icon} me-1" style="font-size:0.75em;"></i>'
f'<i class="bi {icon} me-1" style="font-size:0.85em;"></i>'
f'@{escape(name)}'
f'</span>'
)

View file

@ -0,0 +1,62 @@
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from notifications.models import Discussion, Message
User = get_user_model()
class DiscussionListTests(TestCase):
def setUp(self):
self.user1 = User.objects.create_user(username='user1', password='pass')
self.user2 = User.objects.create_user(username='user2', password='pass')
self.user3 = User.objects.create_user(username='user3', password='pass')
def test_discussion_list_filtering(self):
# Discussion 1: created by user1
d1 = Discussion.objects.create(title="Discussion 1", created_by=self.user1)
d1.participants.add(self.user1, self.user2, self.user3)
# Discussion 2: created by user2, user1 wrote a message
d2 = Discussion.objects.create(title="Discussion 2", created_by=self.user2)
d2.participants.add(self.user1, self.user2, self.user3)
Message.objects.create(discussion=d2, author=self.user1, content="Hello from user1")
# Discussion 3: created by user2, user1 is tagged
d3 = Discussion.objects.create(title="Discussion 3", created_by=self.user2)
d3.participants.add(self.user1, self.user2, self.user3)
Message.objects.create(discussion=d3, author=self.user2, content=f"Hello @[User 1](u:{self.user1.pk})")
# Discussion 4: created by user2, user3 participant but user1 is NOT author and NOT tagged
d4 = Discussion.objects.create(title="Discussion 4", created_by=self.user2)
d4.participants.add(self.user1, self.user2, self.user3)
# Discussion 5: auto-created intervention discussion with created_by=user1, but user1 never posted a message and was never tagged
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(User)
d5 = Discussion.objects.create(title="Discussion 5", created_by=self.user1, content_type=ct, object_id=1)
Message.objects.create(discussion=d5, author=self.user2, content="Hello user3")
self.client.force_login(self.user1)
response = self.client.get(reverse('notifications:discussion_list'))
self.assertEqual(response.status_code, 200)
discussions_in_context = [item['discussion'] for item in response.context['items']]
self.assertIn(d1, discussions_in_context)
self.assertIn(d2, discussions_in_context)
self.assertIn(d3, discussions_in_context)
self.assertNotIn(d4, discussions_in_context)
self.assertNotIn(d5, discussions_in_context)
def test_discussion_create(self):
self.client.force_login(self.user1)
response = self.client.post(reverse('notifications:discussion_create'), {
'title': 'Sujet test',
'content': f'Bonjour @[User 2](u:{self.user2.pk})',
})
self.assertEqual(response.status_code, 302)
discussion = Discussion.objects.get(title='Sujet test')
self.assertEqual(discussion.created_by, self.user1)
self.assertIn(self.user1, discussion.participants.all())
self.assertIn(self.user2, discussion.participants.all())
self.assertEqual(discussion.messages.count(), 1)

View file

@ -19,6 +19,7 @@ urlpatterns = [
path('teams/<int:pk>/delete/', views.team_delete, name='team_delete'),
# Discussions
path('discussions/', views.discussion_list, name='discussion_list'),
path('discussions/new/', views.discussion_create, name='discussion_create'),
path('discussions/<int:pk>/', views.discussion_detail, name='discussion_detail'),
# API
path('api/unread-count/', views.api_unread_count, name='api_unread_count'),

View file

@ -476,9 +476,28 @@ def team_delete(request, pk):
@login_required
def discussion_list(request):
"""Liste les discussions auxquelles l'utilisateur participe."""
"""
Liste les discussions dont l'utilisateur est l'auteur (créateur de la discussion
ou auteur d'un message) ou pour lesquelles il (ou une de ses équipes) a été explicitement taggé.
"""
user = request.user
user_team_ids = list(user.notification_teams.values_list('id', flat=True))
mention_q = Q(messages__content__icontains=f'(u:{user.pk})')
for tid in user_team_ids:
mention_q |= Q(messages__content__icontains=f'(t:{tid})')
discussion_filter = (
Q(messages__author=user, messages__is_deleted=False)
| Q(notifications__recipients__user=user)
| Q(notifications__team_targets__team_id__in=user_team_ids)
| (mention_q & Q(messages__is_deleted=False))
| (Q(created_by=user) & Q(content_type__isnull=True))
)
discussions = (
request.user.discussions
Discussion.objects.filter(discussion_filter)
.distinct()
.prefetch_related('participants', 'messages')
.select_related('created_by')
.order_by('-updated_at')
@ -536,6 +555,38 @@ def discussion_detail(request, pk):
})
@login_required
def discussion_create(request):
"""
Crée une nouvelle discussion indépendante (sans objet lié).
Poste le premier message et notifie les personnes/équipes mentionnées.
"""
if request.method == 'POST':
title = request.POST.get('title', '').strip()
content = request.POST.get('content', '').strip()
files = request.FILES.getlist('attachments')
if content:
discussion = Discussion.objects.create(
title=title or f"Discussion de {request.user.get_full_name() or request.user.username}",
created_by=request.user,
)
discussion.participants.add(request.user)
post_message(
discussion=discussion,
author=request.user,
content=content,
files=files or None,
)
messages.success(request, _("Discussion créée avec succès."))
return redirect('notifications:discussion_detail', pk=discussion.pk)
else:
messages.error(request, _("Le contenu du message ne peut pas être vide."))
return redirect('notifications:discussion_list')
# ──────────────────────────────────────────────────────────────────────────────
# Mention search API
# ──────────────────────────────────────────────────────────────────────────────

View file

@ -470,9 +470,20 @@
const badge = document.getElementById('navbar-notif-badge');
function updateBadge(count) {
if (!badge) return;
if (count > 0) { badge.textContent = count; badge.style.display = ''; }
else { badge.style.display = 'none'; }
if (badge) {
if (count > 0) { badge.textContent = count; badge.style.display = ''; }
else { badge.style.display = 'none'; }
}
const homeAlert = document.getElementById('home-unread-notifications-alert');
if (homeAlert) {
if (count > 0) {
const countEls = homeAlert.querySelectorAll('.home-unread-notifications-count');
countEls.forEach(el => el.textContent = count);
homeAlert.style.display = '';
} else {
homeAlert.style.display = 'none';
}
}
}
const TYPE_ICONS = {