Branch data Line data Source code
1 : : #include "MailThreadModel.h"
2 : :
3 : : #include <QColor>
4 : : #include <QDataStream>
5 : : #include <QFont>
6 : : #include <QIODevice>
7 : : #include <QRegularExpression>
8 : :
9 : : #include "service/ImapResponseParser.h"
10 : : #include "ui/MailListModel.h"
11 : : #include "ui/ThemeManager.h"
12 : :
13 : 148 : MailThreadModel::MailThreadModel(QObject *parent)
14 : 148 : : QAbstractItemModel(parent) {}
15 : :
16 : 258 : MailThreadModel::~MailThreadModel() = default;
17 : :
18 : 55 : void MailThreadModel::setHeaders(const QList<MailHeader> &headers) {
19 : 55 : beginResetModel();
20 : 55 : m_roots.clear();
21 : 55 : m_uidIndex.clear();
22 : 55 : m_suggestionCache.clear();
23 : 55 : m_headers = headers;
24 [ + - ]: 55 : m_roots = ThreadBuilder::buildThreads(m_headers);
25 : 55 : buildUidIndex();
26 : 55 : endResetModel();
27 : 55 : }
28 : :
29 : 2 : void MailThreadModel::appendHeaders(const QList<MailHeader> &headers) {
30 : 2 : beginResetModel();
31 : 2 : m_roots.clear();
32 : 2 : m_uidIndex.clear();
33 : 2 : m_headers.append(headers);
34 [ + - ]: 2 : m_roots = ThreadBuilder::buildThreads(m_headers);
35 : 2 : buildUidIndex();
36 : 2 : endResetModel();
37 : 2 : }
38 : :
39 : 2 : void MailThreadModel::clear() {
40 : 2 : beginResetModel();
41 : 2 : m_roots.clear();
42 : 2 : m_uidIndex.clear();
43 : 2 : m_headers.clear();
44 : 2 : m_suggestionCache.clear();
45 : 2 : endResetModel();
46 : 2 : }
47 : :
48 : 36 : const MailHeader *MailThreadModel::headerAt(const QModelIndex &index) const {
49 : : // T-79.B1/H7: nodes own their headers, so this pointer stays valid for
50 : : // the lifetime of the node (until the next tree rebuild) regardless of
51 : : // what happens to any flat header list elsewhere.
52 : 36 : auto *node = nodeFromIndex(index);
53 [ + + ]: 36 : return node ? &node->header : nullptr;
54 : : }
55 : :
56 : 2 : qint64 MailThreadModel::uidAt(const QModelIndex &index) const {
57 : 2 : auto *node = nodeFromIndex(index);
58 [ + + ]: 2 : return node ? node->header.uid : -1;
59 : : }
60 : :
61 : 11 : QModelIndex MailThreadModel::indexForUid(qint64 uid, qint64 folderId) const {
62 : 11 : auto *node = m_uidIndex.value(MailKey{folderId, uid}, nullptr);
63 [ + + ]: 11 : return node ? indexFromNode(node) : QModelIndex();
64 : : }
65 : :
66 : 141 : void MailThreadModel::updateFlags(qint64 uid, quint32 flags, qint64 folderId) {
67 : : // T-79.B1/H7: the node owns its header — update it directly via the
68 : : // (folderId, uid) index so the view reads the new value.
69 : 141 : auto *node = m_uidIndex.value(MailKey{folderId, uid}, nullptr);
70 [ + + ]: 141 : if (node) {
71 : 5 : node->header.flags = flags;
72 [ + - ]: 5 : auto topLeft = indexFromNode(node, 0);
73 [ + - ]: 5 : auto bottomRight = indexFromNode(node, ColumnCount - 1);
74 [ + - ]: 5 : emit dataChanged(topLeft, bottomRight);
75 : : }
76 : : // Keep the flat source list in sync so a later rebuild
77 : : // (appendHeaders/removeByUid) does not resurrect stale flags.
78 [ + - + - : 161 : for (auto &h : m_headers) {
+ + ]
79 [ + + + - ]: 25 : if (h.uid == uid && h.folderId == folderId) {
80 : 5 : h.flags = flags;
81 : 5 : break;
82 : : }
83 : : }
84 : 141 : }
85 : :
86 : : // T-261: Update labels for a specific UID
87 : 25 : void MailThreadModel::updateLabels(qint64 uid, const QStringList &labels, qint64 folderId) {
88 : 25 : auto *node = m_uidIndex.value(MailKey{folderId, uid}, nullptr);
89 [ + + ]: 25 : if (node) {
90 : 4 : node->header.labels = labels;
91 [ + - ]: 4 : auto topLeft = indexFromNode(node, 0);
92 [ + - ]: 4 : auto bottomRight = indexFromNode(node, ColumnCount - 1);
93 [ + - ]: 4 : emit dataChanged(topLeft, bottomRight);
94 : : }
95 [ + - + - : 27 : for (auto &h : m_headers) {
+ + ]
96 [ + + + - ]: 6 : if (h.uid == uid && h.folderId == folderId) {
97 : 4 : h.labels = labels;
98 : 4 : break;
99 : : }
100 : : }
101 : 25 : }
102 : :
103 : 22 : void MailThreadModel::removeByUid(qint64 uid, qint64 folderId) {
104 : : // Remove the header from our flat list
105 [ + + ]: 25 : for (int i = 0; i < m_headers.size(); ++i) {
106 [ + + + - : 8 : if (m_headers[i].uid == uid && m_headers[i].folderId == folderId) {
+ + ]
107 : : // Rebuild the entire thread tree (simple & correct)
108 : 5 : beginResetModel();
109 : 5 : m_headers.removeAt(i);
110 : 5 : m_roots.clear();
111 : 5 : m_uidIndex.clear();
112 [ + - ]: 5 : m_roots = ThreadBuilder::buildThreads(m_headers);
113 : 5 : buildUidIndex();
114 : 5 : endResetModel();
115 : 5 : return;
116 : : }
117 : : }
118 : : }
119 : :
120 : : // --- QAbstractItemModel interface ---
121 : :
122 : 7683 : QModelIndex MailThreadModel::index(int row, int column,
123 : : const QModelIndex &parent) const {
124 [ + - + - : 7683 : if (row < 0 || column < 0 || column >= ColumnCount)
- + ]
125 : 0 : return {};
126 : :
127 [ + + ]: 7683 : if (!parent.isValid()) {
128 : : // Root level
129 [ - + ]: 6625 : if (row >= static_cast<int>(m_roots.size()))
130 : 0 : return {};
131 : 6625 : return createIndex(row, column, m_roots[row].get());
132 : : }
133 : :
134 : : // Child level
135 : 1058 : auto *parentNode = nodeFromIndex(parent);
136 [ + - - + : 1058 : if (!parentNode || row >= static_cast<int>(parentNode->children.size()))
- + ]
137 : 0 : return {};
138 : 1058 : return createIndex(row, column, parentNode->children[row].get());
139 : : }
140 : :
141 : 119 : QModelIndex MailThreadModel::parent(const QModelIndex &child) const {
142 [ - + ]: 119 : if (!child.isValid())
143 : 0 : return {};
144 : :
145 : 119 : auto *node = nodeFromIndex(child);
146 [ + - + + ]: 119 : if (!node || !node->parent)
147 : 71 : return {};
148 : :
149 : 48 : auto *parentNode = node->parent;
150 : : // Find the row of parentNode in its parent's children (or roots)
151 [ + - ]: 48 : if (!parentNode->parent) {
152 : : // parentNode is a root
153 : 48 : int row = findRootIndex(parentNode);
154 : 48 : return createIndex(row, 0, parentNode);
155 : : }
156 : :
157 : 0 : int row = findChildIndex(parentNode);
158 : 0 : return createIndex(row, 0, parentNode);
159 : : }
160 : :
161 : 165 : int MailThreadModel::rowCount(const QModelIndex &parent) const {
162 [ + + ]: 165 : if (!parent.isValid())
163 : 77 : return static_cast<int>(m_roots.size());
164 : :
165 : 88 : auto *node = nodeFromIndex(parent);
166 : : // Only column 0 items have children
167 [ - + ]: 88 : if (parent.column() != 0)
168 : 0 : return 0;
169 [ + - ]: 88 : return node ? static_cast<int>(node->children.size()) : 0;
170 : : }
171 : :
172 : 106 : int MailThreadModel::columnCount(const QModelIndex &parent) const {
173 : : Q_UNUSED(parent)
174 : 106 : return ColumnCount;
175 : : }
176 : :
177 : 5662 : QVariant MailThreadModel::data(const QModelIndex &index, int role) const {
178 : 5662 : auto *node = nodeFromIndex(index);
179 [ - + ]: 5662 : if (!node)
180 : 0 : return {};
181 : :
182 : 5662 : const auto &h = node->header;
183 : 5662 : int col = index.column();
184 : :
185 [ + + ]: 5662 : if (role == Qt::DisplayRole) {
186 [ + + + + : 824 : switch (col) {
+ + + - ]
187 : 118 : case Star:
188 [ + + + + : 236 : return h.isFlagged() ? QStringLiteral("★") : QStringLiteral("☆");
+ + ]
189 : 117 : case Attachment:
190 : 117 : return {}; // painted by dedicated delegate
191 : 231 : case Subject: {
192 [ - + - - ]: 231 : QString subject = h.subject.isEmpty() ? tr("(No subject)") : h.subject;
193 : : // T-547: Show unseen count for root nodes with children
194 [ + + + + : 231 : if (node->depth == 0 && !node->children.empty()) {
+ + ]
195 [ + - ]: 28 : int total = node->descendantCount();
196 [ + - ]: 28 : int unseen = node->unseenDescendantCount();
197 [ + + ]: 28 : if (unseen > 0)
198 [ + - + - : 81 : subject += QStringLiteral(" (%1/%2)").arg(unseen).arg(total);
+ - ]
199 : : else
200 [ + - + - ]: 2 : subject += QStringLiteral(" (%1)").arg(total);
201 : : }
202 : : // T-128/T-136: Strip Re:/Fwd: for child messages
203 [ + + ]: 231 : if (node->depth > 0) {
204 : : static const QRegularExpression rePrefix(
205 : 6 : QStringLiteral("^(Re|Fwd|Aw|Wg):\\s*"),
206 [ + + + - : 45 : QRegularExpression::CaseInsensitiveOption);
+ - - - ]
207 [ + - ]: 39 : subject = subject.replace(rePrefix, QString());
208 : : }
209 : : // Show ↩ prefix for answered mails
210 [ + + + - ]: 232 : if (h.isAnswered()) subject.prepend(QStringLiteral("↩ "));
211 : : // Bug 2: Visual indentation for threaded replies
212 [ + + ]: 231 : if (node->depth > 0)
213 [ + - + - ]: 39 : subject.prepend(QString(node->depth * 4, QChar(' ')));
214 : 231 : return subject;
215 : 231 : }
216 : 121 : case From:
217 : 121 : return h.from;
218 : 118 : case Date:
219 : : // 67.B4: same humanized format as the flat list
220 [ + - ]: 118 : return MailListModel::formatDate(h.date);
221 : 117 : case Size:
222 [ + + ]: 117 : if (h.size < 1024)
223 [ + - ]: 232 : return QStringLiteral("%1 B").arg(h.size);
224 [ + - ]: 1 : if (h.size < 1024 * 1024)
225 [ + - ]: 2 : return QStringLiteral("%1 KB").arg(h.size / 1024.0, 0, 'f', 1);
226 [ # # ]: 0 : return QStringLiteral("%1 MB").arg(h.size / (1024.0 * 1024.0), 0, 'f', 1);
227 : 2 : case Suggestion: {
228 : 2 : auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
229 [ + + ]: 2 : return it != m_suggestionCache.constEnd() ? it->text : QString();
230 : : }
231 : : }
232 : : }
233 : :
234 : : // T-508/T-547: Bold font for unread messages.
235 : : // For root nodes with children, also check if any descendant is unread
236 : : // so collapsed threads show as bold when they contain unread mail.
237 [ + + ]: 4838 : if (role == Qt::FontRole) {
238 : 802 : bool unread = !h.isSeen();
239 [ + + + - : 802 : if (!unread && node->depth == 0 && !node->children.empty())
- + - + ]
240 : 0 : unread = node->hasUnseenDescendant();
241 [ + + ]: 802 : if (unread) {
242 : : // 67.B4: medium weight (paired with the accent unread dot)
243 [ + - ]: 404 : QFont font;
244 [ + - ]: 404 : font.setWeight(QFont::DemiBold);
245 [ + - ]: 404 : return font;
246 : 404 : }
247 : 398 : return {}; // Use view's default font for read messages
248 : : }
249 : :
250 [ + + + + ]: 4036 : if (role == Qt::ForegroundRole && col == Star) {
251 : 116 : auto &tm = ThemeManager::instance();
252 [ - + - - : 348 : return h.isFlagged() ? QColor(tm.color(QStringLiteral("@star_active")))
- + - + -
- - - -
- ]
253 [ + - + - : 232 : : QColor(tm.color(QStringLiteral("@star_inactive")));
+ - + - +
- - + - -
- - - - ]
254 : : }
255 : :
256 : : // T-232: Suggestion column color by confidence (shared palette)
257 [ + + + + ]: 3920 : if (role == Qt::ForegroundRole && col == Suggestion) {
258 : 4 : auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
259 [ + - + - ]: 8 : return ThemeManager::confidenceColor(
260 [ + - ]: 16 : it != m_suggestionCache.constEnd() ? it->confidence : 0.0);
261 : : }
262 : :
263 : : // Text alignment for Star column
264 [ + + + + ]: 3916 : if (role == Qt::TextAlignmentRole && col == Star) {
265 : 117 : return static_cast<int>(Qt::AlignCenter);
266 : : }
267 : :
268 [ + + ]: 3799 : if (role == SortRole) {
269 [ - + - + : 131 : switch (col) {
- - - ]
270 : 0 : case Star:
271 [ # # ]: 0 : return h.isFlagged() ? 1 : 0;
272 : 1 : case Subject:
273 : 1 : return h.subject;
274 : 0 : case From:
275 : 0 : return h.from;
276 : 130 : case Date:
277 : 130 : return h.date;
278 : 0 : case Size:
279 : 0 : return h.size;
280 : 0 : case Suggestion:
281 : 0 : return 0.0;
282 : : }
283 : : }
284 : :
285 [ + + ]: 3668 : if (role == FlagsRole)
286 : 128 : return h.flags;
287 [ + + ]: 3540 : if (role == HasAttachmentsRole)
288 : 108 : return h.hasAttachments;
289 [ + + ]: 3432 : if (role == LabelsRole) {
290 : 108 : QStringList filtered;
291 [ + + ]: 114 : for (const auto &label : h.labels) {
292 [ + - + - ]: 6 : if (!ImapResponseParser::isInternalKeyword(label))
293 [ + - ]: 6 : filtered.append(label);
294 : : }
295 : 108 : return filtered;
296 : 108 : }
297 [ + + ]: 3324 : if (role == ThreadCountRole)
298 : 1 : return node->descendantCount();
299 [ + + ]: 3323 : if (role == DepthRole) // T-128
300 : 3 : return node->depth;
301 : :
302 : 3320 : return {};
303 : : }
304 : :
305 : 1085 : QVariant MailThreadModel::headerData(int section, Qt::Orientation orientation,
306 : : int role) const {
307 [ + + + + ]: 1085 : if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
308 : 865 : return {};
309 : :
310 [ + + + + : 220 : switch (section) {
+ + + - ]
311 : 36 : case Star:
312 : 36 : return QStringLiteral("★");
313 : 36 : case Attachment:
314 : 36 : return QStringLiteral("");
315 : 37 : case Subject:
316 [ + - ]: 37 : return tr("Subject");
317 : 1 : case Suggestion:
318 : 1 : return QStringLiteral("→");
319 : 37 : case From:
320 [ + - ]: 37 : return tr("From");
321 : 37 : case Date:
322 [ + - ]: 37 : return tr("Date");
323 : 36 : case Size:
324 [ + - ]: 36 : return tr("Size");
325 : : }
326 : 0 : return {};
327 : : }
328 : :
329 : : // Sprint 76 (T-76.B3): refresh translated headers on a live language switch.
330 : 2 : void MailThreadModel::retranslateUi() {
331 [ + - + - ]: 2 : if (columnCount() > 0)
332 [ + - + - ]: 2 : emit headerDataChanged(Qt::Horizontal, 0, columnCount() - 1);
333 : 2 : }
334 : :
335 : : // ═══════════════════════════════════════════════════════
336 : : // Drag & Drop (T-102)
337 : : // ═══════════════════════════════════════════════════════
338 : :
339 : 738 : Qt::ItemFlags MailThreadModel::flags(const QModelIndex &index) const {
340 [ + - ]: 738 : auto defaultFlags = QAbstractItemModel::flags(index);
341 [ + - ]: 738 : if (index.isValid())
342 : 738 : return defaultFlags | Qt::ItemIsDragEnabled;
343 : 0 : return defaultFlags;
344 : : }
345 : :
346 : 2 : QStringList MailThreadModel::mimeTypes() const {
347 [ + + - - ]: 4 : return {QStringLiteral("application/x-mailjd-uids")};
348 [ + - - - : 4 : }
- - ]
349 : :
350 : 1 : QMimeData *MailThreadModel::mimeData(const QModelIndexList &indexes) const {
351 : 1 : QSet<qint64> uidSet;
352 [ + + ]: 2 : for (const auto &idx : indexes) {
353 [ # # ]: 1 : auto *node = nodeFromIndex(idx);
354 [ + - ]: 1 : if (node)
355 [ + - ]: 1 : uidSet.insert(node->header.uid);
356 : : }
357 : :
358 : 1 : QByteArray encoded;
359 [ + - ]: 1 : QDataStream stream(&encoded, QIODevice::WriteOnly);
360 [ + - + - : 2 : for (qint64 uid : uidSet)
+ + ]
361 [ + - ]: 1 : stream << uid;
362 : :
363 [ + - + - : 1 : auto *mimeData = new QMimeData;
- + - - ]
364 [ + - ]: 2 : mimeData->setData(QStringLiteral("application/x-mailjd-uids"), encoded);
365 [ + - + - : 2 : mimeData->setText(QString("%1 Mail(s)").arg(uidSet.size()));
+ - ]
366 : 1 : return mimeData;
367 : 1 : }
368 : :
369 : 2 : Qt::DropActions MailThreadModel::supportedDragActions() const {
370 : 2 : return Qt::MoveAction;
371 : : }
372 : :
373 : : // --- Private helpers ---
374 : :
375 : 6966 : ThreadNode *MailThreadModel::nodeFromIndex(const QModelIndex &index) const {
376 [ + + ]: 6966 : if (!index.isValid())
377 : 2 : return nullptr;
378 : 6964 : return static_cast<ThreadNode *>(index.internalPointer());
379 : : }
380 : :
381 : 32 : QModelIndex MailThreadModel::indexFromNode(ThreadNode *node, int column) const {
382 [ - + ]: 32 : if (!node)
383 : 0 : return {};
384 : :
385 [ + + ]: 32 : if (!node->parent) {
386 : 26 : int row = findRootIndex(node);
387 [ - + ]: 26 : if (row < 0)
388 : 0 : return {};
389 : 26 : return createIndex(row, column, node);
390 : : }
391 : :
392 : 6 : int row = findChildIndex(node);
393 [ - + ]: 6 : if (row < 0)
394 : 0 : return {};
395 : 6 : return createIndex(row, column, node);
396 : : }
397 : :
398 : 74 : int MailThreadModel::findRootIndex(ThreadNode *node) const {
399 [ + - ]: 99 : for (size_t i = 0; i < m_roots.size(); ++i) {
400 [ + + ]: 99 : if (m_roots[i].get() == node)
401 : 74 : return i;
402 : : }
403 : 0 : return -1;
404 : : }
405 : :
406 : 6 : int MailThreadModel::findChildIndex(ThreadNode *node) const {
407 [ + - - + ]: 6 : if (!node || !node->parent)
408 : 0 : return -1;
409 [ + - ]: 9 : for (size_t i = 0; i < node->parent->children.size(); ++i) {
410 [ + + ]: 9 : if (node->parent->children[i].get() == node)
411 : 6 : return i;
412 : : }
413 : 0 : return -1;
414 : : }
415 : :
416 : 62 : void MailThreadModel::buildUidIndex() {
417 : 62 : m_uidIndex.clear();
418 : 283 : std::function<void(ThreadNode *)> walk = [&](ThreadNode *node) {
419 [ + - ]: 159 : m_uidIndex.insert(MailKey{node->header.folderId, node->header.uid}, node);
420 [ + + ]: 173 : for (auto &child : node->children)
421 [ + - ]: 14 : walk(child.get());
422 : 221 : };
423 [ + + ]: 207 : for (auto &root : m_roots)
424 [ + - ]: 145 : walk(root.get());
425 : 62 : }
426 : :
427 : : // T-232: Set suggestion for a specific UID
428 : 6 : void MailThreadModel::setSuggestion(qint64 uid, qint64 folderId,
429 : : const QString &text,
430 : : double confidence) {
431 [ + - ]: 6 : m_suggestionCache[MailKey{folderId, uid}] = {text, confidence};
432 : 6 : auto *node = m_uidIndex.value(MailKey{folderId, uid}, nullptr);
433 [ + - ]: 6 : if (node) {
434 [ + - ]: 6 : auto idx = indexFromNode(node, Suggestion);
435 [ + - + - ]: 6 : emit dataChanged(idx, idx, {Qt::DisplayRole, Qt::ForegroundRole});
436 : : }
437 [ - - ]: 12 : }
438 : :
439 : : // T-232: Clear all suggestion data
440 : 4 : void MailThreadModel::clearSuggestions() {
441 [ + + ]: 4 : if (m_suggestionCache.isEmpty())
442 : 2 : return;
443 : 2 : m_suggestionCache.clear();
444 : : // Use dataChanged for the suggestion column range instead of dangerous
445 : : // bare layoutChanged() (which causes crash without layoutAboutToBeChanged).
446 [ + - ]: 2 : if (!m_roots.empty()) {
447 [ + - + - : 6 : emit dataChanged(index(0, Suggestion),
+ - ]
448 [ + - + - ]: 4 : index(rowCount() - 1, Suggestion),
449 : : {Qt::DisplayRole, Qt::ForegroundRole});
450 : : }
451 : : }
|