From 078957941ba2f2304531033f52bb16c13706b2f6 Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 11 Apr 2024 23:06:05 -0400 Subject: [PATCH 1/5] docker compose --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 58037646c..2cf81be3a 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ![Philomena](/assets/static/images/phoenix.svg) ## Getting started -On systems with `docker` and `docker-compose` installed, the process should be as simple as: +On systems with `docker` and `docker compose` installed, the process should be as simple as: ``` -docker-compose build -docker-compose up +docker compose build +docker compose up ``` If you use `podman` and `podman-compose` instead, the process for constructing a rootless container is nearly identical: From c8f9e7e56503ee3227f7a66e50a6a8d185919898 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 Apr 2024 08:38:31 -0400 Subject: [PATCH 2/5] Resolve jest config deprecations --- assets/jest.config.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/assets/jest.config.js b/assets/jest.config.js index 192b61fd2..32c0a3344 100644 --- a/assets/jest.config.js +++ b/assets/jest.config.js @@ -30,12 +30,13 @@ export default { moduleNameMapper: { './js/(.*)': '/js/$1', }, - transform: {}, - globals: { - extensionsToTreatAsEsm: ['.ts', '.js'], - 'ts-jest': { + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.json', useESM: true, - }, + }] }, + globals: { + extensionsToTreatAsEsm: ['.ts', '.js'], + } }; From 394c23893c61425a33f0fa678a52778ca9a0b162 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 15 Apr 2024 20:36:57 -0400 Subject: [PATCH 3/5] Fix leftover jsdoc --- assets/js/utils/local-autocompleter.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/assets/js/utils/local-autocompleter.ts b/assets/js/utils/local-autocompleter.ts index 1e8b17e67..73d88f921 100644 --- a/assets/js/utils/local-autocompleter.ts +++ b/assets/js/utils/local-autocompleter.ts @@ -63,8 +63,7 @@ export class LocalAutocompleter { const nameLength = this.view.getUint8(location); const assnLength = this.view.getUint8(location + 1 + nameLength); - /** @type {number[]} */ - const associations = []; + const associations: number[] = []; const name = this.decoder.decode(this.data.slice(location + 1, location + nameLength + 1)); for (let i = 0; i < assnLength; i++) { From 3cba72ec4cfecde8e3803b17eef1936d44344739 Mon Sep 17 00:00:00 2001 From: liamwhite Date: Tue, 16 Apr 2024 08:02:03 -0400 Subject: [PATCH 4/5] ujs: migrate to TypeScript (#225) * ujs: migrate to TypeScript * Address review comments --- assets/js/__tests__/ujs.spec.ts | 335 ++++++++++++++++++++++++++++++++ assets/js/{ujs.js => ujs.ts} | 40 ++-- 2 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 assets/js/__tests__/ujs.spec.ts rename assets/js/{ujs.js => ujs.ts} (68%) diff --git a/assets/js/__tests__/ujs.spec.ts b/assets/js/__tests__/ujs.spec.ts new file mode 100644 index 000000000..142e47c0f --- /dev/null +++ b/assets/js/__tests__/ujs.spec.ts @@ -0,0 +1,335 @@ +import fetchMock from 'jest-fetch-mock'; +import { fireEvent } from '@testing-library/dom'; +import { assertType } from '../utils/assert'; +import '../ujs'; + +const mockEndpoint = 'http://localhost/endpoint'; +const mockVerb = 'POST'; + +describe('Remote utilities', () => { + beforeAll(() => { + fetchMock.enableMocks(); + }); + + afterAll(() => { + fetchMock.disableMocks(); + }); + + beforeEach(() => { + window.booru.csrfToken = Math.random().toString(); + fetchMock.resetMocks(); + }); + + function addOneShotEventListener(name: string, cb: (e: Event) => void) { + const handler = (event: Event) => { + cb(event); + document.removeEventListener(name, handler); + }; + document.addEventListener(name, handler); + } + + describe('a[data-remote]', () => { + const submitA = ({ setMethod }: { setMethod: boolean; }) => { + const a = document.createElement('a'); + a.href = mockEndpoint; + a.dataset.remote = 'remote'; + if (setMethod) { + a.dataset.method = mockVerb; + } + + document.documentElement.insertAdjacentElement('beforeend', a); + a.click(); + + return a; + }; + + it('should call native fetch with the correct parameters (without body)', () => { + submitA({ setMethod: true }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { + method: mockVerb, + credentials: 'same-origin', + headers: { + 'x-csrf-token': window.booru.csrfToken, + 'x-requested-with': 'XMLHttpRequest' + } + }); + }); + + it('should call native fetch for a get request without explicit method', () => { + submitA({ setMethod: false }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'x-csrf-token': window.booru.csrfToken, + 'x-requested-with': 'XMLHttpRequest' + } + }); + }); + + it('should emit fetchcomplete event', () => new Promise(resolve => { + let a: HTMLAnchorElement | null = null; + + addOneShotEventListener('fetchcomplete', event => { + expect(event.target).toBe(a); + resolve(); + }); + + a = submitA({ setMethod: true }); + })); + }); + + describe('a[data-method]', () => { + const submitA = () => { + const a = document.createElement('a'); + a.href = mockEndpoint; + a.dataset.method = mockVerb; + + document.documentElement.insertAdjacentElement('beforeend', a); + a.click(); + + return a; + }; + + it('should submit a form with the given action', () => new Promise(resolve => { + addOneShotEventListener('submit', event => { + event.preventDefault(); + + const target = assertType(event.target, HTMLFormElement); + const [ csrf, method ] = target.querySelectorAll('input'); + + expect(csrf.name).toBe('_csrf_token'); + expect(csrf.value).toBe(window.booru.csrfToken); + + expect(method.name).toBe('_method'); + expect(method.value).toBe(mockVerb); + + resolve(); + }); + + submitA(); + })); + }); + + describe('form[data-remote]', () => { + // https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/ + let oldWindowLocation: Location; + + beforeAll(() => { + oldWindowLocation = window.location; + delete (window as any).location; + + (window as any).location = Object.defineProperties( + {}, + { + ...Object.getOwnPropertyDescriptors(oldWindowLocation), + reload: { + configurable: true, + value: jest.fn(), + }, + }, + ); + }); + + beforeEach(() => { + (window.location.reload as any).mockReset(); + }); + + afterAll(() => { + // restore window.location to the jsdom Location object + window.location = oldWindowLocation; + }); + + const configureForm = () => { + const form = document.createElement('form'); + form.action = mockEndpoint; + form.dataset.remote = 'remote'; + document.documentElement.insertAdjacentElement('beforeend', form); + return form; + }; + + const submitForm = () => { + const form = configureForm(); + form.method = mockVerb; + form.submit(); + return form; + }; + + it('should call native fetch with the correct parameters (with body)', () => { + submitForm(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { + method: mockVerb, + credentials: 'same-origin', + headers: { + 'x-csrf-token': window.booru.csrfToken, + 'x-requested-with': 'XMLHttpRequest' + }, + body: new FormData(), + }); + }); + + it('should submit a PUT request with put data-method specified', () => { + const form = configureForm(); + form.dataset.method = 'put'; + form.submit(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'x-csrf-token': window.booru.csrfToken, + 'x-requested-with': 'XMLHttpRequest' + }, + body: new FormData(), + }); + }); + + it('should emit fetchcomplete event', () => new Promise(resolve => { + let form: HTMLFormElement | null = null; + + addOneShotEventListener('fetchcomplete', event => { + expect(event.target).toBe(form); + resolve(); + }); + + form = submitForm(); + })); + + it('should reload the page on 300 multiple choices response', () => { + const promiseLike = { + then(cb: (r: Response) => void) { + if (cb) { + cb(new Response('', { status: 300 })); + } + } + }; + + jest.spyOn(global, 'fetch').mockReturnValue(promiseLike as any); + + submitForm(); + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('Form utilities', () => { + beforeEach(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => { + cb(1); + return 1; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('[data-confirm]', () => { + const createA = () => { + const a = document.createElement('a'); + a.dataset.confirm = 'confirm'; + a.href = mockEndpoint; + document.documentElement.insertAdjacentElement('beforeend', a); + return a; + }; + + it('should cancel the event on failed confirm', () => { + const a = createA(); + const confirm = jest.spyOn(window, 'confirm').mockImplementationOnce(() => false); + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + + expect(fireEvent(a, event)).toBe(false); + expect(confirm).toHaveBeenCalledTimes(1); + }); + + it('should allow the event on confirm', () => { + const a = createA(); + const confirm = jest.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + + expect(fireEvent(a, event)).toBe(true); + expect(confirm).toHaveBeenCalledTimes(1); + }); + }); + + describe('[data-disable-with][data-enable-with]', () => { + const createFormAndButton = (innerHTML: string, disableWith: string) => { + const form = document.createElement('form'); + form.action = mockEndpoint; + + // jsdom has no implementation for HTMLFormElement.prototype.submit + // and will return an error if the event's default isn't prevented + form.addEventListener('submit', event => event.preventDefault()); + + const button = document.createElement('button'); + button.type = 'submit'; + button.innerHTML = innerHTML; + button.dataset.disableWith = disableWith; + + form.insertAdjacentElement('beforeend', button); + document.documentElement.insertAdjacentElement('beforeend', form); + + return [ form, button ]; + }; + + const submitText = 'Submit'; + const loadingText = 'Loading...'; + const submitMarkup = 'Submit'; + const loadingMarkup = 'Loading...'; + + it('should disable submit button containing a text child on click', () => { + const [ , button ] = createFormAndButton(submitText, loadingText); + button.click(); + + expect(button.textContent).toEqual(' Loading...'); + expect(button.dataset.enableWith).toEqual(submitText); + }); + + it('should disable submit button containing element children on click', () => { + const [ , button ] = createFormAndButton(submitMarkup, loadingMarkup); + button.click(); + + expect(button.innerHTML).toEqual(loadingMarkup); + expect(button.dataset.enableWith).toEqual(submitMarkup); + }); + + it('should not disable anything when the form is invalid', () => { + const [ form, button ] = createFormAndButton(submitText, loadingText); + form.insertAdjacentHTML('afterbegin', ''); + button.click(); + + expect(button.textContent).toEqual(submitText); + expect(button.dataset.enableWith).not.toBeDefined(); + }); + + it('should reset submit button containing a text child on completion', () => { + const [ form, button ] = createFormAndButton(submitText, loadingText); + button.click(); + fireEvent(form, new CustomEvent('reset', { bubbles: true })); + + expect(button.textContent?.trim()).toEqual(submitText); + expect(button.dataset.enableWith).not.toBeDefined(); + }); + + it('should reset submit button containing element children on completion', () => { + const [ form, button ] = createFormAndButton(submitMarkup, loadingMarkup); + button.click(); + fireEvent(form, new CustomEvent('reset', { bubbles: true })); + + expect(button.innerHTML).toEqual(submitMarkup); + expect(button.dataset.enableWith).not.toBeDefined(); + }); + + it('should reset disabled form elements on pageshow', () => { + const [ , button ] = createFormAndButton(submitText, loadingText); + button.click(); + fireEvent(window, new CustomEvent('pageshow')); + + expect(button.textContent?.trim()).toEqual(submitText); + expect(button.dataset.enableWith).not.toBeDefined(); + }); + }); +}); diff --git a/assets/js/ujs.js b/assets/js/ujs.ts similarity index 68% rename from assets/js/ujs.js rename to assets/js/ujs.ts index d8598d1cb..413bc6cb5 100644 --- a/assets/js/ujs.js +++ b/assets/js/ujs.ts @@ -1,3 +1,4 @@ +import { assertNotNull, assertNotUndefined } from './utils/assert'; import { $$, makeEl, findFirstTextNode } from './utils/dom'; import { fire, delegate, leftClick } from './utils/events'; @@ -6,7 +7,7 @@ const headers = () => ({ 'x-requested-with': 'XMLHttpRequest' }); -function confirm(event, target) { +function confirm(event: Event, target: HTMLElement) { if (!window.confirm(target.dataset.confirm)) { event.preventDefault(); event.stopImmediatePropagation(); @@ -14,28 +15,28 @@ function confirm(event, target) { } } -function disable(event, target) { +function disable(event: Event, target: HTMLAnchorElement | HTMLButtonElement | HTMLInputElement) { // failed validations prevent the form from being submitted; // stop here or the form will be permanently locked if (target.type === 'submit' && target.closest(':invalid') !== null) return; // Store what's already there so we don't lose it - const label = findFirstTextNode(target); + const label = findFirstTextNode(target); if (label) { - target.dataset.enableWith = label.nodeValue; + target.dataset.enableWith = assertNotNull(label.nodeValue); label.nodeValue = ` ${target.dataset.disableWith}`; } else { target.dataset.enableWith = target.innerHTML; - target.innerHTML = target.dataset.disableWith; + target.innerHTML = assertNotUndefined(target.dataset.disableWith); } // delay is needed because Safari stops the submit if the button is immediately disabled - requestAnimationFrame(() => target.disabled = 'disabled'); + requestAnimationFrame(() => target.setAttribute('disabled', 'disabled')); } // you should use button_to instead of link_to[method]! -function linkMethod(event, target) { +function linkMethod(event: Event, target: HTMLAnchorElement) { event.preventDefault(); const form = makeEl('form', { action: target.href, method: 'POST' }); @@ -49,41 +50,42 @@ function linkMethod(event, target) { form.submit(); } -function formRemote(event, target) { +function formRemote(event: Event, target: HTMLFormElement) { event.preventDefault(); fetch(target.action, { credentials: 'same-origin', - method: (target.dataset.method || target.method || 'POST').toUpperCase(), + method: (target.dataset.method || target.method).toUpperCase(), headers: headers(), body: new FormData(target) }).then(response => { + fire(target, 'fetchcomplete', response); if (response && response.status === 300) { - window.location.reload(true); - return; + window.location.reload(); } - fire(target, 'fetchcomplete', response); }); } -function formReset(event, target) { - $$('[disabled][data-disable-with][data-enable-with]', target).forEach(input => { +function formReset(_event: Event | null, target: HTMLElement) { + $$('[disabled][data-disable-with][data-enable-with]', target).forEach(input => { const label = findFirstTextNode(input); if (label) { label.nodeValue = ` ${input.dataset.enableWith}`; } - else { input.innerHTML = target.dataset.enableWith; } + else { + input.innerHTML = assertNotUndefined(input.dataset.enableWith); + } delete input.dataset.enableWith; input.removeAttribute('disabled'); }); } -function linkRemote(event, target) { +function linkRemote(event: Event, target: HTMLAnchorElement) { event.preventDefault(); fetch(target.href, { credentials: 'same-origin', - method: target.dataset.method.toUpperCase(), + method: (target.dataset.method || 'get').toUpperCase(), headers: headers() }).then(response => fire(target, 'fetchcomplete', response) @@ -106,5 +108,7 @@ delegate(document, 'reset', { }); window.addEventListener('pageshow', () => { - [].forEach.call(document.forms, form => formReset(null, form)); + for (const form of document.forms) { + formReset(null, form); + } }); From 33a713310aafa4a3191e3e4fe5539f87f824d695 Mon Sep 17 00:00:00 2001 From: liamwhite Date: Tue, 16 Apr 2024 08:02:12 -0400 Subject: [PATCH 5/5] timeago: migrate to TypeScript (#226) --- assets/js/__tests__/timeago.spec.ts | 114 +++++++++++++++++++++++++++ assets/js/comment.js | 5 +- assets/js/{timeago.js => timeago.ts} | 33 +++++--- assets/test/jest-setup.ts | 2 + assets/types/booru-object.d.ts | 7 ++ 5 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 assets/js/__tests__/timeago.spec.ts rename assets/js/{timeago.js => timeago.ts} (67%) diff --git a/assets/js/__tests__/timeago.spec.ts b/assets/js/__tests__/timeago.spec.ts new file mode 100644 index 000000000..e69e27025 --- /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 f7b3797ee..de245fa8d 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 f3694d6fa..12f39bd9f 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 82c545f8f..297796728 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 b4aead083..22d1aa08a 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