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