๐ (comments): Relative comment times, card styling, spacing
Changes
7 files changed, +138 -4
MODIFY
docs/users/comments.md
+3 -1
@@ -11,7 +11,9 @@
11
11
comment* note instead of the form.
12
12
13
13
Comments are plain text and appear in the order they were posted, each showing the
14
-author's avatar, username, and the time it was posted.
14
+author's avatar, username, and a relative post time (*just now*, *10min ago*,
15
+*6h ago*, or a date for anything older than a day); hover the time to see the exact
16
+timestamp.
15
17
16
18
Any authenticated user who can read the repository may comment โ you do not need to
17
19
be the owner or a collaborator. On a private repository, only users who can read it
ADD
src/main/java/de/workaround/web/RelativeTime.java
+54 -0
@@ -0,0 +1,54 @@
1
+package de.workaround.web;
2
+
3
+import java.time.Duration;
4
+import java.time.Instant;
5
+import java.time.ZoneOffset;
6
+import java.time.format.DateTimeFormatter;
7
+
8
+import io.quarkus.qute.TemplateExtension;
9
+
10
+/**
11
+ * Human-friendly relative timestamps for the UI. Raw ISO instants (e.g. 2026-07-20T11:26:09.565647Z) are
12
+ * hard to read at a glance, so render "just now", "10min ago" or "6h ago" for recent times and fall back to
13
+ * a plain UTC date once something is more than a day old. Exposed to Qute as {instant.since}.
14
+ */
15
+public final class RelativeTime
16
+{
17
+ private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC);
18
+
19
+ private RelativeTime()
20
+ {
21
+ }
22
+
23
+ @TemplateExtension
24
+ public static String since(Instant instant)
25
+ {
26
+ return format(instant, Instant.now());
27
+ }
28
+
29
+ /** Package-visible with an explicit "now" so the formatting is deterministically testable. */
30
+ static String format(Instant instant, Instant now)
31
+ {
32
+ if (instant == null)
33
+ {
34
+ return "";
35
+ }
36
+ long seconds = Duration.between(instant, now).getSeconds();
37
+ if (seconds < 60)
38
+ {
39
+ // covers future timestamps (negative durations) too
40
+ return "just now";
41
+ }
42
+ long minutes = seconds / 60;
43
+ if (minutes < 60)
44
+ {
45
+ return minutes + "min ago";
46
+ }
47
+ long hours = minutes / 60;
48
+ if (hours < 24)
49
+ {
50
+ return hours + "h ago";
51
+ }
52
+ return DATE.format(instant);
53
+ }
54
+}
MODIFY
src/main/resources/META-INF/resources/shark.css
+26 -0
@@ -889,6 +889,11 @@
889
889
gap: var(--s2);
890
890
}
891
891
892
+/* the discussion comment box and its submit button would otherwise touch */
893
+.comment-form .form-actions {
894
+ margin-top: var(--s3);
895
+}
896
+
892
897
details.archive {
893
898
margin-top: var(--s5);
894
899
}
@@ -1537,6 +1542,27 @@
1537
1542
font-family: var(--font);
1538
1543
}
1539
1544
1545
+/* discussion comments render as distinct cards (author/time header + message body); the
1546
+ inline diff-anchored variant (.dl-comment-row) keeps its own styling and is unaffected */
1547
+.comment-row {
1548
+ display: flex;
1549
+ align-items: flex-start;
1550
+ gap: var(--s2);
1551
+ margin-bottom: var(--s3);
1552
+}
1553
+
1554
+.comment-row .comment {
1555
+ padding: var(--s3);
1556
+ background: var(--surface);
1557
+ border: 1px solid var(--border);
1558
+ border-radius: var(--radius);
1559
+}
1560
+
1561
+.comment-row:hover .comment-del,
1562
+.comment-row:focus-within .comment-del {
1563
+ opacity: 1;
1564
+}
1565
+
1540
1566
.comment {
1541
1567
flex: 1 1 auto;
1542
1568
max-width: 640px;
MODIFY
src/main/resources/templates/IssueResource/issue.html
+1 -1
@@ -40,7 +40,7 @@
40
40
<div class="comment">
41
41
<div class="comment-head">
42
42
<span class="who">{#avatar user=c.author /} <strong>{c.author.username}</strong></span>
43
- <span class="muted comment-when">{c.createdAt}</span>
43
+ <span class="muted comment-when" title="{c.createdAt}">{c.createdAt.since}</span>
44
44
</div>
45
45
<div class="comment-body">{c.body}</div>
46
46
</div>
MODIFY
src/main/resources/templates/MergeRequestResource/mergeRequest.html
+1 -1
@@ -117,7 +117,7 @@
117
117
<div class="comment">
118
118
<div class="comment-head">
119
119
<span class="who">{#avatar user=c.author /} <strong>{c.author.username}</strong></span>
120
- <span class="muted comment-when">{c.createdAt}</span>
120
+ <span class="muted comment-when" title="{c.createdAt}">{c.createdAt.since}</span>
121
121
</div>
122
122
<div class="comment-body">{c.body}</div>
123
123
</div>
MODIFY
src/test/java/de/workaround/web/MergeRequestDiscussionUiTest.java
+5 -1
@@ -85,7 +85,11 @@
85
85
given().when().get(detail)
86
86
.then().statusCode(200)
87
87
.body(containsString("general remark on this MR"))
88
- .body(containsString("mrd-owner"));
88
+ .body(containsString("mrd-owner"))
89
+ // the just-posted comment shows a friendly relative time (via the {createdAt.since}
90
+ // template extension) with the exact instant kept in a hover tooltip
91
+ .body(containsString(">just now</span>"))
92
+ .body(containsString("class=\"muted comment-when\" title=\""));
89
93
}
90
94
91
95
@Test
ADD
src/test/java/de/workaround/web/RelativeTimeTest.java
+48 -0
@@ -0,0 +1,48 @@
1
+package de.workaround.web;
2
+
3
+import java.time.Instant;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import static org.junit.jupiter.api.Assertions.assertEquals;
8
+
9
+class RelativeTimeTest
10
+{
11
+ private static final Instant NOW = Instant.parse("2026-07-20T12:00:00Z");
12
+
13
+ @Test
14
+ void withinAMinuteReadsJustNow()
15
+ {
16
+ assertEquals("just now", RelativeTime.format(NOW.minusSeconds(30), NOW));
17
+ }
18
+
19
+ @Test
20
+ void withinAnHourReadsMinutesAgo()
21
+ {
22
+ assertEquals("10min ago", RelativeTime.format(NOW.minusSeconds(10 * 60), NOW));
23
+ }
24
+
25
+ @Test
26
+ void withinADayReadsHoursAgo()
27
+ {
28
+ assertEquals("6h ago", RelativeTime.format(NOW.minusSeconds(6 * 3600), NOW));
29
+ }
30
+
31
+ @Test
32
+ void olderThanADayReadsAsADate()
33
+ {
34
+ assertEquals("2026-07-17", RelativeTime.format(NOW.minusSeconds(3 * 24 * 3600), NOW));
35
+ }
36
+
37
+ @Test
38
+ void futureTimestampsClampToJustNow()
39
+ {
40
+ assertEquals("just now", RelativeTime.format(NOW.plusSeconds(5), NOW));
41
+ }
42
+
43
+ @Test
44
+ void nullIsEmpty()
45
+ {
46
+ assertEquals("", RelativeTime.format(null, NOW));
47
+ }
48
+}