[network/ruqola] src/widgets/room/delegate: Group the timestamp with the author line in Normal message style

Laurent Montel <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 5accdc6b8277e78c4c627e792fb9f9b1c3797c15 by Laurent Montel, on behalf of Till Adam.
Committed on 27/07/2026 at 18:33.
Pushed by tilladam into branch 'master'.

Group the timestamp with the author line in Normal message style

The timestamp was pinned to the right edge of the row, ~a column width away
from the message text it belonged to, with the tiny read-receipt check marks
stranded next to it.

In the Normal layout, place the time (and its read receipt) directly after the
sender name on the author line instead: "Alice Martin · 12:34 ✓✓", the
Slack/Discord/Element convention. The author-line time is clamped so a long
display name or many author-line icons cannot push it (or the ignored-message
icon overlap it) off the row.

For grouped consecutive messages (same sender, no repeated author line) there is
no author line to attach to, so the time is shown on hover only: in the empty
avatar gutter when one is available, otherwise at the right edge (when avatars
are off, or the gutter is already occupied by status icons). Grouped rows drop
the per-message read receipt rather than stranding it. Hover visibility keys off
the actual cursor state, independent of the "highlight on hover" preference.

Width reserved after the text now covers the trailing hover-action icons (so
they stay on the row now that the timestamp no longer holds that space), plus the
right-edge fallback timestamp's width on the grouped rows that use it.

Only the Normal layout opts into this via Layout::timeStampHoverOnly; Compact and
Cozy leave it false, so their always-visible timestamp/receipt are unchanged.

M  +16   -6    src/widgets/room/delegate/messagelistdelegate.cpp
M  +4    -0    src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.h
M  +58   -8    src/widgets/room/delegate/messagelistlayout/messagelistnormallayout.cpp

https://invent.kde.org/network/ruqola/-/commit/5accdc6b8277e78c4c627e792fb9f9b1c3797c15

diff --git a/src/widgets/room/delegate/messagelistdelegate.cpp b/src/widgets/room/delegate/messagelistdelegate.cpp
index b243aa9942..6085e048b7 100644
--- a/src/widgets/room/delegate/messagelistdelegate.cpp
+++ b/src/widgets/room/delegate/messagelistdelegate.cpp
@@ -553,8 +553,14 @@ void MessageListDelegate::paint(QPainter *painter, const QStyleOptionViewItem &o
         drawLastSeenLine(painter, layout.displayLastSeenMessageY, option);
     }
 
-    // Timestamp
-    DelegatePaintUtil::drawLighterText(painter, layout.timeStampText, layout.timeStampPos);
+    // Timestamp. Normally drawn at its laid-out position; only the Normal layout's
+    // grouped rows mark it gutter/hover-only, in which case it appears while the row is
+    // hovered. Gate on the actual hover state (not the background-highlight preference,
+    // which only controls the row fill), so the time still appears when that is off.
+    const bool showTimestamp = !layout.timeStampHoverOnly || message->hoverHighlight();
+    if (showTimestamp) {
+        DelegatePaintUtil::drawLighterText(painter, layout.timeStampText, layout.timeStampPos);
+    }
 
     // Message
     if (layout.textRect.isValid()) {
@@ -617,10 +623,14 @@ void MessageListDelegate::paint(QPainter *painter, const QStyleOptionViewItem &o
         mTranslatedIcon.paint(painter, layout.translatedIconRect);
     }
 
-    if (message->unread()) {
-        mSingleCheckIcon.paint(painter, layout.readReceiptIconRect);
-    } else {
-        mDoubleCheckIcon.paint(painter, layout.readReceiptIconRect);
+    // The read receipt follows the timestamp: on the author line for a new sender,
+    // and suppressed for grouped rows (null rect) where the timestamp is gutter-only.
+    if (showTimestamp && layout.readReceiptIconRect.isValid()) {
+        if (message->unread()) {
+            mSingleCheckIcon.paint(painter, layout.readReceiptIconRect);
+        } else {
+            mDoubleCheckIcon.paint(painter, layout.readReceiptIconRect);
+        }
     }
 
     // Draw encrypted icon
diff --git a/src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.h b/src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.h
index 0504c8fd60..49fbe8dacc 100644
--- a/src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.h
+++ b/src/widgets/room/delegate/messagelistlayout/messagelistlayoutbase.h
@@ -106,6 +106,10 @@ public:
         bool showIgnoreMessage = false;
         bool sameSenderAsPreviousMessage = false;
         bool messageIsFollowing = false;
+        // The timestamp lives in the gutter and should only be painted while the row is
+        // hovered. Set solely by the Normal layout for grouped rows; other layouts leave
+        // it false so their timestamp is always drawn.
+        bool timeStampHoverOnly = false;
     };
 
     [[nodiscard]] virtual MessageListLayoutBase::Layout doLayout(const QStyleOptionViewItem &option, const QModelIndex &index) const = 0;
diff --git a/src/widgets/room/delegate/messagelistlayout/messagelistnormallayout.cpp b/src/widgets/room/delegate/messagelistlayout/messagelistnormallayout.cpp
index 983bc9eb2f..5ba55b729d 100644
--- a/src/widgets/room/delegate/messagelistlayout/messagelistnormallayout.cpp
+++ b/src/widgets/room/delegate/messagelistlayout/messagelistnormallayout.cpp
@@ -125,8 +125,25 @@ MessageListLayoutBase::Layout MessageListNormalLayout::doLayout(const QStyleOpti
     layout.timeStampText = index.data(MessagesModel::Timestamp).toString();
     const QSize timeSize = MessageDelegateUtils::timeStampSize(layout.timeStampText, option);
 
-    // Message (using the rest of the available width)
-    const int widthAfterMessage = iconSizeMargin + timeSize.width() + margin / 2;
+    // A grouped row shows its hover timestamp in the avatar gutter, but falls back to the
+    // right edge when there is no gutter (avatars off) or the gutter is already taken by
+    // status icons (edited/starred/…). Compute that here since the width reservation below
+    // depends on it, and the status icons are only laid out further down (mirror them).
+    const bool groupedStatusIconsInGutter = layout.sameSenderAsPreviousMessage
+        && (message->wasEdited() || message->isStarred() || message->isPinned() || layout.messageIsFollowing || message->isEncryptedMessage()
+            || message->isAutoTranslated() || !message->localTranslation().isEmpty());
+    const bool timeStampUsesRightEdge = layout.sameSenderAsPreviousMessage && (avatarWidth < timeSize.width() || groupedStatusIconsInGutter);
+
+    // Message (using the rest of the available width). Reserve room after the text for the
+    // trailing hover-action icons (add-reaction, reply-in-thread, and text-to-speech when
+    // built) so they stay on the row; the timestamp itself moved to the author line and no
+    // longer needs right-edge space, except for a grouped row that uses the right-edge
+    // fallback, where its width is reserved so it cannot overprint a long line.
+    qreal hoverActionsWidth = 2 * iconSizeMargin; // add-reaction + reply-in-thread
+#if HAVE_TEXT_TO_SPEECH
+    hoverActionsWidth += iconSizeMargin; // text-to-speech
+#endif
+    const int widthAfterMessage = hoverActionsWidth + margin / 2 + (timeStampUsesRightEdge ? timeSize.width() + margin : 0);
     const int maxWidth = qMax(30, option.rect.width() - textLeft - widthAfterMessage);
     layout.baseLine = 0;
     const QSize textSize = mDelegate->helperText()->sizeHint(index, maxWidth, option, &layout.baseLine);
@@ -235,15 +252,48 @@ MessageListLayoutBase::Layout MessageListNormalLayout::doLayout(const QStyleOpti
     layout.textToSpeechIconRect = QRect(textLeft + textSize.width() + 3 * margin + iconSize * 2, layout.textRect.y(), iconSize, iconSize);
 #endif
 
-    layout.timeStampPos = QPoint(option.rect.width() - timeSize.width() - margin / 2, layout.baseLine);
-    layout.timeStampRect = QRect(QPoint(layout.timeStampPos.x(), senderRectY), timeSize);
+    // Right edge available to laid-out content (a half-margin gutter is kept clear).
+    const int rightEdge = option.rect.width() - margin / 2;
+    if (!layout.sameSenderAsPreviousMessage) {
+        // Group the time with the author line, right after the sender name (and any
+        // author-line icons): "Alice Martin · 12:34 ✓✓". The old far-right placement
+        // stranded it ~a column width from the text it belonged to.
+        const QString separator = QStringLiteral("·  "); // middot
+        layout.timeStampText = separator + layout.timeStampText;
+        const QSize authorTimeSize = MessageDelegateUtils::timeStampSize(layout.timeStampText, option);
+        // Start just after the sender name and its author-line icons. The ignored-message
+        // icon advances textLeft rather than positionIcon, so step past it explicitly.
+        int timeX = positionIcon;
+        if (ignoreMessage) {
+            timeX += iconSizeMargin;
+        }
+        // Keep the time and its read receipt inside the row: a very long display name or a
+        // pile of author-line icons could otherwise push them past the right edge (the old
+        // fixed-right placement was always visible). Clamp so both stay on screen.
+        const int rightLimit = rightEdge - iconSize - margin - authorTimeSize.width();
+        timeX = qMin(timeX, rightLimit);
+        layout.timeStampPos = QPoint(timeX, layout.baseLine);
+        layout.timeStampRect = QRect(QPoint(timeX, senderRectY), authorTimeSize);
+        layout.readReceiptIconRect = QRect(layout.timeStampRect.right() + margin, senderRectY, iconSize, iconSize);
+    } else {
+        // Grouped consecutive message: no author line, so the delegate draws the time on
+        // hover only (see paint()), aligned to the first content line. Preferred spot is
+        // the empty avatar gutter (Slack-style); when that gutter is unavailable or already
+        // holds status icons, fall back to the right edge (maxWidth reserves its width).
+        layout.timeStampHoverOnly = true;
+        const int contentTop = layout.textRect.isValid() ? layout.textRect.y() : attachmentsY;
+        const int gutterRight = textLeft - margin;
+        const int timeX = timeStampUsesRightEdge ? rightEdge - timeSize.width() // right edge fallback
+                                                 : gutterRight - timeSize.width(); // right-aligned in the avatar gutter
+        layout.timeStampPos = QPoint(timeX, contentTop + option.fontMetrics.ascent());
+        layout.timeStampRect = QRect(timeX, contentTop, timeSize.width(), option.fontMetrics.height());
+        // No per-message read receipt on grouped rows (it would strand a tiny check next
+        // to the hover time); the receipt stays with the author line above.
+        layout.readReceiptIconRect = QRect();
+    }
     generateAttachmentBlockAndUrlPreviewLayout(mDelegate, layout, message, attachmentsY, textLeft, maxWidth, option, index);
     layout.reactionsHeight = mDelegate->helperReactions()->sizeHint(index, maxWidth, option).height();
 
-    // Center the read-receipt icon on the timestamp text; the old baseLine anchor put
-    // the icon's top at the text baseline, dropping it a full icon-height below the time.
-    layout.readReceiptIconRect = QRect(layout.timeStampRect.left() - margin - iconSize, senderRectY + (timeSize.height() - iconSize) / 2, iconSize, iconSize);
-
     // Replies
     layout.repliesY = layout.reactionsY + layout.reactionsHeight;
     if (message->threadCount() > 0) {
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.