|
3 | 3 | * SPDX-License-Identifier: AGPL-3.0-or-later |
4 | 4 | */ |
5 | 5 |
|
6 | | -import type { MaybeRef } from 'vue' |
7 | | -import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n' |
8 | | -import { computed, onUnmounted, ref, toValue, watchEffect } from 'vue' |
| 6 | +import type { FormatDateOptions } from '@nextcloud/l10n' |
| 7 | +import type { MaybeRefOrGetter, Ref } from 'vue' |
| 8 | + |
| 9 | +import { formatRelativeTime, getCanonicalLocale } from '@nextcloud/l10n' |
| 10 | +import { computed, onUnmounted, readonly, ref, toValue, watchEffect } from 'vue' |
9 | 11 | import { t } from '../l10n.js' |
10 | 12 |
|
11 | | -const FEW_SECONDS_AGO = { |
12 | | - long: t('a few seconds ago'), |
13 | | - short: t('seconds ago'), // FOR TRANSLATORS: Shorter version of 'a few seconds ago' |
14 | | - narrow: t('sec. ago'), // FOR TRANSLATORS: If possible in your language an even shorter version of 'a few seconds ago' |
| 13 | +interface FormatRelativeTimeOptions extends Partial<Omit<FormatDateOptions, 'ignoreSeconds'>> { |
| 14 | + ignoreSeconds?: boolean |
| 15 | + |
| 16 | + /** |
| 17 | + * If set to false the relative time will not be updated anymore. |
| 18 | + * @default true - Meaning the relative time will be updated if needed |
| 19 | + */ |
| 20 | + update?: boolean |
15 | 21 | } |
16 | 22 |
|
17 | | -interface FormatDateOptions { |
| 23 | +interface FormatTimeOptions { |
| 24 | + /** |
| 25 | + * Locale to use for formatting. |
| 26 | + * @default current locale |
| 27 | + */ |
| 28 | + locale?: string |
| 29 | + |
| 30 | + /** |
| 31 | + * The format used for displaying. |
| 32 | + * |
| 33 | + * @default { timeStyle: 'medium', dateStyle: 'short' } |
| 34 | + */ |
| 35 | + format?: Intl.DateTimeFormatOptions |
| 36 | +} |
| 37 | + |
| 38 | +/** |
| 39 | + * @deprecated |
| 40 | + */ |
| 41 | +interface LegacyFormatDateTimeOptions { |
18 | 42 | /** |
19 | 43 | * The format used for displaying, or if relative time is used the format used for the title |
20 | 44 | */ |
21 | 45 | format?: Intl.DateTimeFormatOptions |
22 | 46 | /** |
23 | 47 | * Ignore seconds when displaying the relative time and just show `a few seconds ago` |
24 | 48 | */ |
25 | | - ignoreSeconds?: boolean |
| 49 | + ignoreSeconds?: true |
26 | 50 | /** |
27 | 51 | * Wether to display the timestamp as time from now |
28 | 52 | */ |
29 | 53 | relativeTime?: false | 'long' | 'short' | 'narrow' |
30 | 54 | } |
31 | 55 |
|
| 56 | +const FEW_SECONDS_AGO = { |
| 57 | + long: t('a few seconds ago'), |
| 58 | + short: t('seconds ago'), // FOR TRANSLATORS: Shorter version of 'a few seconds ago' |
| 59 | + narrow: t('sec. ago'), // FOR TRANSLATORS: If possible in your language an even shorter version of 'a few seconds ago' |
| 60 | +} |
| 61 | + |
32 | 62 | /** |
33 | | - * Composable for formatting time stamps using current users locale and language |
| 63 | + * Format a timestamp or date object as relative time. |
34 | 64 | * |
35 | | - * @param {import('vue').MaybeRef<Date | number>} timestamp Current timestamp |
36 | | - * @param {object} opts Optional options |
37 | | - * @param {Intl.DateTimeFormatOptions} opts.format The format used for displaying, or if relative time is used the format used for the title (optional) |
38 | | - * @param {boolean} opts.ignoreSeconds Ignore seconds when displaying the relative time and just show `a few seconds ago` |
39 | | - * @param {false | 'long' | 'short' | 'narrow'} opts.relativeTime Wether to display the timestamp as time from now (optional) |
| 65 | + * This is a composable wrapper around `formatRelativeTime` from `@nextcloud/l10n`. |
| 66 | + * |
| 67 | + * @param timestamp - The timestamp to format |
| 68 | + * @param opts - Formatting options |
40 | 69 | */ |
41 | | -export function useFormatDateTime( |
42 | | - timestamp: MaybeRef<Date|number> = Date.now(), |
43 | | - opts: MaybeRef<FormatDateOptions> = {}, |
44 | | -) { |
45 | | - // Current time as Date.now is not reactive |
46 | | - const currentTime = ref(Date.now()) |
47 | | - // The interval ID for the window |
48 | | - let intervalId: number|undefined |
49 | | - |
50 | | - const options = ref({ |
51 | | - format: { |
52 | | - timeStyle: 'medium', |
53 | | - dateStyle: 'short', |
54 | | - } as Intl.DateTimeFormatOptions, |
55 | | - relativeTime: 'long' as const, |
56 | | - ignoreSeconds: false, |
57 | | - ...toValue(opts), |
58 | | - }) |
59 | | - const wrappedOptions = computed<Required<FormatDateOptions>>(() => ({ ...toValue(opts), ...options.value })) |
| 70 | +export function useFormatRelativeTime( |
| 71 | + timestamp: MaybeRefOrGetter<Date | number> = Date.now(), |
| 72 | + opts: MaybeRefOrGetter<FormatRelativeTimeOptions> = {}, |
| 73 | +): Readonly<Ref<string>> { |
| 74 | + let timeoutId: number |
60 | 75 |
|
61 | | - /** ECMA Date object of the timestamp */ |
| 76 | + /** |
| 77 | + * ECMA Date object of the timestamp |
| 78 | + */ |
62 | 79 | const date = computed(() => new Date(toValue(timestamp))) |
63 | 80 |
|
64 | | - const formattedFullTime = computed<string>(() => { |
65 | | - const formatter = new Intl.DateTimeFormat(getCanonicalLocale(), wrappedOptions.value.format) |
66 | | - return formatter.format(date.value) |
| 81 | + /** |
| 82 | + * Reactive options for `formatRelativeTime` method |
| 83 | + */ |
| 84 | + const options = computed<FormatDateOptions>(() => { |
| 85 | + const { language, relativeTime, ignoreSeconds } = toValue(opts) |
| 86 | + return { |
| 87 | + ...language && { language }, |
| 88 | + ...relativeTime && { relativeTime }, |
| 89 | + ignoreSeconds: ignoreSeconds |
| 90 | + ? FEW_SECONDS_AGO[relativeTime || 'long'] |
| 91 | + : false, |
| 92 | + } |
67 | 93 | }) |
68 | 94 |
|
69 | | - /** Time string formatted for main text */ |
70 | | - const formattedTime = computed<string>(() => { |
71 | | - if (wrappedOptions.value.relativeTime !== false) { |
72 | | - const formatter = new Intl.RelativeTimeFormat(getLanguage(), { numeric: 'auto', style: wrappedOptions.value.relativeTime }) |
73 | | - |
74 | | - const diff = date.value.getTime() - currentTime.value |
75 | | - const seconds = diff / 1000 |
76 | | - if (Math.abs(seconds) <= 90) { |
77 | | - if (wrappedOptions.value.ignoreSeconds) { |
78 | | - return FEW_SECONDS_AGO[wrappedOptions.value.relativeTime] |
79 | | - } else { |
80 | | - return formatter.format(Math.round(seconds), 'second') |
81 | | - } |
82 | | - } |
83 | | - const minutes = seconds / 60 |
84 | | - if (Math.abs(minutes) <= 90) { |
85 | | - return formatter.format(Math.round(minutes), 'minute') |
86 | | - } |
87 | | - const hours = minutes / 60 |
88 | | - if (Math.abs(hours) <= 24) { |
89 | | - return formatter.format(Math.round(hours), 'hour') |
90 | | - } |
91 | | - const days = hours / 24 |
92 | | - if (Math.abs(days) <= 6) { |
93 | | - return formatter.format(Math.round(days), 'day') |
94 | | - } |
95 | | - const weeks = days / 7 |
96 | | - if (Math.abs(weeks) <= 4) { |
97 | | - return formatter.format(Math.round(weeks), 'week') |
98 | | - } |
99 | | - const months = days / 30 |
100 | | - if (Math.abs(months) <= 12) { |
101 | | - return formatter.format(Math.round(months), 'month') |
102 | | - } |
103 | | - return formatter.format(Math.round(days / 365), 'year') |
| 95 | + /** |
| 96 | + * The formatted relative time |
| 97 | + */ |
| 98 | + const relativeTime = ref('') |
| 99 | + watchEffect(() => updateRelativeTime()) |
| 100 | + |
| 101 | + /** |
| 102 | + * Update the relative time string. |
| 103 | + * This is the callback for the interval. |
| 104 | + */ |
| 105 | + function updateRelativeTime() { |
| 106 | + relativeTime.value = formatRelativeTime(date.value, options.value) |
| 107 | + |
| 108 | + if (toValue(opts).update !== false) { |
| 109 | + const diff = Math.abs(Date.now() - new Date(toValue(timestamp)).getTime()) |
| 110 | + const interval = diff > 120000 || options.value.ignoreSeconds |
| 111 | + ? Math.min(diff / 60, 1800000) |
| 112 | + : 1000 |
| 113 | + timeoutId = window.setTimeout(updateRelativeTime, interval) |
104 | 114 | } |
105 | | - return formattedFullTime.value |
106 | | - }) |
| 115 | + } |
| 116 | + |
| 117 | + // when the component is unmounted we also clear the timeout |
| 118 | + onUnmounted(() => timeoutId && window.clearTimeout(timeoutId)) |
| 119 | + |
| 120 | + return readonly(relativeTime) |
| 121 | +} |
| 122 | + |
| 123 | +/** |
| 124 | + * Format a given timestamp or date object as a human readable string. |
| 125 | + * |
| 126 | + * @param timestamp - Timestamp or date object to format |
| 127 | + * @param opts - Formatting options |
| 128 | + */ |
| 129 | +export function useFormatTime( |
| 130 | + timestamp: MaybeRefOrGetter<number | Date>, |
| 131 | + opts: MaybeRefOrGetter<FormatTimeOptions>, |
| 132 | +): Readonly<Ref<string>> { |
| 133 | + const options = computed<Required<FormatTimeOptions>>(() => ({ |
| 134 | + locale: getCanonicalLocale(), |
| 135 | + format: { dateStyle: 'short', timeStyle: 'medium' }, |
| 136 | + ...toValue(opts), |
| 137 | + })) |
| 138 | + |
| 139 | + const formatter = computed(() => new Intl.DateTimeFormat(options.value.locale, options.value.format)) |
| 140 | + |
| 141 | + return computed(() => formatter.value.format(toValue(timestamp))) |
| 142 | +} |
107 | 143 |
|
108 | | - // Set or clear interval if relative time is dis/enabled |
109 | | - watchEffect(() => { |
110 | | - window.clearInterval(intervalId) |
111 | | - intervalId = undefined |
112 | | - if (wrappedOptions.value.relativeTime) { |
113 | | - intervalId = window.setInterval(() => { currentTime.value = Date.now() }, 1000) |
| 144 | +/** |
| 145 | + * Composable for formatting time stamps using current users locale and language |
| 146 | + * |
| 147 | + * @param {import('vue').MaybeRefOrGetter<Date | number>} timestamp Current timestamp |
| 148 | + * @param {object} opts Optional options |
| 149 | + * @param {Intl.DateTimeFormatOptions} opts.format The format used for displaying, or if relative time is used the format used for the title (optional) |
| 150 | + * @param {boolean} opts.ignoreSeconds Ignore seconds when displaying the relative time and just show `a few seconds ago` |
| 151 | + * @param {false | 'long' | 'short' | 'narrow'} opts.relativeTime Wether to display the timestamp as time from now (optional) |
| 152 | + * |
| 153 | + * @deprecated use `useFormatRelativeTime` or `useFormatTime` instead. |
| 154 | + */ |
| 155 | +export function useFormatDateTime( |
| 156 | + timestamp: MaybeRefOrGetter<Date|number> = Date.now(), |
| 157 | + opts: MaybeRefOrGetter<LegacyFormatDateTimeOptions> = {}, |
| 158 | +) { |
| 159 | + const formattedFullTime = useFormatTime(timestamp, opts) |
| 160 | + const relativeTime = useFormatRelativeTime(timestamp, computed(() => { |
| 161 | + const options = toValue(opts) |
| 162 | + return { |
| 163 | + ...options, |
| 164 | + relativeTime: typeof options.relativeTime === 'string' |
| 165 | + ? options.relativeTime |
| 166 | + : 'long', |
114 | 167 | } |
115 | | - }) |
| 168 | + })) |
116 | 169 |
|
117 | | - // ensure interval is cleared |
118 | | - onUnmounted(() => { |
119 | | - window.clearInterval(intervalId) |
120 | | - }) |
| 170 | + const formattedTime = computed(() => toValue(opts).relativeTime !== false |
| 171 | + ? relativeTime.value |
| 172 | + : formattedFullTime.value, |
| 173 | + ) |
121 | 174 |
|
122 | 175 | return { |
123 | 176 | formattedTime, |
124 | 177 | formattedFullTime, |
125 | | - options, |
126 | 178 | } |
127 | 179 | } |
0 commit comments