diff --git a/assets/js/__tests__/timeago.spec.ts b/assets/js/__tests__/timeago.spec.ts new file mode 100644 index 00000000..e69e2702 --- /dev/null +++ b/assets/js/__tests__/timeago.spec.ts @@ -0,0 +1,114 @@ +import { timeAgo, setupTimestamps } from '../timeago'; + +const epochRfc3339 = '1970-01-01T00:00:00.000Z'; + +describe('Timeago functionality', () => { + // TODO: is this robust? do we need e.g. timekeeper to freeze the time? + function timeAgoWithSecondOffset(offset: number) { + const utc = new Date(new Date().getTime() + offset * 1000).toISOString(); + + const timeEl = document.createElement('time'); + timeEl.setAttribute('datetime', utc); + timeEl.textContent = utc; + + timeAgo([timeEl]); + return timeEl.textContent; + } + + /* eslint-disable no-implicit-coercion */ + it('should parse a time as less than a minute', () => { + expect(timeAgoWithSecondOffset(-15)).toEqual('less than a minute ago'); + expect(timeAgoWithSecondOffset(+15)).toEqual('less than a minute from now'); + }); + + it('should parse a time as about a minute', () => { + expect(timeAgoWithSecondOffset(-75)).toEqual('about a minute ago'); + expect(timeAgoWithSecondOffset(+75)).toEqual('about a minute from now'); + }); + + it('should parse a time as 30 minutes', () => { + expect(timeAgoWithSecondOffset(-(60 * 30))).toEqual('30 minutes ago'); + expect(timeAgoWithSecondOffset(+(60 * 30))).toEqual('30 minutes from now'); + }); + + it('should parse a time as about an hour', () => { + expect(timeAgoWithSecondOffset(-(60 * 60))).toEqual('about an hour ago'); + expect(timeAgoWithSecondOffset(+(60 * 60))).toEqual('about an hour from now'); + }); + + it('should parse a time as about 6 hours', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 6))).toEqual('about 6 hours ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 6))).toEqual('about 6 hours from now'); + }); + + it('should parse a time as a day', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 36))).toEqual('a day ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 36))).toEqual('a day from now'); + }); + + it('should parse a time as 25 days', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 25))).toEqual('25 days ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 25))).toEqual('25 days from now'); + }); + + it('should parse a time as about a month', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 35))).toEqual('about a month ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 35))).toEqual('about a month from now'); + }); + + it('should parse a time as 3 months', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 30 * 3))).toEqual('3 months ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 30 * 3))).toEqual('3 months from now'); + }); + + it('should parse a time as about a year', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 30 * 13))).toEqual('about a year ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 30 * 13))).toEqual('about a year from now'); + }); + + it('should parse a time as 5 years', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 30 * 12 * 5))).toEqual('5 years ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 30 * 12 * 5))).toEqual('5 years from now'); + }); + /* eslint-enable no-implicit-coercion */ + + it('should ignore time elements without a datetime attribute', () => { + const timeEl = document.createElement('time'); + const value = Math.random().toString(); + + timeEl.textContent = value; + timeAgo([timeEl]); + + expect(timeEl.textContent).toEqual(value); + }); + + it('should not reset title attribute if it already exists', () => { + const timeEl = document.createElement('time'); + const value = Math.random().toString(); + + timeEl.setAttribute('datetime', epochRfc3339); + timeEl.setAttribute('title', value); + timeAgo([timeEl]); + + expect(timeEl.getAttribute('title')).toEqual(value); + expect(timeEl.textContent).not.toEqual(epochRfc3339); + }); +}); + +describe('Automatic timestamps', () => { + it('should process all timestamps in the document', () => { + for (let i = 0; i < 5; i += 1) { + const timeEl = document.createElement('time'); + timeEl.setAttribute('datetime', epochRfc3339); + timeEl.textContent = epochRfc3339; + + document.documentElement.insertAdjacentElement('beforeend', timeEl); + } + + setupTimestamps(); + + for (const timeEl of document.getElementsByTagName('time')) { + expect(timeEl.textContent).not.toEqual(epochRfc3339); + } + }); +}); diff --git a/assets/js/comment.js b/assets/js/comment.js index f7b3797e..de245fa8 100644 --- a/assets/js/comment.js +++ b/assets/js/comment.js @@ -6,6 +6,7 @@ import { $ } from './utils/dom'; import { showOwnedComments } from './communications/comment'; import { filterNode } from './imagesclientside'; import { fetchHtml } from './utils/requests'; +import { timeAgo } from './timeago'; function handleError(response) { @@ -91,7 +92,7 @@ function insertParentPost(data, clickedLink, fullComment) { fullComment.previousSibling.classList.add('fetched-comment'); // Execute timeago on the new comment - it was not present when first run - window.booru.timeAgo(fullComment.previousSibling.getElementsByTagName('time')); + timeAgo(fullComment.previousSibling.getElementsByTagName('time')); // Add class active_reply_link to the clicked link clickedLink.classList.add('active_reply_link'); @@ -125,7 +126,7 @@ function displayComments(container, commentsHtml) { container.innerHTML = commentsHtml; // Execute timeago on comments - window.booru.timeAgo(document.getElementsByTagName('time')); + timeAgo(document.getElementsByTagName('time')); // Filter images in the comments filterNode(container); diff --git a/assets/js/timeago.js b/assets/js/timeago.ts similarity index 67% rename from assets/js/timeago.js rename to assets/js/timeago.ts index f3694d6f..12f39bd9 100644 --- a/assets/js/timeago.js +++ b/assets/js/timeago.ts @@ -2,7 +2,9 @@ * Frontend timestamps. */ -const strings = { +import { assertNotNull } from './utils/assert'; + +const strings: Record = { seconds: 'less than a minute', minute: 'about a minute', minutes: '%d minutes', @@ -16,16 +18,21 @@ const strings = { years: '%d years', }; -function distance(time) { - return new Date() - time; +function distance(time: Date) { + return new Date().getTime() - time.getTime(); } -function substitute(key, amount) { - return strings[key].replace('%d', Math.round(amount)); +function substitute(key: string, amount: number) { + return strings[key].replace('%d', Math.round(amount).toString()); } -function setTimeAgo(el) { - const date = new Date(el.getAttribute('datetime')); +function setTimeAgo(el: HTMLTimeElement) { + const datetime = el.getAttribute('datetime'); + if (!datetime) { + return; + } + + const date = new Date(datetime); const distMillis = distance(date); const seconds = Math.abs(distMillis) / 1000, @@ -49,20 +56,20 @@ function setTimeAgo(el) { substitute('years', years); if (!el.getAttribute('title')) { - el.setAttribute('title', el.textContent); + el.setAttribute('title', assertNotNull(el.textContent)); } el.textContent = words + (distMillis < 0 ? ' from now' : ' ago'); } -function timeAgo(args) { - [].forEach.call(args, el => setTimeAgo(el)); +export function timeAgo(args: HTMLTimeElement[] | HTMLCollectionOf) { + for (const el of args) { + setTimeAgo(el); + } } -function setupTimestamps() { +export function setupTimestamps() { timeAgo(document.getElementsByTagName('time')); window.setTimeout(setupTimestamps, 60000); } -export { setupTimestamps }; - window.booru.timeAgo = timeAgo; diff --git a/assets/test/jest-setup.ts b/assets/test/jest-setup.ts index 82c545f8..29779672 100644 --- a/assets/test/jest-setup.ts +++ b/assets/test/jest-setup.ts @@ -2,6 +2,8 @@ import '@testing-library/jest-dom'; import { matchNone } from '../js/query/boolean'; window.booru = { + // eslint-disable-next-line @typescript-eslint/no-empty-function + timeAgo: () => {}, csrfToken: 'mockCsrfToken', hiddenTag: '/mock-tagblocked.svg', hiddenTagList: [], diff --git a/assets/types/booru-object.d.ts b/assets/types/booru-object.d.ts index b4aead08..22d1aa08 100644 --- a/assets/types/booru-object.d.ts +++ b/assets/types/booru-object.d.ts @@ -13,6 +13,13 @@ interface Interaction { } interface BooruObject { + /** + * Automatic timestamp recalculation function for userscript use + */ + timeAgo: (args: HTMLTimeElement[]) => void; + /** + * Anti-forgery token sent by the server + */ csrfToken: string; /** * One of the specified values, based on user setting