Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 3 additions & 103 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
"@nextcloud/files": "^3.10.2",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.2.0",
"@nextcloud/moment": "^1.3.2",
"@nextcloud/notify_push": "^1.3.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^8.23.1",
Expand Down
106 changes: 106 additions & 0 deletions src/shared/datetime.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { getCanonicalLocale } from '@nextcloud/l10n'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Late-thought opinion: getLanguage should be used for relative time (not absolute, that required locale format):

With 'en' language and 'de' locale we are geting human-readable represenation, but language of it won't match expected system:

Locale \ Lang 'en' 'de'
'en' 1:23 PM, 'in 1 day' 🟢 13:23, 'in 1 day'
'de' 1:23 PM, 'in 1 Tag' 13:23, 'in 1 Tag' 🟢

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and this is an old discussion about using locale for durations.

Here it works the same way it works on the server.


const locale = getCanonicalLocale()

const relativeTimeFormat = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })

/**
* Format duration in human-readable format.
* If the unit is not provided, the largest unit is used for rounded duration in milliseconds.
* @param duration - Duration in the unit
* @param unit - Unit to format to
*/
export function formatDuration(duration: number, unit?: Intl.RelativeTimeFormatUnit) {
if (!unit) {
const { value, unit: largestUnit } = convertMsToLargestTimeUnit(duration)
duration = value
unit = largestUnit
}

return new Intl.NumberFormat(locale, { style: 'unit', unit, unitDisplay: 'long' }).format(duration)
}

/**
* Format duration in human-readable format from now.
* If the unit is not provided, the largest unit is used for rounded duration in milliseconds.
* @param dateOrMs - Date or ms to format
* @param unit - Unit to format to
*/
export function formatDurationFromNow(dateOrMs: Date | number, unit?: Intl.RelativeTimeFormatUnit) {
return formatDuration(+new Date(dateOrMs) - Date.now(), unit)
}

/**
* Format relative time duration in human-readable format
* @param ms - Duration in milliseconds
*/
export function formatRelativeTime(ms: number) {
const { value, unit } = convertMsToLargestTimeUnit(ms)

return relativeTimeFormat.format(value, unit)
}

/**
* Format relative time duration in human-readable format from now
* @param dateOrMs - Date or ms to format
*/
export function formatRelativeTimeFromNow(dateOrMs: Date | number) {
return formatRelativeTime(+new Date(dateOrMs) - Date.now())
}

/**
* Convert milliseconds to the largest unit rounded from 0.75 point.
* @example 123 -> { value: 0, unit: 'second' }
* @example 1000 -> { value: 1, unit: 'second' }
* @example 25 * 60 * 60 * 1000 -> { value: 25, unit: 'minute' }
* @example 35 * 60 * 60 * 1000 -> { value: 35, unit: 'minute' }
* @example 45 * 60 * 60 * 1000 -> { value: 1, unit: 'hour' }
* @example 3600000 -> { value: 1, unit: 'hour' }
* @example 86400000 -> { value: 1, unit: 'day' }
* @param ms - Duration in milliseconds
*/
export function convertMsToLargestTimeUnit(ms: number): { value: number; unit: Intl.RelativeTimeFormatUnit } {
const units = {
year: 0,
month: 0,
day: 0,
hour: 0,
minute: 0,
second: 0,
}

units.second = ms / 1000
units.minute = units.second / 60
units.hour = units.minute / 60
units.day = units.hour / 24
units.month = units.day / 30
units.year = units.day / 365

//
const round = (value: number) => Math.abs(value % 1) < 0.75 ? Math.trunc(value) : Math.round(value)

// Loop from the largest unit to the smallest
for (const key in units) {
const unit = key as keyof typeof units
// Round the value so 59 min 59 sec 999 ms is 1 hour and not 59 minutes
const rounded = round(units[unit])
// Return the first non-zero unit
if (rounded !== 0) {
return {
value: rounded,
unit,
}
}
}

// now
return {
value: 0,
unit: 'second',
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { computed } from 'vue'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import { translate as t } from '@nextcloud/l10n'
import { clearAtToLabel, getTimestampForPredefinedClearAt } from '../userStatus.utils.ts'
import { formatDuration } from '../../../../shared/datetime.utils.ts'

const props = withDefaults(defineProps<{
clearAt?: number | null,
Expand All @@ -25,19 +26,19 @@ const clearAtOptions = [{
label: t('talk_desktop', 'Don\'t clear'),
clearAt: null,
}, {
label: t('talk_desktop', '30 minutes'),
label: formatDuration(1800 * 1000), // 30 minutes
clearAt: {
type: 'period',
time: 1800,
},
}, {
label: t('talk_desktop', '1 hour'),
label: formatDuration(3600 * 1000), // 1 hour
clearAt: {
type: 'period',
time: 3600,
},
}, {
label: t('talk_desktop', '4 hours'),
label: formatDuration(14400 * 1000), // 4 hours
clearAt: {
type: 'period',
time: 14400,
Expand Down
24 changes: 14 additions & 10 deletions src/talk/renderer/UserStatus/userStatus.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
*/

import type { ClearAtPredefinedConfig, PredefinedUserStatus, UserStatus, UserStatusStatusType } from './userStatus.types.ts'
import moment from '@nextcloud/moment'
import { translate as t } from '@nextcloud/l10n'
import { t, getFirstDay } from '@nextcloud/l10n'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot from appData.userMetadata, this results in empty <html data-locale > (when was not set on personal settings). Maybe it should defaults to selected language; here to 'de'?

image

also custom window.firstDay from settings is not provided as in web, and always leads to default locale value:

  • 'en' - Sunday
  • 'de' - Monday

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also custom window.firstDay from settings is not provided as in web

Yes, this info is not available for clients, so, using locale data

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot from appData.userMetadata, this results in empty <html data-locale > (when was not set on personal settings). Maybe it should defaults to selected language; here to 'de'?

I cannot reproduce this state... Even for new accounts it's en initially. How do you unset it?

Anyway, Nextcloud defaults it to 'en' in @nextcloud/l10n, so should be fixed there. But I'd fallback for the local device settings, not the language.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

occ user:setting <userid> --delete core locale. Then in web it defaults to core.lang

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import { formatDurationFromNow, formatDuration } from '../../../shared/datetime.utils.ts'

/**
* List of user status types that user can set
Expand Down Expand Up @@ -58,11 +58,17 @@ export function getTimestampForPredefinedClearAt(clearAt: ClearAtPredefinedConfi
}

if (clearAt.type === 'end-of') {
switch (clearAt.time) {
case 'day':
case 'week':
return Number(moment(new Date()).endOf(clearAt.time).format('X'))
const date = new Date()
// In any case, set the end of the day
date.setHours(23, 59, 59, 999)

if (clearAt.time === 'week') {
const firstDay = getFirstDay()
const lastDay = (firstDay + 6) % 7
date.setDate(date.getDate() + (lastDay + 7 - date.getDay()) % 7)
}

return Math.floor(date.getTime() / 1000)
}

// Unknown type
Expand All @@ -82,9 +88,7 @@ export function clearAtToLabel(clearAt: ClearAtPredefinedConfig | number | null)

// Clear At has been already set
if (typeof clearAt === 'number') {
const momentNow = moment(new Date())
const momentClearAt = moment(new Date(clearAt * 1000))
return moment.duration(momentNow.diff(momentClearAt)).humanize()
return formatDurationFromNow(clearAt * 1000)
}

// ClearAt is an object description of predefined value
Expand All @@ -98,7 +102,7 @@ export function clearAtToLabel(clearAt: ClearAtPredefinedConfig | number | null)

// ClearAt is an object description of predefined value
if (clearAt.type === 'period') {
return moment.duration(clearAt.time * 1000).humanize()
return formatDuration(clearAt.time * 1000)
}

return ''
Expand Down