diff --git a/app/assets/javascripts/initializePage.js b/app/assets/javascripts/initializePage.js index 380344d09..97d0dcf92 100644 --- a/app/assets/javascripts/initializePage.js +++ b/app/assets/javascripts/initializePage.js @@ -3,7 +3,7 @@ initializeAllTagEditButtons, initializeUserFollowButts, initializeCommentsPage, initializeArticleDate, initializeArticleReactions, initNotifications, - initializeCommentDate, initializeSettings, + initializeSettings, initializeCommentPreview, initializeRuntimeBanner, initializeTimeFixer, initializeDashboardSort, initializeArchivedPostFilter, initializeCreditsPage, @@ -20,7 +20,6 @@ function callInitializers() { initializeArticleDate(); initializeArticleReactions(); initNotifications(); - initializeCommentDate(); initializeSettings(); initializeCommentPreview(); initializeTimeFixer(); diff --git a/app/assets/javascripts/initializers/initializeCommentsPage.js.erb b/app/assets/javascripts/initializers/initializeCommentsPage.js.erb index ec31ab2f2..bd4e081b1 100644 --- a/app/assets/javascripts/initializers/initializeCommentsPage.js.erb +++ b/app/assets/javascripts/initializers/initializeCommentsPage.js.erb @@ -451,6 +451,14 @@ function updateCommentsCount() { commentsSidebarCountDiv.innerHTML = `${commentsCountData}`; } +function initializeCommentDate() { + const commentsDates = document.querySelectorAll('.comment-date time'); + + if (commentsDates) { + addLocalizedDateTimeToElementsTitles(commentsDates, 'datetime'); + } +} + function handleHiddenComments(commentableType){ const currentUser = userData(); const commentableAuthorIds = []; diff --git a/app/javascript/packs/baseInitializers.js b/app/javascript/packs/baseInitializers.js new file mode 100644 index 000000000..4c90cd89f --- /dev/null +++ b/app/javascript/packs/baseInitializers.js @@ -0,0 +1,3 @@ +import { initializeCommentDate } from "./initializers/initializeCommentDate"; + +initializeCommentDate(); \ No newline at end of file diff --git a/app/javascript/packs/initializers/initializeCommentDate.js b/app/javascript/packs/initializers/initializeCommentDate.js new file mode 100644 index 000000000..57b4c17a8 --- /dev/null +++ b/app/javascript/packs/initializers/initializeCommentDate.js @@ -0,0 +1,9 @@ +import { addLocalizedDateTimeToElementsTitles } from "../../utilities/localDateTime"; + +export function initializeCommentDate() { + const commentsDates = document.querySelectorAll('.comment-date time'); + + if (commentsDates) { + addLocalizedDateTimeToElementsTitles(commentsDates, 'datetime'); + } +} diff --git a/app/javascript/utilities/__tests__/localDateTime.test.js b/app/javascript/utilities/__tests__/localDateTime.test.js new file mode 100644 index 000000000..371834855 --- /dev/null +++ b/app/javascript/utilities/__tests__/localDateTime.test.js @@ -0,0 +1,26 @@ +import {timestampToLocalDateTime, addLocalizedDateTimeToElementsTitles } from '@utilities/localDateTime'; + +describe('LocalDateTime Utilities', () => { + it('should return empty string when no timestamp', () => { + const localTime = timestampToLocalDateTime(null, null, null) + expect(localTime).toEqual(''); + }); + + it('should return readable date string', () => { + const localTime = timestampToLocalDateTime('2019-05-03T16:02:50.908Z', 'default', {}) + expect(localTime).toEqual('5/3/2019'); + }); + + it('should return formatted year when year option added', () => { + const localTime = timestampToLocalDateTime('2019-05-03T16:02:50.908Z', 'default', {year: '2-digit'}) + expect(localTime).toEqual('19'); + }); + + it('should add datetime attribute to element', () => { + document.body.setAttribute('datetime', 2222) + addLocalizedDateTimeToElementsTitles(document.querySelectorAll("body"), 'datetime') + // eslint-disable-next-line no-prototype-builtins + expect(document.querySelector('body').attributes.hasOwnProperty('datetime')).toBe(true); + }); +}); + \ No newline at end of file diff --git a/app/javascript/utilities/localDateTime.js b/app/javascript/utilities/localDateTime.js new file mode 100644 index 000000000..5f0d8faaa --- /dev/null +++ b/app/javascript/utilities/localDateTime.js @@ -0,0 +1,107 @@ +/* Local date/time utilities */ + +/* + Convert string timestamp to local time, using the given locale. + + timestamp should be something like '2019-05-03T16:02:50.908Z' + locale can be `navigator.language` or a custom locale. defaults to 'default' + options are `Intl.DateTimeFormat` options + + see + for more information. +*/ +export function timestampToLocalDateTime(timestamp, locale, options) { + if (!timestamp) { + return ''; + } + + try { + const time = new Date(timestamp); + const formattedTime = new Intl.DateTimeFormat( + locale || 'default', + options, + ).format(time); + return options.year === '2-digit' + ? formattedTime.replace(', ', " '") + : formattedTime; + } catch (e) { + return ''; + } +} + +export function addLocalizedDateTimeToElementsTitles(elements, timestampAttribute) { + for (let i = 0; i < elements.length; i += 1) { + const element = elements[i]; + + // get UTC timestamp set by the server + const timestamp = element.getAttribute(timestampAttribute || 'datetime'); + + if (timestamp) { + // add a full datetime to the element title, visible on hover. + // `navigator.language` is used to allow the date to be localized + // according to the browser's locale + // see + const localDateTime = timestampToLocalDateTimeLong(timestamp); + element.setAttribute('title', localDateTime); + } + } +} + +export function localizeTimeElements(elements, timeOptions) { + for (let i = 0; i < elements.length; i += 1) { + const element = elements[i]; + + const timestamp = element.getAttribute('datetime'); + if (timestamp) { + const localDateTime = timestampToLocalDateTime( + timestamp, + navigator.language, + timeOptions, + ); + + element.textContent = localDateTime; + } + } +} + +function timestampToLocalDateTimeLong(timestamp) { + // example: "Wednesday, April 3, 2019, 2:55:14 PM" + + return timestampToLocalDateTime(timestamp, navigator.language, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }); +} + +function timestampToLocalDateTimeShort(timestamp) { + // example: "10 Dec 2018" if it is not the current year + // example: "6 Sep" if it is the current year + + if (timestamp) { + const currentYear = new Date().getFullYear(); + const givenYear = new Date(timestamp).getFullYear(); + + const timeOptions = { + day: 'numeric', + month: 'short', + }; + + if (givenYear !== currentYear) { + timeOptions.year = 'numeric'; + } + + return timestampToLocalDateTime(timestamp, navigator.language, timeOptions); + } + + return ''; +} + +if (typeof globalThis !== 'undefined') { + globalThis.timestampToLocalDateTimeLong = timestampToLocalDateTimeLong; // eslint-disable-line no-undef + globalThis.timestampToLocalDateTimeShort = timestampToLocalDateTimeShort; // eslint-disable-line no-undef +} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e625f33ae..98c07b0e0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -20,9 +20,9 @@ <%= render "layouts/styles", qualifier: "main" %> <%= javascript_include_tag "base", defer: true %> <% if user_signed_in? %> - <%= javascript_packs_with_chunks_tag "base", "Search", "runtimeBanner", "onboardingRedirectCheck", "contentDisplayPolicy", "baseTracking", defer: true %> + <%= javascript_packs_with_chunks_tag "base", "baseInitializers", "Search", "runtimeBanner", "onboardingRedirectCheck", "contentDisplayPolicy", "baseTracking", defer: true %> <% else %> - <%= javascript_packs_with_chunks_tag "base", "Search", "runtimeBanner", "baseTracking", defer: true %> + <%= javascript_packs_with_chunks_tag "base", "baseInitializers", "Search", "runtimeBanner", "baseTracking", defer: true %> <% end %> <%= yield(:page_meta) %>