diff --git a/app/assets/javascripts/blame/streaming/index.js b/app/assets/javascripts/blame/streaming/index.js index 935343cca2e3459c52bcc250c4f96801a957ecd3..a88ef1c3e2194f1ee32feaf917b7d571fb5493fb 100644 --- a/app/assets/javascripts/blame/streaming/index.js +++ b/app/assets/javascripts/blame/streaming/index.js @@ -1,5 +1,6 @@ import { renderHtmlStreams } from '~/streaming/render_html_streams'; import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link'; +import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests'; @@ -11,6 +12,7 @@ export async function renderBlamePageStreams(firstStreamPromise) { if (!element || !firstStreamPromise) return; const stopAnchorObserver = handleStreamedAnchorLink(element); + const relativeTimestampsHandler = handleStreamedRelativeTimestamps(element); const { dataset } = document.querySelector('#blob-content-holder'); const totalExtraPages = parseInt(dataset.totalExtraPages, 10); const { pagesUrl } = dataset; @@ -50,6 +52,8 @@ export async function renderBlamePageStreams(firstStreamPromise) { }); throw error; } finally { + const stopTimestampObserver = await relativeTimestampsHandler; + stopTimestampObserver(); stopAnchorObserver(); document.querySelector('#blame-stream-loading').remove(); } diff --git a/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js b/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js new file mode 100644 index 0000000000000000000000000000000000000000..fa5fe02878c9fa61d1d3ce7a5dea94479efba23e --- /dev/null +++ b/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js @@ -0,0 +1,80 @@ +import { localTimeAgo } from '~/lib/utils/datetime_utility'; + +const STREAMING_ELEMENT_NAME = 'streaming-element'; +const TIME_AGO_CLASS_NAME = 'js-timeago'; + +// Callback handler for intersections observed on timestamps. +const handleTimestampsIntersecting = (entries, observer) => { + entries.forEach((entry) => { + const { isIntersecting, target: timestamp } = entry; + if (isIntersecting) { + localTimeAgo([timestamp]); + observer.unobserve(timestamp); + } + }); +}; + +// Finds nodes containing the `js-timeago` class within a mutation list. +const findTimeAgoNodes = (mutationList) => { + return mutationList.reduce((acc, mutation) => { + [...mutation.addedNodes].forEach((node) => { + if (node.classList?.contains(TIME_AGO_CLASS_NAME)) { + acc.push(node); + } + }); + + return acc; + }, []); +}; + +// Callback handler for mutations observed on the streaming element. +const handleStreamingElementMutation = (mutationList) => { + const timestamps = findTimeAgoNodes(mutationList); + const timestampIntersectionObserver = new IntersectionObserver(handleTimestampsIntersecting, { + rootMargin: `${window.innerHeight}px 0px`, + }); + + timestamps.forEach((timestamp) => timestampIntersectionObserver.observe(timestamp)); +}; + +// Finds the streaming element within a mutation list. +const findStreamingElement = (mutationList) => + mutationList.find((mutation) => + [...mutation.addedNodes].find((node) => node.localName === STREAMING_ELEMENT_NAME), + )?.target; + +// Waits for the streaming element to become available on the rootElement. +const waitForStreamingElement = (rootElement) => { + return new Promise((resolve) => { + let element = document.querySelector(STREAMING_ELEMENT_NAME); + + if (element) { + resolve(element); + return; + } + + const rootElementObserver = new MutationObserver((mutations) => { + element = findStreamingElement(mutations); + if (element) { + resolve(element); + rootElementObserver.disconnect(); + } + }); + + rootElementObserver.observe(rootElement, { childList: true, subtree: true }); + }); +}; + +/** + * Ensures relative (timeago) timestamps that are streamed are formatted correctly. + * + * Example: `May 12, 2020` → `3 years ago` + */ +export const handleStreamedRelativeTimestamps = async (rootElement) => { + const streamingElement = await waitForStreamingElement(rootElement); // wait for streaming to start + const streamingElementObserver = new MutationObserver(handleStreamingElementMutation); + + streamingElementObserver.observe(streamingElement, { childList: true, subtree: true }); + + return () => streamingElementObserver.disconnect(); +}; diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js index 8c9c435041e097c8a041a4741b96a04719775476..fd3945adfd89475989bcda847e19789f5d4bcf34 100644 --- a/spec/frontend/__helpers__/mock_dom_observer.js +++ b/spec/frontend/__helpers__/mock_dom_observer.js @@ -22,9 +22,9 @@ class MockObserver { takeRecords() {} - $_triggerObserve(node, { entry = {}, options = {} } = {}) { + $_triggerObserve(node, { entry = {}, observer = {}, options = {} } = {}) { if (this.$_hasObserver(node, options)) { - this.$_cb([{ target: node, ...entry }]); + this.$_cb([{ target: node, ...entry }], observer); } } diff --git a/spec/frontend/blame/streaming/index_spec.js b/spec/frontend/blame/streaming/index_spec.js index e048ce3f70e62234732face38163ec2494025002..29beb6beffa693ec5b8c0f1d86c7373312dacc3e 100644 --- a/spec/frontend/blame/streaming/index_spec.js +++ b/spec/frontend/blame/streaming/index_spec.js @@ -4,12 +4,14 @@ import { setHTMLFixture } from 'helpers/fixtures'; import { renderHtmlStreams } from '~/streaming/render_html_streams'; import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests'; import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link'; +import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps'; import { toPolyfillReadable } from '~/streaming/polyfills'; import { createAlert } from '~/alert'; jest.mock('~/streaming/render_html_streams'); jest.mock('~/streaming/rate_limit_stream_requests'); jest.mock('~/streaming/handle_streamed_anchor_link'); +jest.mock('~/streaming/handle_streamed_relative_timestamps'); jest.mock('~/streaming/polyfills'); jest.mock('~/sentry'); jest.mock('~/alert'); @@ -18,6 +20,7 @@ global.fetch = jest.fn(); describe('renderBlamePageStreams', () => { let stopAnchor; + let stopTimetamps; const PAGES_URL = 'https://example.com/'; const findStreamContainer = () => document.querySelector('#blame-stream-container'); const findStreamLoadingIndicator = () => document.querySelector('#blame-stream-loading'); @@ -34,6 +37,7 @@ describe('renderBlamePageStreams', () => { }; handleStreamedAnchorLink.mockImplementation(() => stopAnchor); + handleStreamedRelativeTimestamps.mockImplementation(() => Promise.resolve(stopTimetamps)); rateLimitStreamRequests.mockImplementation(({ factory, total }) => { return Array.from({ length: total }, (_, i) => { return Promise.resolve(factory(i)); @@ -43,6 +47,7 @@ describe('renderBlamePageStreams', () => { beforeEach(() => { stopAnchor = jest.fn(); + stopTimetamps = jest.fn(); fetch.mockClear(); }); @@ -50,6 +55,7 @@ describe('renderBlamePageStreams', () => { await renderBlamePageStreams(); expect(handleStreamedAnchorLink).not.toHaveBeenCalled(); + expect(handleStreamedRelativeTimestamps).not.toHaveBeenCalled(); expect(renderHtmlStreams).not.toHaveBeenCalled(); }); @@ -64,7 +70,9 @@ describe('renderBlamePageStreams', () => { renderBlamePageStreams(stream); expect(handleStreamedAnchorLink).toHaveBeenCalledTimes(1); + expect(handleStreamedRelativeTimestamps).toHaveBeenCalledTimes(1); expect(stopAnchor).toHaveBeenCalledTimes(0); + expect(stopTimetamps).toHaveBeenCalledTimes(0); expect(renderHtmlStreams).toHaveBeenCalledWith([stream], findStreamContainer()); expect(findStreamLoadingIndicator()).not.toBe(null); @@ -72,6 +80,7 @@ describe('renderBlamePageStreams', () => { await waitForPromises(); expect(stopAnchor).toHaveBeenCalledTimes(1); + expect(stopTimetamps).toHaveBeenCalledTimes(1); expect(findStreamLoadingIndicator()).toBe(null); }); diff --git a/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js b/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..12bd27488b1aee8aadf6335688ecc27c21fe33ec --- /dev/null +++ b/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js @@ -0,0 +1,94 @@ +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; +import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps'; +import { localTimeAgo } from '~/lib/utils/datetime_utility'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; + +jest.mock('~/lib/utils/datetime_utility'); + +const TIMESTAMP_MOCK = `<div class="js-timeago">Oct 2, 2019</div>`; + +describe('handleStreamedRelativeTimestamps', () => { + const findRoot = () => document.querySelector('#root'); + const findStreamingElement = () => document.querySelector('streaming-element'); + const findTimestamp = () => document.querySelector('.js-timeago'); + + afterEach(() => { + resetHTMLFixture(); + }); + + describe('when element is present', () => { + beforeEach(() => { + setHTMLFixture(`<div id="root">${TIMESTAMP_MOCK}</div>`); + handleStreamedRelativeTimestamps(findRoot()); + }); + + it('does nothing', async () => { + await waitForPromises(); + expect(localTimeAgo).not.toHaveBeenCalled(); + }); + }); + + describe('when element is streamed', () => { + let relativeTimestampsHandler; + const { trigger: triggerIntersection } = useMockIntersectionObserver(); + + const insertStreamingElement = () => + findRoot().insertAdjacentHTML('afterbegin', `<streaming-element></streaming-element>`); + + beforeEach(() => { + setHTMLFixture('<div id="root"></div>'); + relativeTimestampsHandler = handleStreamedRelativeTimestamps(findRoot()); + }); + + it('formats and unobserved the timestamp when inserted and intersecting', async () => { + insertStreamingElement(); + await waitForPromises(); + findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK); + await waitForPromises(); + + const timestamp = findTimestamp(); + const unobserveMock = jest.fn(); + + triggerIntersection(findTimestamp(), { + entry: { isIntersecting: true }, + observer: { unobserve: unobserveMock }, + }); + + expect(unobserveMock).toHaveBeenCalled(); + expect(localTimeAgo).toHaveBeenCalledWith([timestamp]); + }); + + it('does not format the timestamp when inserted but not intersecting', async () => { + insertStreamingElement(); + await waitForPromises(); + findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK); + await waitForPromises(); + + const unobserveMock = jest.fn(); + + triggerIntersection(findTimestamp(), { + entry: { isIntersecting: false }, + observer: { unobserve: unobserveMock }, + }); + + expect(unobserveMock).not.toHaveBeenCalled(); + expect(localTimeAgo).not.toHaveBeenCalled(); + }); + + it('does not format the time when destroyed', async () => { + insertStreamingElement(); + + const stop = await relativeTimestampsHandler; + stop(); + + await waitForPromises(); + findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK); + await waitForPromises(); + + triggerIntersection(findTimestamp(), { entry: { isIntersecting: true } }); + + expect(localTimeAgo).not.toHaveBeenCalled(); + }); + }); +});