@theia/ai-chat-ui 1.77.0-next.37 → 1.77.0-next.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,10 @@
1
- import * as locales from 'date-fns/locale';
2
- /**
3
- * Returns the date-fns locale matching the current Theia locale.
4
- */
5
- export declare function getDateFnsLocale(): locales.Locale;
6
1
  /**
7
2
  * Formats a timestamp as a human-readable relative time string (e.g., "2 hours ago").
3
+ *
4
+ * The largest unit that fits into the elapsed time is used and the value is rounded
5
+ * down so that it never crosses into the next larger unit. Timestamps less than a
6
+ * second away render as "0 seconds ago", future ones as "in ...".
8
7
  * @param timestamp - The timestamp in milliseconds
9
- * @param addSuffix - Whether to add "ago" suffix (default: true)
10
8
  */
11
- export declare function formatTimeAgo(timestamp: number, addSuffix?: boolean): string;
9
+ export declare function formatTimeAgo(timestamp: number): string;
12
10
  //# sourceMappingURL=chat-date-utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat-date-utils.d.ts","sourceRoot":"","sources":["../../src/browser/chat-date-utils.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,OAAO,MAAM,iBAAiB,CAAC;AAE3C;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAGjD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,GAAE,OAAc,GAAG,MAAM,CAKlF"}
1
+ {"version":3,"file":"chat-date-utils.d.ts","sourceRoot":"","sources":["../../src/browser/chat-date-utils.ts"],"names":[],"mappings":"AAoDA;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAcvD"}
@@ -15,27 +15,61 @@
15
15
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
16
16
  // *****************************************************************************
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.getDateFnsLocale = getDateFnsLocale;
19
18
  exports.formatTimeAgo = formatTimeAgo;
20
19
  const core_1 = require("@theia/core");
21
- const date_fns_1 = require("date-fns");
22
- const locales = require("date-fns/locale");
23
20
  /**
24
- * Returns the date-fns locale matching the current Theia locale.
21
+ * The relative time units supported by `formatTimeAgo` with their approximate
22
+ * lengths in milliseconds, from largest to smallest. The first unit that fits
23
+ * into the elapsed time is used.
25
24
  */
26
- function getDateFnsLocale() {
27
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
- return core_1.nls.locale ? locales[core_1.nls.locale] ?? locales.enUS : locales.enUS;
25
+ const RELATIVE_TIME_UNITS = [
26
+ ['year', 31_557_600_000], // 365.25 days
27
+ ['month', 2_629_800_000], // 30.44 days
28
+ ['week', 604_800_000],
29
+ ['day', 86_400_000],
30
+ ['hour', 3_600_000],
31
+ ['minute', 60_000],
32
+ ['second', 1_000]
33
+ ];
34
+ let cachedRelativeTimeFormat;
35
+ /**
36
+ * Returns a cached `Intl.RelativeTimeFormat` for the current Theia locale,
37
+ * falling back to English if the locale is not supported.
38
+ */
39
+ function getRelativeTimeFormat() {
40
+ // Theia locale ids may use '_' as region separator while `Intl` expects BCP-47 ('-').
41
+ const locale = core_1.nls.locale?.replace('_', '-');
42
+ if (cachedRelativeTimeFormat === undefined || cachedRelativeTimeFormat.locale !== locale) {
43
+ try {
44
+ cachedRelativeTimeFormat = { locale, format: new Intl.RelativeTimeFormat(locale, { numeric: 'always' }) };
45
+ }
46
+ catch {
47
+ cachedRelativeTimeFormat = { locale, format: new Intl.RelativeTimeFormat('en', { numeric: 'always' }) };
48
+ }
49
+ }
50
+ return cachedRelativeTimeFormat.format;
29
51
  }
30
52
  /**
31
53
  * Formats a timestamp as a human-readable relative time string (e.g., "2 hours ago").
54
+ *
55
+ * The largest unit that fits into the elapsed time is used and the value is rounded
56
+ * down so that it never crosses into the next larger unit. Timestamps less than a
57
+ * second away render as "0 seconds ago", future ones as "in ...".
32
58
  * @param timestamp - The timestamp in milliseconds
33
- * @param addSuffix - Whether to add "ago" suffix (default: true)
34
59
  */
35
- function formatTimeAgo(timestamp, addSuffix = true) {
36
- return (0, date_fns_1.formatDistance)(new Date(timestamp), new Date(), {
37
- addSuffix,
38
- locale: getDateFnsLocale()
39
- });
60
+ function formatTimeAgo(timestamp) {
61
+ if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat === 'undefined') {
62
+ // Environments without `Intl.RelativeTimeFormat` get an absolute date instead.
63
+ return new Date(timestamp).toLocaleString();
64
+ }
65
+ const elapsed = Date.now() - timestamp;
66
+ const direction = elapsed >= 0 ? -1 : 1; // `Intl` expects negative values for the past
67
+ const duration = Math.abs(elapsed);
68
+ for (const [unit, unitLength] of RELATIVE_TIME_UNITS) {
69
+ if (duration >= unitLength) {
70
+ return getRelativeTimeFormat().format(direction * Math.floor(duration / unitLength), unit);
71
+ }
72
+ }
73
+ return getRelativeTimeFormat().format(-0, 'second');
40
74
  }
41
75
  //# sourceMappingURL=chat-date-utils.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat-date-utils.js","sourceRoot":"","sources":["../../src/browser/chat-date-utils.ts"],"names":[],"mappings":";AAAA,gFAAgF;AAChF,yCAAyC;AACzC,EAAE;AACF,2EAA2E;AAC3E,mEAAmE;AACnE,wCAAwC;AACxC,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,6EAA6E;AAC7E,yDAAyD;AACzD,uDAAuD;AACvD,EAAE;AACF,gFAAgF;AAChF,gFAAgF;;AAShF,4CAGC;AAOD,sCAKC;AAtBD,sCAAkC;AAClC,uCAA0C;AAC1C,2CAA2C;AAE3C;;GAEG;AACH,SAAgB,gBAAgB;IAC5B,8DAA8D;IAC9D,OAAO,UAAG,CAAC,MAAM,CAAC,CAAC,CAAE,OAAe,CAAC,UAAG,CAAC,MAAM,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC;AACpF,CAAC;AAED;;;;GAIG;AACH,SAAgB,aAAa,CAAC,SAAiB,EAAE,YAAqB,IAAI;IACtE,OAAO,IAAA,yBAAc,EAAC,IAAI,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,IAAI,EAAE,EAAE;QACnD,SAAS;QACT,MAAM,EAAE,gBAAgB,EAAE;KAC7B,CAAC,CAAC;AACP,CAAC"}
1
+ {"version":3,"file":"chat-date-utils.js","sourceRoot":"","sources":["../../src/browser/chat-date-utils.ts"],"names":[],"mappings":";AAAA,gFAAgF;AAChF,yCAAyC;AACzC,EAAE;AACF,2EAA2E;AAC3E,mEAAmE;AACnE,wCAAwC;AACxC,EAAE;AACF,4EAA4E;AAC5E,8EAA8E;AAC9E,6EAA6E;AAC7E,yDAAyD;AACzD,uDAAuD;AACvD,EAAE;AACF,gFAAgF;AAChF,gFAAgF;;AA8ChF,sCAcC;AA1DD,sCAAkC;AAElC;;;;GAIG;AACH,MAAM,mBAAmB,GAAkE;IACvF,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,cAAc;IACxC,CAAC,OAAO,EAAE,aAAa,CAAC,EAAE,aAAa;IACvC,CAAC,MAAM,EAAE,WAAW,CAAC;IACrB,CAAC,KAAK,EAAE,UAAU,CAAC;IACnB,CAAC,MAAM,EAAE,SAAS,CAAC;IACnB,CAAC,QAAQ,EAAE,MAAM,CAAC;IAClB,CAAC,QAAQ,EAAE,KAAK,CAAC;CACpB,CAAC;AAEF,IAAI,wBAAqG,CAAC;AAE1G;;;GAGG;AACH,SAAS,qBAAqB;IAC1B,sFAAsF;IACtF,MAAM,MAAM,GAAG,UAAG,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAC7C,IAAI,wBAAwB,KAAK,SAAS,IAAI,wBAAwB,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QACvF,IAAI,CAAC;YACD,wBAAwB,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,IAAI,CAAC,kBAAkB,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;QAC9G,CAAC;QAAC,MAAM,CAAC;YACL,wBAAwB,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,IAAI,CAAC,kBAAkB,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;QAC5G,CAAC;IACL,CAAC;IACD,OAAO,wBAAwB,CAAC,MAAM,CAAC;AAC3C,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,aAAa,CAAC,SAAiB;IAC3C,IAAI,OAAO,IAAI,KAAK,WAAW,IAAI,OAAO,IAAI,CAAC,kBAAkB,KAAK,WAAW,EAAE,CAAC;QAChF,+EAA+E;QAC/E,OAAO,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,cAAc,EAAE,CAAC;IAChD,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC;IACvC,MAAM,SAAS,GAAG,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,8CAA8C;IACvF,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACnC,KAAK,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,IAAI,mBAAmB,EAAE,CAAC;QACnD,IAAI,QAAQ,IAAI,UAAU,EAAE,CAAC;YACzB,OAAO,qBAAqB,EAAE,CAAC,MAAM,CAAC,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,UAAU,CAAC,EAAE,IAAI,CAAC,CAAC;QAC/F,CAAC;IACL,CAAC;IACD,OAAO,qBAAqB,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;AACxD,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=chat-date-utils.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-date-utils.spec.d.ts","sourceRoot":"","sources":["../../src/browser/chat-date-utils.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ // *****************************************************************************
3
+ // Copyright (C) 2026 EclipseSource GmbH.
4
+ //
5
+ // This program and the accompanying materials are made available under the
6
+ // terms of the Eclipse Public License v. 2.0 which is available at
7
+ // http://www.eclipse.org/legal/epl-2.0.
8
+ //
9
+ // This Source Code may also be made available under the following Secondary
10
+ // Licenses when the conditions for such availability set forth in the Eclipse
11
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
12
+ // with the GNU Classpath Exception which is available at
13
+ // https://www.gnu.org/software/classpath/license.html.
14
+ //
15
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
16
+ // *****************************************************************************
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ const chai_1 = require("chai");
19
+ const chat_date_utils_1 = require("./chat-date-utils");
20
+ describe('formatTimeAgo', () => {
21
+ // `formatTimeAgo` uses the runtime's default locale when no Theia locale is
22
+ // set (as in tests), so expected strings must be computed with a reference
23
+ // formatter using the same locale instead of being hard-coded.
24
+ const reference = new Intl.RelativeTimeFormat(undefined, { numeric: 'always' });
25
+ it('uses the largest unit that fits into the elapsed time', () => {
26
+ const cases = [
27
+ [2_000, 'second'],
28
+ [2 * 60_000, 'minute'],
29
+ [2 * 3_600_000, 'hour'],
30
+ [2 * 86_400_000, 'day'],
31
+ [2 * 604_800_000, 'week'],
32
+ [2 * 2_629_800_000, 'month'],
33
+ [2 * 31_557_600_000, 'year']
34
+ ];
35
+ for (const [elapsed, unit] of cases) {
36
+ const timestamp = Date.now() - elapsed;
37
+ (0, chai_1.expect)((0, chat_date_utils_1.formatTimeAgo)(timestamp)).to.equal(reference.format(-2, unit));
38
+ }
39
+ });
40
+ it('rounds down so the value never crosses into the next larger unit', () => {
41
+ const cases = [
42
+ [90_000, 'minute', 1], // 1.5 minutes floor to 1
43
+ [59.5 * 60_000, 'minute', 59],
44
+ [23.6 * 3_600_000, 'hour', 23],
45
+ [6.6 * 86_400_000, 'day', 6],
46
+ [11.6 * 2_629_800_000, 'month', 11]
47
+ ];
48
+ for (const [elapsed, unit, value] of cases) {
49
+ const timestamp = Date.now() - elapsed;
50
+ (0, chai_1.expect)((0, chat_date_utils_1.formatTimeAgo)(timestamp)).to.equal(reference.format(-value, unit));
51
+ }
52
+ });
53
+ it('renders the present timestamp as 0 seconds ago', () => {
54
+ (0, chai_1.expect)((0, chat_date_utils_1.formatTimeAgo)(Date.now())).to.equal(reference.format(-0, 'second'));
55
+ });
56
+ it('renders future timestamps as "in ..."', () => {
57
+ const timestamp = Date.now() + 2.5 * 3_600_000; // 2.5 hours floor to 2
58
+ (0, chai_1.expect)((0, chat_date_utils_1.formatTimeAgo)(timestamp)).to.equal(reference.format(2, 'hour'));
59
+ });
60
+ });
61
+ //# sourceMappingURL=chat-date-utils.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-date-utils.spec.js","sourceRoot":"","sources":["../../src/browser/chat-date-utils.spec.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,19 +1,18 @@
1
1
  {
2
2
  "name": "@theia/ai-chat-ui",
3
- "version": "1.77.0-next.37+29045502f",
3
+ "version": "1.77.0-next.43+5dbb84a9f",
4
4
  "description": "Theia - AI Chat UI Extension",
5
5
  "dependencies": {
6
- "@theia/ai-chat": "1.77.0-next.37+29045502f",
7
- "@theia/ai-core": "1.77.0-next.37+29045502f",
8
- "@theia/core": "1.77.0-next.37+29045502f",
9
- "@theia/editor": "1.77.0-next.37+29045502f",
10
- "@theia/editor-preview": "1.77.0-next.37+29045502f",
11
- "@theia/filesystem": "1.77.0-next.37+29045502f",
12
- "@theia/monaco": "1.77.0-next.37+29045502f",
6
+ "@theia/ai-chat": "1.77.0-next.43+5dbb84a9f",
7
+ "@theia/ai-core": "1.77.0-next.43+5dbb84a9f",
8
+ "@theia/core": "1.77.0-next.43+5dbb84a9f",
9
+ "@theia/editor": "1.77.0-next.43+5dbb84a9f",
10
+ "@theia/editor-preview": "1.77.0-next.43+5dbb84a9f",
11
+ "@theia/filesystem": "1.77.0-next.43+5dbb84a9f",
12
+ "@theia/monaco": "1.77.0-next.43+5dbb84a9f",
13
13
  "@theia/monaco-editor-core": "1.108.201",
14
- "@theia/preferences": "1.77.0-next.37+29045502f",
15
- "@theia/workspace": "1.77.0-next.37+29045502f",
16
- "date-fns": "^4.4.0",
14
+ "@theia/preferences": "1.77.0-next.43+5dbb84a9f",
15
+ "@theia/workspace": "1.77.0-next.43+5dbb84a9f",
17
16
  "mermaid": "^11.17.2",
18
17
  "tslib": "^2.8.1"
19
18
  },
@@ -56,5 +55,5 @@
56
55
  "nyc": {
57
56
  "extends": "../../configs/nyc.json"
58
57
  },
59
- "gitHead": "29045502ffe2bd1ecd5c655b40f37f0d0bc0c4ad"
58
+ "gitHead": "5dbb84a9f43e9287fe3d6f0f52ee1dd2c3c4a20f"
60
59
  }
@@ -0,0 +1,65 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { formatTimeAgo } from './chat-date-utils';
19
+
20
+ describe('formatTimeAgo', () => {
21
+
22
+ // `formatTimeAgo` uses the runtime's default locale when no Theia locale is
23
+ // set (as in tests), so expected strings must be computed with a reference
24
+ // formatter using the same locale instead of being hard-coded.
25
+ const reference = new Intl.RelativeTimeFormat(undefined, { numeric: 'always' });
26
+
27
+ it('uses the largest unit that fits into the elapsed time', () => {
28
+ const cases: ReadonlyArray<readonly [number, Intl.RelativeTimeFormatUnit]> = [
29
+ [2_000, 'second'],
30
+ [2 * 60_000, 'minute'],
31
+ [2 * 3_600_000, 'hour'],
32
+ [2 * 86_400_000, 'day'],
33
+ [2 * 604_800_000, 'week'],
34
+ [2 * 2_629_800_000, 'month'],
35
+ [2 * 31_557_600_000, 'year']
36
+ ];
37
+ for (const [elapsed, unit] of cases) {
38
+ const timestamp = Date.now() - elapsed;
39
+ expect(formatTimeAgo(timestamp)).to.equal(reference.format(-2, unit));
40
+ }
41
+ });
42
+
43
+ it('rounds down so the value never crosses into the next larger unit', () => {
44
+ const cases: ReadonlyArray<readonly [number, Intl.RelativeTimeFormatUnit, number]> = [
45
+ [90_000, 'minute', 1], // 1.5 minutes floor to 1
46
+ [59.5 * 60_000, 'minute', 59],
47
+ [23.6 * 3_600_000, 'hour', 23],
48
+ [6.6 * 86_400_000, 'day', 6],
49
+ [11.6 * 2_629_800_000, 'month', 11]
50
+ ];
51
+ for (const [elapsed, unit, value] of cases) {
52
+ const timestamp = Date.now() - elapsed;
53
+ expect(formatTimeAgo(timestamp)).to.equal(reference.format(-value, unit));
54
+ }
55
+ });
56
+
57
+ it('renders the present timestamp as 0 seconds ago', () => {
58
+ expect(formatTimeAgo(Date.now())).to.equal(reference.format(-0, 'second'));
59
+ });
60
+
61
+ it('renders future timestamps as "in ..."', () => {
62
+ const timestamp = Date.now() + 2.5 * 3_600_000; // 2.5 hours floor to 2
63
+ expect(formatTimeAgo(timestamp)).to.equal(reference.format(2, 'hour'));
64
+ });
65
+ });
@@ -15,25 +15,61 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { nls } from '@theia/core';
18
- import { formatDistance } from 'date-fns';
19
- import * as locales from 'date-fns/locale';
20
18
 
21
19
  /**
22
- * Returns the date-fns locale matching the current Theia locale.
20
+ * The relative time units supported by `formatTimeAgo` with their approximate
21
+ * lengths in milliseconds, from largest to smallest. The first unit that fits
22
+ * into the elapsed time is used.
23
23
  */
24
- export function getDateFnsLocale(): locales.Locale {
25
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- return nls.locale ? (locales as any)[nls.locale] ?? locales.enUS : locales.enUS;
24
+ const RELATIVE_TIME_UNITS: ReadonlyArray<readonly [Intl.RelativeTimeFormatUnit, number]> = [
25
+ ['year', 31_557_600_000], // 365.25 days
26
+ ['month', 2_629_800_000], // 30.44 days
27
+ ['week', 604_800_000],
28
+ ['day', 86_400_000],
29
+ ['hour', 3_600_000],
30
+ ['minute', 60_000],
31
+ ['second', 1_000]
32
+ ];
33
+
34
+ let cachedRelativeTimeFormat: { locale: string | undefined; format: Intl.RelativeTimeFormat } | undefined;
35
+
36
+ /**
37
+ * Returns a cached `Intl.RelativeTimeFormat` for the current Theia locale,
38
+ * falling back to English if the locale is not supported.
39
+ */
40
+ function getRelativeTimeFormat(): Intl.RelativeTimeFormat {
41
+ // Theia locale ids may use '_' as region separator while `Intl` expects BCP-47 ('-').
42
+ const locale = nls.locale?.replace('_', '-');
43
+ if (cachedRelativeTimeFormat === undefined || cachedRelativeTimeFormat.locale !== locale) {
44
+ try {
45
+ cachedRelativeTimeFormat = { locale, format: new Intl.RelativeTimeFormat(locale, { numeric: 'always' }) };
46
+ } catch {
47
+ cachedRelativeTimeFormat = { locale, format: new Intl.RelativeTimeFormat('en', { numeric: 'always' }) };
48
+ }
49
+ }
50
+ return cachedRelativeTimeFormat.format;
27
51
  }
28
52
 
29
53
  /**
30
54
  * Formats a timestamp as a human-readable relative time string (e.g., "2 hours ago").
55
+ *
56
+ * The largest unit that fits into the elapsed time is used and the value is rounded
57
+ * down so that it never crosses into the next larger unit. Timestamps less than a
58
+ * second away render as "0 seconds ago", future ones as "in ...".
31
59
  * @param timestamp - The timestamp in milliseconds
32
- * @param addSuffix - Whether to add "ago" suffix (default: true)
33
60
  */
34
- export function formatTimeAgo(timestamp: number, addSuffix: boolean = true): string {
35
- return formatDistance(new Date(timestamp), new Date(), {
36
- addSuffix,
37
- locale: getDateFnsLocale()
38
- });
61
+ export function formatTimeAgo(timestamp: number): string {
62
+ if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat === 'undefined') {
63
+ // Environments without `Intl.RelativeTimeFormat` get an absolute date instead.
64
+ return new Date(timestamp).toLocaleString();
65
+ }
66
+ const elapsed = Date.now() - timestamp;
67
+ const direction = elapsed >= 0 ? -1 : 1; // `Intl` expects negative values for the past
68
+ const duration = Math.abs(elapsed);
69
+ for (const [unit, unitLength] of RELATIVE_TIME_UNITS) {
70
+ if (duration >= unitLength) {
71
+ return getRelativeTimeFormat().format(direction * Math.floor(duration / unitLength), unit);
72
+ }
73
+ }
74
+ return getRelativeTimeFormat().format(-0, 'second');
39
75
  }