Skip to content

Commit 17b92aa

Browse files
authored
feature: chat shows the time a message was sent once it is an hour old (#919)
1 parent b2a39dc commit 17b92aa

3 files changed

Lines changed: 206 additions & 2 deletions

File tree

‎components/TimeAgo.vue‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { shallowRef } from "vue";
1414
import TimeAgo from "javascript-time-ago";
1515
import type { LocaleData } from "javascript-time-ago";
1616
import en from "javascript-time-ago/locale/en";
17+
import { dateLocale } from "~/utilities/dateLocale";
1718
1819
TimeAgo.addDefaultLocale(en);
1920
@@ -150,6 +151,11 @@ export default {
150151
type: Boolean,
151152
default: false,
152153
},
154+
clockAfterHour: {
155+
required: false,
156+
type: Boolean,
157+
default: false,
158+
},
153159
},
154160
data() {
155161
return {
@@ -204,6 +210,30 @@ export default {
204210
this.unsubscribe?.();
205211
this.unsubscribe = null;
206212
},
213+
clockText(time: Date) {
214+
const now = new Date();
215+
// The relative text reads the date a second early and rounds 59.5
216+
// minutes up to "1 hour ago"; the clock takes over at that moment.
217+
if (now.getTime() - time.getTime() + 1000 < 59.5 * 60 * 1000) {
218+
return "";
219+
}
220+
221+
if (time.toDateString() === now.toDateString()) {
222+
return time.toLocaleTimeString(dateLocale(), {
223+
hour: "numeric",
224+
minute: "2-digit",
225+
});
226+
}
227+
228+
return time.toLocaleString(dateLocale(), {
229+
weekday: "short",
230+
month: "short",
231+
day: "numeric",
232+
year: time.getFullYear() === now.getFullYear() ? undefined : "numeric",
233+
hour: "numeric",
234+
minute: "2-digit",
235+
});
236+
},
207237
updateText() {
208238
const time = new Date(this.date);
209239
if (Number.isNaN(time.getTime())) {
@@ -228,6 +258,12 @@ export default {
228258
return;
229259
}
230260
261+
const clock = this.clockAfterHour ? this.clockText(time) : "";
262+
if (clock) {
263+
this.text = clock;
264+
return;
265+
}
266+
231267
time.setSeconds(time.getSeconds() - 1);
232268
233269
if (this.seconds) {

‎components/chat/ChatMessage.vue‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ import FiveStackToolTip from "~/components/FiveStackToolTip.vue";
8383
v-if="!mediaLeads"
8484
class="whitespace-nowrap text-[10px] text-muted-foreground/70"
8585
>
86-
<time-ago :date="message.timestamp" hide-icon></time-ago>
86+
<time-ago
87+
:date="message.timestamp"
88+
hide-icon
89+
clock-after-hour
90+
></time-ago>
8791
</span>
8892
<span
8993
v-if="authorBlocked"
@@ -107,7 +111,11 @@ import FiveStackToolTip from "~/components/FiveStackToolTip.vue";
107111
data-chat-media-time
108112
class="whitespace-nowrap text-[10px] leading-4 text-muted-foreground/70"
109113
>
110-
<time-ago :date="message.timestamp" hide-icon></time-ago>
114+
<time-ago
115+
:date="message.timestamp"
116+
hide-icon
117+
clock-after-hour
118+
></time-ago>
111119
</div>
112120
<ChatMessageEditor
113121
v-if="editing && room"
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { flushPromises } from "@vue/test-utils";
3+
import { mountSuspended } from "@nuxt/test-utils/runtime";
4+
import ChatMessage from "~/components/chat/ChatMessage.vue";
5+
import {
6+
resetBrowserLanguages,
7+
setBrowserLanguages,
8+
} from "../helpers/browserLocale";
9+
10+
const NOW = new Date(2026, 9, 20, 15, 40);
11+
12+
const message = (sent: Date, overrides: Record<string, unknown> = {}) => ({
13+
id: "m-1",
14+
message: "on my way",
15+
source: "web",
16+
timestamp: sent.toISOString(),
17+
from: { steam_id: "76561198000000001", name: "kairo" },
18+
...overrides,
19+
});
20+
21+
const i18n = () => (useNuxtApp() as any).$i18n;
22+
23+
let unmounts: Array<() => void> = [];
24+
25+
beforeEach(() => {
26+
vi.useFakeTimers({
27+
now: NOW,
28+
toFake: ["Date", "setInterval", "clearInterval"],
29+
});
30+
});
31+
32+
afterEach(async () => {
33+
for (const unmount of unmounts) {
34+
unmount();
35+
}
36+
unmounts = [];
37+
vi.useRealTimers();
38+
resetBrowserLanguages();
39+
await i18n().setLocale("en");
40+
});
41+
42+
async function mountLine(sent: Date, overrides: Record<string, unknown> = {}) {
43+
const wrapper = await mountSuspended(ChatMessage, {
44+
props: { message: message(sent, overrides) },
45+
});
46+
unmounts.push(() => wrapper.unmount());
47+
await flushPromises();
48+
return wrapper;
49+
}
50+
51+
function shown(element: Element | null | undefined) {
52+
return (element?.textContent ?? "").replace(/\s+/g, " ").trim();
53+
}
54+
55+
function nameLineTime(wrapper: Awaited<ReturnType<typeof mountLine>>) {
56+
return shown(wrapper.get("h4").element.nextElementSibling);
57+
}
58+
59+
const bands: Array<[string, Date]> = [
60+
["59 minutes old", new Date(2026, 9, 20, 14, 41)],
61+
["an hour old", new Date(2026, 9, 20, 14, 40)],
62+
["from earlier today", new Date(2026, 9, 20, 9, 5)],
63+
["from yesterday", new Date(2026, 9, 19, 21, 14)],
64+
["from last year", new Date(2025, 9, 18, 21, 14)],
65+
];
66+
67+
describe.each([
68+
[
69+
"English on a 12-hour browser",
70+
"en",
71+
"en-US",
72+
[
73+
"59 minutes ago",
74+
"2:40 PM",
75+
"9:05 AM",
76+
"Mon, Oct 19, 9:14 PM",
77+
"Sat, Oct 18, 2025, 9:14 PM",
78+
],
79+
],
80+
[
81+
"German on a 24-hour browser",
82+
"de",
83+
"de-DE",
84+
[
85+
"vor 59 Minuten",
86+
"14:40",
87+
"9:05",
88+
"Mo., 19. Okt., 21:14",
89+
"Sa., 18. Okt. 2025, 21:14",
90+
],
91+
],
92+
])("chat message time, %s", (_name, ui, browser, expected) => {
93+
beforeEach(async () => {
94+
setBrowserLanguages([browser]);
95+
await i18n().setLocale(ui);
96+
});
97+
98+
it.each(bands.map(([band, sent], index) => [band, sent, index] as const))(
99+
"a message %s",
100+
async (_band, sent, index) => {
101+
const wrapper = await mountLine(sent);
102+
103+
await vi.waitFor(() =>
104+
expect(nameLineTime(wrapper)).toBe(expected[index]),
105+
);
106+
},
107+
);
108+
});
109+
110+
describe("chat message time as the clock runs", () => {
111+
beforeEach(() => {
112+
setBrowserLanguages(["en-US"]);
113+
});
114+
115+
it("turns from minutes ago to the time of day once the message is an hour old", async () => {
116+
const wrapper = await mountLine(new Date(2026, 9, 20, 14, 41));
117+
118+
expect(nameLineTime(wrapper)).toBe("59 minutes ago");
119+
120+
await vi.advanceTimersByTimeAsync(61_000);
121+
122+
expect(nameLineTime(wrapper)).toBe("2:41 PM");
123+
});
124+
125+
it("never reads 1 hour ago on the way there", async () => {
126+
const wrapper = await mountLine(new Date(2026, 9, 20, 14, 40, 35));
127+
const seen = [nameLineTime(wrapper)];
128+
129+
for (let second = 0; second < 60; second++) {
130+
await vi.advanceTimersByTimeAsync(1000);
131+
if (nameLineTime(wrapper) !== seen.at(-1)) {
132+
seen.push(nameLineTime(wrapper));
133+
}
134+
}
135+
136+
expect(seen).toEqual(["59 minutes ago", "2:40 PM"]);
137+
});
138+
139+
it("adds the day and date once the day it was sent is over", async () => {
140+
vi.setSystemTime(new Date(2026, 9, 20, 23, 59, 30));
141+
const wrapper = await mountLine(new Date(2026, 9, 20, 13, 5));
142+
143+
expect(nameLineTime(wrapper)).toBe("1:05 PM");
144+
145+
await vi.advanceTimersByTimeAsync(60_000);
146+
147+
expect(nameLineTime(wrapper)).toBe("Tue, Oct 20, 1:05 PM");
148+
});
149+
150+
it("does the same for the time under the name when a GIF leads", async () => {
151+
const wrapper = await mountLine(new Date(2026, 9, 20, 12, 40), {
152+
message: "",
153+
gif: { id: "abc123", width: 480, height: 270 },
154+
});
155+
156+
expect(shown(wrapper.find("[data-chat-media-time]").element)).toBe(
157+
"12:40 PM",
158+
);
159+
});
160+
});

0 commit comments

Comments
 (0)