From 954b22995a44bf11bfcd5850b62e206e46ee2db9 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 5 Apr 2023 04:25:31 -0500 Subject: [PATCH] Add a way to select time of day (#139) - Fix https://github.com/matrix-org/matrix-public-archive/issues/7 - A URL with time looks like - `/r/too-many-messages-on-day:my.synapse.server/date/2022/11/16T23:59` - Or when more precision is required (seconds): `/r/too-many-messages-on-day:my.synapse.server/date/2022/11/16T23:59:59` - Add new custom time picker/scrubber (pictured below) with momentum scrubbing - Native built-in `` for easier picking if you prefer that and accessibility. - Uses localized time strings - Design inspired by Thiago Sanchez's *Time Zone Translate* concept, https://dribbble.com/shots/14590546-Time-Zone-Translate --- package.json | 4 +- public/css/styles.css | 282 ++- .../child-process-runner/child-fork-script.js | 1 - .../fetch-events-from-timestamp-backwards.js | 5 +- .../get-messages-response-from-event-id.js | 14 +- server/routes/room-routes.js | 469 +++-- shared/hydrogen-vm-render-script.js | 7 +- shared/lib/reference-values.js | 30 + shared/lib/timestamp-utilities.js | 101 + shared/lib/url-creator.js | 43 +- shared/viewmodels/ArchiveRoomViewModel.js | 107 +- shared/viewmodels/CalendarViewModel.js | 7 +- .../JumpToNextActivitySummaryTileViewModel.js | 15 +- ...pToPreviousActivitySummaryTileViewModel.js | 15 +- shared/viewmodels/TimeSelectorViewModel.js | 86 + shared/views/ArchiveRoomView.js | 28 +- shared/views/CalendarView.js | 16 +- .../JumpToNextActivitySummaryTileView.js | 1 - .../JumpToPreviousActivitySummaryTileView.js | 1 - shared/views/ModalView.js | 6 +- shared/views/RightPanelContentView.js | 25 +- shared/views/TimeSelectorView.js | 514 +++++ test/e2e-tests.js | 1704 ++++++++++++++--- test/shared/lib/timestamp-utilties-tests.js | 499 +++++ test/{lib => test-utils}/client-utils.js | 35 +- test/{lib => test-utils}/test-error.js | 0 26 files changed, 3491 insertions(+), 524 deletions(-) create mode 100644 shared/lib/reference-values.js create mode 100644 shared/lib/timestamp-utilities.js create mode 100644 shared/viewmodels/TimeSelectorViewModel.js create mode 100644 shared/views/TimeSelectorView.js create mode 100644 test/shared/lib/timestamp-utilties-tests.js rename test/{lib => test-utils}/client-utils.js (93%) rename test/{lib => test-utils}/test-error.js (100%) diff --git a/package.json b/package.json index d057718..6912989 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build": "node ./build/do-client-build.js", "start": "node server/server.js", "start-dev": "node server/start-dev.js", - "test": "npm run mocha -- test/e2e-tests.js --timeout 15000", - "test-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --interactive", + "test": "npm run mocha -- test/**/*-tests.js --timeout 15000", + "test-e2e-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --interactive", "nodemon": "nodemon", "vite": "vite", "mocha": "mocha", diff --git a/public/css/styles.css b/public/css/styles.css index 2b93f5d..078e858 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1,3 +1,10 @@ +:root { + --primary-access-color: #0dbd8b; + --primary-access-color-hover: #0a8f69; + + --text-color-rgb: 46, 47, 50; +} + /* apply a natural box layout model to all elements, but allowing components to change */ html { box-sizing: border-box; @@ -131,12 +138,17 @@ summary { } .RightPanelContentView { + overflow: auto; display: flex; flex-direction: column; justify-content: space-between; height: 100%; } +.RightPanelContentView_mainContent > * + * { + margin-top: 1em; +} + .RightPanelContentView_footer { padding-left: 16px; padding-right: 16px; @@ -270,18 +282,284 @@ summary { } .CalendarView_dayLink_active { - background-color: #0dbd8b; + background-color: var(--primary-access-color); color: #ffffff; } .CalendarView_dayLink_active:hover { - background-color: #0a8f69; + background-color: var(--primary-access-color-hover); } .CalendarView_dayLink_disabled { opacity: 0.5; } +.TimeSelectorView { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +.TimeSelectorView_header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 8px; +} + +.TimeSelectorView_primaryTimezoneLabel { + display: inline-block; + padding: 2px 12px; + + background-color: var(--primary-access-color); + /* Always make a pill shape */ + border-radius: 9999px; + + color: #ffffff; +} + +.TimeSelectorView_timeInput { + margin-top: 6px; + padding: 2px 18px; + + background: transparent; + border: 0; + /* Always make a pill shape */ + border-radius: 9999px; + + color: var(--primary-access-color); + font-family: inherit; + font-size: inherit; + font-weight: bold; +} + +/** + * The Chrome has a little clock icon that makes the whole input + * appear off-center since we don't have any border/background on it. These styles just + * scoot the time text over to appear centered regardless of the icon. + */ +@supports selector(::-webkit-calendar-picker-indicator) { + .TimeSelectorView_timeInput { + margin-right: calc(-20px - 6px - 8px); + } + + .TimeSelectorView_timeInput::-webkit-calendar-picker-indicator { + width: 20px; + padding: 3px; + margin-left: 8px; + } +} + +.TimeSelectorView_goAction { + position: absolute; + right: 0; + + display: inline-block; + padding: 4px 16px; + margin-right: 16px; + + background-color: var(--primary-access-color); + /* Always make a pill shape */ + border-radius: 9999px; + border: 0; + + color: #ffffff; + line-height: 24px; + text-decoration: none; + + cursor: pointer; +} + +.TimeSelectorView_goAction:hover { + background-color: var(--primary-access-color-hover); +} + +.TimeSelectorView_footer { + display: flex; + flex-direction: column; + align-items: center; + + margin-top: 16px; +} + +.TimeSelectorView_secondaryTime { + color: rgba(var(--text-color-rgb), 0.75); + font-weight: bold; +} + +.TimeSelectorView_secondaryTimezoneLabel { + display: inline-block; + margin-top: 6px; + + color: rgba(var(--text-color-rgb), 0.75); +} + +.TimeSelectorView_scrubber { + position: relative; + width: 100%; + max-width: 600px; +} + +/* Current position triangle indicator */ +.TimeSelectorView_scrubber::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 8px; + height: 32px; + + transform: translateX(-50%) translateY(-50%); + + /* Draw some pointer triangles at our current scroll position */ + /* prettier-ignore */ + background-image: + /* Arrow pointing down at the top */ + linear-gradient(to top right, transparent 50%, var(--primary-access-color) 0), + linear-gradient(to bottom right, var(--primary-access-color) 50%, transparent 0), + /* Arrow pointing up at the bottom */ + linear-gradient(to bottom right, transparent 50%, rgba(var(--text-color-rgb), 0.2) 0), + linear-gradient(to top right, rgba(var(--text-color-rgb), 0.2) 50%, transparent 0); + background-size: 50% 4px; + background-repeat: no-repeat; + /* prettier-ignore */ + background-position: + /* Arrow pointing down at the top */ + left top, right top, + /* Arrow pointing up at the bottom */ + left bottom, right bottom; + + /* This is just a visual thing, so don't mess with clicks or selection here */ + pointer-events: none; +} + +/* The magnifier highlights the time range of messages in the timeline on this page */ +.TimeSelectorView_magnifierBubble { + position: absolute; + top: 50%; + height: 24px; + + transform: translateY(-50%); + + /* Add slight highlight color to magnifier glass - Based on message highlight color */ + background-color: rgb(255, 255, 138, 0.1); + border-radius: 8px; + box-shadow: 0 2px 6px rgba(var(--text-color-rgb), 0.3); + + /* The magnifier is just a visual thing, so don't mess with clicks or selection here */ + pointer-events: none; +} + +.TimeSelectorView_scrubberScrollWrapper { + /** + * By having plain normal scrolling for this control, we get all the nice momentum + * scrolling that is native to peoples touch devices (Android, iOS, etc). We also + * emulate momentum scrolling for mouse click and dragging. + */ + overflow-x: auto; + /* Hide scrollbar in Firefox. We want scroll but no scrollbar so mobile and touchpad + people can still scroll */ + scrollbar-width: none; + + display: flex; + width: 100%; + cursor: grab; +} + +.TimeSelectorView_scrubberScrollWrapper::-webkit-scrollbar { + /* Hide scrollbar in Safari and Chrome. We want scroll but no scrollbar so mobile and touchpad + people can still scroll */ + display: none; +} + +.TimeSelectorView_scrubberScrollWrapper.is-dragging { + cursor: grabbing; + user-select: none; +} + +.TimeSelectorView_dial { + --tick-width: 1px; + --tick-major-height: 20px; + --tick-minor-height: 10px; + --tick-detail-height: 3px; + + position: relative; + display: flex; + height: 68px; + margin: 0; + padding: 0; + /* Allow the dial to scroll to the ends with the start/end tick being in the middle */ + margin: 0 50%; + + list-style: none; + + /* Draw some tick marks along the dial */ + /* prettier-ignore */ + background-image: + /* Ticks every hour */ + linear-gradient(90deg, currentcolor var(--tick-width), transparent 0), + /* Last hour tick */ + linear-gradient(90deg, currentcolor var(--tick-width), transparent 0), + /* Ticks every 10 mins */ + linear-gradient(90deg, currentcolor var(--tick-width), transparent 0), + /* Ticks every 5 mins */ + linear-gradient(90deg, rgba(var(--text-color-rgb), 0.4) var(--tick-width), transparent 0); + /* prettier-ignore */ + background-repeat: + /* Ticks every hour */ + repeat-x, + /* Last hour tick */ + no-repeat, + /* Ticks every 10 mins */ + repeat-x, + /* Ticks every 5 mins */ + repeat-x; + /* prettier-ignore */ + background-size: + /* Ticks every hour */ + 60px var(--tick-major-height), + /* Last hour tick */ + var(--tick-width) var(--tick-major-height), + /* Ticks every 10 mins */ + 10px var(--tick-minor-height), + /* Ticks every 5 mins */ + 5px var(--tick-detail-height); + /* prettier-ignore */ + background-position: + /* Ticks every hour */ + center left, + /* Last hour tick */ + center right, + /* Ticks every 10 mins */ + center left, + /* Ticks every 5 mins */ + center left; +} + +.TimeSelectorView_incrementLabel { + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + width: 60px; + margin: 0; + white-space: nowrap; + + font-size: 12px; + font-weight: bold; +} + +.TimeSelectorView_incrementLabelText, +.TimeSelectorView_incrementLabelTextSecondary { + transform: translateX(-50%); +} + +.TimeSelectorView_incrementLabelTextSecondary { + color: rgba(var(--text-color-rgb), 0.75); +} + /* Some custom timeline, tiles stuff */ .JumpToPreviousActivitySummaryTileView, diff --git a/server/child-process-runner/child-fork-script.js b/server/child-process-runner/child-fork-script.js index 4f55bb9..16c6578 100644 --- a/server/child-process-runner/child-fork-script.js +++ b/server/child-process-runner/child-fork-script.js @@ -45,7 +45,6 @@ async function serializeError(err) { // If we don't listen for these events, the child will exit with status code 1 // (error) when they occur. process.on('uncaughtException', async (err /*, origin*/) => { - console.log('2 uncaughtException', err); await serializeError(new RethrownError('uncaughtException in child process', err)); }); diff --git a/server/lib/matrix-utils/fetch-events-from-timestamp-backwards.js b/server/lib/matrix-utils/fetch-events-from-timestamp-backwards.js index 3943279..515f132 100644 --- a/server/lib/matrix-utils/fetch-events-from-timestamp-backwards.js +++ b/server/lib/matrix-utils/fetch-events-from-timestamp-backwards.js @@ -3,6 +3,7 @@ const assert = require('assert'); const { traceFunction } = require('../../tracing/trace-utilities'); +const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values'); const timestampToEvent = require('./timestamp-to-event'); const getMessagesResponseFromEventId = require('./get-messages-response-from-event-id'); @@ -40,7 +41,7 @@ async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limi accessToken, roomId, ts, - direction: 'b', + direction: DIRECTION.backward, }); eventIdForTimestamp = eventId; } catch (err) { @@ -67,7 +68,7 @@ async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limi eventId: eventIdForTimestamp, // We go backwards because that's the direction that backfills events (Synapse // doesn't backfill in the forward direction) - dir: 'b', + dir: DIRECTION.backward, limit, }); diff --git a/server/lib/matrix-utils/get-messages-response-from-event-id.js b/server/lib/matrix-utils/get-messages-response-from-event-id.js index 4d7cafa..d5d6c6b 100644 --- a/server/lib/matrix-utils/get-messages-response-from-event-id.js +++ b/server/lib/matrix-utils/get-messages-response-from-event-id.js @@ -3,6 +3,7 @@ const assert = require('assert'); const urlJoin = require('url-join'); +const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values'); const { fetchEndpointAsJson } = require('../fetch-endpoint'); const config = require('../config'); @@ -32,6 +33,17 @@ async function getMessagesResponseFromEventId({ accessToken, roomId, eventId, di accessToken, }); + // We want to re-paginte over the same event so it's included in the response. + // + // When going backwards, that means starting using the paginatin token after the event + // so we can see it looking backwards again. + let paginationToken = contextResData.end; + // When going forwards, that means starting using the paginatin token before the event + // so we can see it looking forwards again. + if (dir === DIRECTION.forward) { + paginationToken = contextResData.start; + } + // Add `filter={"lazy_load_members":true}` to only get member state events for // the messages included in the response const messagesEndpoint = urlJoin( @@ -39,7 +51,7 @@ async function getMessagesResponseFromEventId({ accessToken, roomId, eventId, di `_matrix/client/r0/rooms/${encodeURIComponent( roomId )}/messages?dir=${dir}&from=${encodeURIComponent( - contextResData.end + paginationToken )}&limit=${limit}&filter={"lazy_load_members":true}` ); const { data: messageResData } = await fetchEndpointAsJson(messagesEndpoint, { diff --git a/server/routes/room-routes.js b/server/routes/room-routes.js index 7e909ab..7dc457a 100644 --- a/server/routes/room-routes.js +++ b/server/routes/room-routes.js @@ -18,6 +18,26 @@ const timestampToEvent = require('../lib/matrix-utils/timestamp-to-event'); const getMessagesResponseFromEventId = require('../lib/matrix-utils/get-messages-response-from-event-id'); const renderHydrogenVmRenderScriptToPageHtml = require('../hydrogen-render/render-hydrogen-vm-render-script-to-page-html'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); +const { + MS_LOOKUP, + TIME_PRECISION_VALUES, + DIRECTION, +} = require('matrix-public-archive-shared/lib/reference-values'); +const { ONE_DAY_IN_MS, ONE_HOUR_IN_MS, ONE_MINUTE_IN_MS, ONE_SECOND_IN_MS } = MS_LOOKUP; +const { + roundUpTimestampToUtcDay, + roundUpTimestampToUtcHour, + roundUpTimestampToUtcMinute, + roundUpTimestampToUtcSecond, + getUtcStartOfDayTs, + getUtcStartOfHourTs, + getUtcStartOfMinuteTs, + getUtcStartOfSecondTs, + areTimestampsFromSameUtcDay, + areTimestampsFromSameUtcHour, + areTimestampsFromSameUtcMinute, + areTimestampsFromSameUtcSecond, +} = require('matrix-public-archive-shared/lib/timestamp-utilities'); const config = require('../lib/config'); const basePath = config.get('basePath'); @@ -43,6 +63,15 @@ const VALID_ENTITY_DESCRIPTOR_TO_SIGIL_MAP = { const validSigilList = Object.values(VALID_ENTITY_DESCRIPTOR_TO_SIGIL_MAP); const sigilRe = new RegExp(`^(${validSigilList.join('|')})`); +function getErrorStringForTooManyMessages(archiveMessageLimit) { + const message = + `Too many messages were sent all within a second for us to display ` + + `(more than ${archiveMessageLimit} in one second). We're unable to redirect you to ` + + `a smaller time range to view them without losing a few between each page. ` + + `Since this is probably pretty rare, we've decided not to support it for now.`; + return message; +} + function getRoomIdOrAliasFromReq(req) { const entityDescriptor = req.params.entityDescriptor; // This could be with or with our without the sigil. Although the correct thing here @@ -60,46 +89,73 @@ function getRoomIdOrAliasFromReq(req) { return `${sigil}${roomIdOrAliasWithoutSigil}`; } +// eslint-disable-next-line max-statements, complexity function parseArchiveRangeFromReq(req) { const yyyy = parseInt(req.params.yyyy, 10); // Month is the only zero-based index in this group const mm = parseInt(req.params.mm, 10) - 1; const dd = parseInt(req.params.dd, 10); - const hourRange = req.params.hourRange; + const timeString = req.params.time; + let timeInMs = 0; + let timeDefined = false; + let secondsDefined = false; + if (timeString) { + const timeMatches = timeString.match(/^T(\d\d?):(\d\d?)(?::(\d\d?))?$/); - let fromHour = 0; - let toHour = 0; - if (hourRange) { - const hourMatches = hourRange.match(/^(\d\d?)-(\d\d?)$/); - - if (!hourMatches) { - throw new StatusError(404, 'Hour was unable to be parsed'); + if (!timeMatches) { + throw new StatusError( + 404, + 'Time was unable to be parsed from URL. It should be in 24-hour format 23:59:59' + ); } - fromHour = parseInt(hourMatches[1], 10); - toHour = parseInt(hourMatches[2], 10); + const hour = timeMatches[1] && parseInt(timeMatches[1], 10); + const minute = timeMatches[2] && parseInt(timeMatches[2], 10); + const second = timeMatches[3] ? parseInt(timeMatches[3], 10) : 0; - if (Number.isNaN(fromHour) || fromHour < 0 || fromHour > 23) { - throw new StatusError(404, 'From hour can only be in range 0-23'); + timeDefined = !!timeMatches; + // Whether the timestamp included seconds + secondsDefined = !!timeMatches[3]; + + if (Number.isNaN(hour) || hour < 0 || hour > 23) { + throw new StatusError(404, `Hour can only be in range 0-23 -> ${hour}`); } + if (Number.isNaN(minute) || minute < 0 || minute > 59) { + throw new StatusError(404, `Minute can only be in range 0-59 -> ${minute}`); + } + if (Number.isNaN(second) || second < 0 || second > 59) { + throw new StatusError(404, `Second can only be in range 0-59 -> ${second}`); + } + + const hourInMs = hour * ONE_HOUR_IN_MS; + const minuteInMs = minute * ONE_MINUTE_IN_MS; + const secondInMs = second * ONE_SECOND_IN_MS; + + timeInMs = hourInMs + minuteInMs + secondInMs; } - const fromTimestamp = Date.UTC(yyyy, mm, dd, fromHour); - let toTimestamp = Date.UTC(yyyy, mm, dd + 1, fromHour); - if (hourRange) { - toTimestamp = Date.UTC(yyyy, mm, dd, toHour); + let toTimestamp; + if (timeInMs) { + const startOfDayTimestamp = Date.UTC(yyyy, mm, dd); + toTimestamp = startOfDayTimestamp + timeInMs; + } + // If no time specified, then we assume end-of-day + else { + // We `- 1` from UTC midnight to get the timestamp that is a millisecond before the + // next day T23:59:59.999 + toTimestamp = Date.UTC(yyyy, mm, dd + 1) - 1; } return { - fromTimestamp, toTimestamp, yyyy, mm, dd, - hourRange, - fromHour, - toHour, + // Whether the req included time `T23:59` + timeDefined, + // Whether the req included seconds in the time `T23:59:59` + secondsDefined, }; } @@ -119,12 +175,12 @@ router.get( // any of the additional room info or messages. const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via); - // Find the closest day to today with messages + // Find the closest day to the current time with messages const { originServerTs } = await timestampToEvent({ accessToken: matrixAccessToken, roomId, ts: dateBeforeJoin, - direction: 'b', + direction: DIRECTION.backward, }); if (!originServerTs) { throw new StatusError(404, 'Unable to find day with history'); @@ -153,33 +209,139 @@ router.get( router.get( '/jump', - // eslint-disable-next-line max-statements + // eslint-disable-next-line max-statements, complexity asyncHandler(async function (req, res) { const roomIdOrAlias = getRoomIdOrAliasFromReq(req); - const ts = parseInt(req.query.ts, 10); - assert(!Number.isNaN(ts), '?ts query parameter must be a number'); + const currentRangeStartTs = parseInt(req.query.currentRangeStartTs, 10); + assert( + !Number.isNaN(currentRangeStartTs), + '?currentRangeStartTs query parameter must be a number' + ); + const currentRangeEndTs = parseInt(req.query.currentRangeEndTs, 10); + assert(!Number.isNaN(currentRangeEndTs), '?currentRangeEndTs query parameter must be a number'); const dir = req.query.dir; - assert(['f', 'b'].includes(dir), '?dir query parameter must be [f|b]'); + assert( + [DIRECTION.forward, DIRECTION.backward].includes(dir), + '?dir query parameter must be [f|b]' + ); + + let ts; + if (dir === DIRECTION.backward) { + // We `- 1` so we don't jump to the same event because the endpoint is inclusive. + // + // XXX: This is probably an edge-case flaw when there could be multiple events at + // the same timestamp + ts = currentRangeStartTs - 1; + } else if (dir === DIRECTION.forward) { + // We `+ 1` so we don't jump to the same event because the endpoint is inclusive + // + // XXX: This is probably an edge-case flaw when there could be multiple events at + // the same timestamp + ts = currentRangeEndTs + 1; + } else { + throw new Error(`Unable to handle unknown dir=${dir} in /jump`); + } // We have to wait for the room join to happen first before we can use the jump to // date endpoint const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via); - let eventIdForTimestamp; - let originServerTs; + let eventIdForClosestEvent; + let tsForClosestEvent; + let newOriginServerTs; + let preferredPrecision = null; try { - // Find the closest day to today with messages - ({ eventId: eventIdForTimestamp, originServerTs } = await timestampToEvent({ - accessToken: matrixAccessToken, - roomId, - ts: ts, - direction: dir, - })); + // We pull this fresh from the config for each request to ensure we have an + // updated value between each e2e test + const archiveMessageLimit = config.get('archiveMessageLimit'); - // The goal is to go forward 100 messages, so that when we view the room at that - // point going backwards 100 messages, we end up at the perfect sam continuation - // spot in the room. + // Find the closest event to the given timestamp + ({ eventId: eventIdForClosestEvent, originServerTs: tsForClosestEvent } = + await timestampToEvent({ + accessToken: matrixAccessToken, + roomId, + ts: ts, + direction: dir, + })); + + // Based on what we found was the closest, figure out the URL that will represent + // the next chunk in the desired direction. + // ============================== + // + // When jumping backwards, since a given room archive URL represents the end of + // the day/time-period looking backward (scroll is also anchored to the bottom), + // we just need to get the user to the previous time-period. + // + // We are trying to avoid sending the user to the same time period they were just + // viewing. i.e, if they were visiting `/2020/01/02T16:00:00` (displays messages + // backwards from that time up to the limit), which had more messages than we + // could display in that day, jumping backwards from the earliest displayed event + // in the displayed range, say `T12:00:05` would still give us the same day + // `/2020/01/02` and we want to redirect them to previous chunk from that same + // day, like `/2020/01/02T12:00:00` + if (dir === DIRECTION.backward) { + const fromSameDay = + tsForClosestEvent && areTimestampsFromSameUtcDay(currentRangeEndTs, tsForClosestEvent); + const fromSameHour = + tsForClosestEvent && areTimestampsFromSameUtcHour(currentRangeEndTs, tsForClosestEvent); + const fromSameMinute = + tsForClosestEvent && areTimestampsFromSameUtcMinute(currentRangeEndTs, tsForClosestEvent); + const fromSameSecond = + tsForClosestEvent && areTimestampsFromSameUtcSecond(currentRangeEndTs, tsForClosestEvent); + + // The closest event is from the same second we tried to jump from. Since we + // can't represent something smaller than a second in the URL yet (we could do + // ms but it's a concious choice to make the URL cleaner, + // #support-ms-time-slice), we will need to just return the timestamp with a + // precision of seconds and hope that there isn't too many messages in this same + // second. + // + // XXX: If there is too many messages all within the same second, people will be + // stuck visiting the same page over and over every time they try to jump + // backwards from that range. + if (fromSameSecond) { + newOriginServerTs = tsForClosestEvent; + preferredPrecision = TIME_PRECISION_VALUES.seconds; + } + // The closest event is from the same minute we tried to jump from, we will need + // to round up to the nearest second so that the URL encompasses the closest + // event looking backwards + else if (fromSameMinute) { + newOriginServerTs = roundUpTimestampToUtcSecond(tsForClosestEvent); + preferredPrecision = TIME_PRECISION_VALUES.seconds; + } + // The closest event is from the same hour we tried to jump from, we will need + // to round up to the nearest minute so that the URL encompasses the closest + // event looking backwards + else if (fromSameHour) { + newOriginServerTs = roundUpTimestampToUtcMinute(tsForClosestEvent); + preferredPrecision = TIME_PRECISION_VALUES.minutes; + } + // The closest event is from the same day we tried to jump from, we will need to + // round up to the nearest hour so that the URL encompasses the closest event + // looking backwards + else if (fromSameDay) { + newOriginServerTs = roundUpTimestampToUtcHour(tsForClosestEvent); + preferredPrecision = TIME_PRECISION_VALUES.minutes; + } + // We don't need to do anything. The next closest event is far enough away + // (greater than 1 day) where we don't need to worry about the URL at all and + // can just render whatever day that the closest event is from because the + // archives biggest time-period represented in the URL is a day. + // + // We can display more than a day of content at a given URL (imagine lots of a + // quiet days in a room), but the URL will never represent a time-period + // greater than a day, ex. `/2023/01/01`. We don't allow someone to just + // specify the month like `/2023/01` ❌ + else { + newOriginServerTs = tsForClosestEvent; + } + } + // When jumping forwards, the goal is to go forward 100 messages, so that when we + // view the room at that point going backwards 100 messages (which is how the + // archive works for any given date from the archive URL), we end up at the + // perfect continuation spot in the room (seamless). // // XXX: This is flawed in the fact that when we go `/messages?dir=b` later, it // could backfill messages which will fill up the response before we perfectly @@ -187,22 +349,26 @@ router.get( // `/messages?dir=f` backfills, we won't have this problem anymore because any // messages backfilled in the forwards direction would be picked up the same going // backwards. - if (dir === 'f') { + if (dir === DIRECTION.forward) { // Use `/messages?dir=f` and get the `end` pagination token to paginate from. And // then start the scroll from the top of the page so they can continue. - const archiveMessageLimit = config.get('archiveMessageLimit'); + // + // XXX: It would be cool to somehow cache this response and re-use our work here + // for the actual room display that we redirect to from this route. No need for + // us go out 100 messages, only for us to go backwards 100 messages again in the + // next route. const messageResData = await getMessagesResponseFromEventId({ accessToken: matrixAccessToken, roomId, - eventId: eventIdForTimestamp, - dir: 'f', + eventId: eventIdForClosestEvent, + dir: DIRECTION.forward, limit: archiveMessageLimit, }); if (!messageResData.chunk?.length) { throw new StatusError( 404, - `/messages response didn't contain any more messages to jump to` + `/jump?dir=${dir}: /messages response didn't contain any more messages to jump to` ); } @@ -210,32 +376,84 @@ router.get( messageResData.chunk[messageResData.chunk.length - 1].origin_server_ts; const dateOfLastMessage = new Date(timestampOfLastMessage); - // Back track from the last message timestamp to the date boundary. This will - // gurantee some overlap with the previous page we jumped from so we don't lose - // any messages in the gap. + // Back-track from the last message timestamp to the nearest date boundary. + // Because we're back-tracking a couple events here, when we paginate back out + // by the `archiveMessageLimit` later in the room route, it will gurantee some + // overlap with the previous page we jumped from so we don't lose any messages + // in the gap. // - // XXX: This date boundary logic may need to change once we introduce hour - // chunks or time slices - // (https://github.com/matrix-org/matrix-public-archive/issues/7). For example - // if we reached into the next day but it has too many messages to show for a - // given page, we would want to back track until a suitable time slice boundary. - // Maybe we need to add a new URL parameter here `?time-slice=true` to indicate - // that it's okay to break it up by time slice based on previously having to - // view by time slice. We wouldn't want to give - const utcMidnightOfDayBefore = Date.UTC( - dateOfLastMessage.getUTCFullYear(), - dateOfLastMessage.getUTCMonth(), - dateOfLastMessage.getUTCDate() - ); - // We minus 1 from UTC midnight to get to the day before - const endOfDayBeforeDate = new Date(utcMidnightOfDayBefore - 1); + // We could choose to jump to the exact timestamp of the last message instead of + // back-tracking but then we get ugly URL's every time you jump instead of being + // able to back-track and round down to the nearest hour in a lot of cases. The + // other reason not to return the exact date is maybe there multiple messages at + // the same timestamp and we will lose messages in the gap it displays more than + // we thought. + const msGapFromJumpPointToLastMessage = timestampOfLastMessage - ts; + const moreThanDayGap = msGapFromJumpPointToLastMessage > ONE_DAY_IN_MS; + const moreThanHourGap = msGapFromJumpPointToLastMessage > ONE_HOUR_IN_MS; + const moreThanMinuteGap = msGapFromJumpPointToLastMessage > ONE_MINUTE_IN_MS; + const moreThanSecondGap = msGapFromJumpPointToLastMessage > ONE_SECOND_IN_MS; - originServerTs = endOfDayBeforeDate; + // If the `/messages` response returns less than the `archiveMessageLimit` + // looking forwards, it means we're looking at the latest events in the room. We + // can simply just display the day that the latest event occured on or given + // rangeEnd (whichever is later). + const haveReachedLatestMessagesInRoom = messageResData.chunk?.length < archiveMessageLimit; + if (haveReachedLatestMessagesInRoom) { + const latestDesiredTs = Math.max(currentRangeEndTs, timestampOfLastMessage); + const latestDesiredDate = new Date(latestDesiredTs); + const utcMidnightTs = getUtcStartOfDayTs(latestDesiredDate); + newOriginServerTs = utcMidnightTs; + preferredPrecision = TIME_PRECISION_VALUES.none; + } + // More than a day gap here, so we can just back-track to the nearest day + else if (moreThanDayGap) { + const utcMidnightOfDayBefore = getUtcStartOfDayTs(dateOfLastMessage); + // We `- 1` from UTC midnight to get the timestamp that is a millisecond + // before the next day but we choose a no time precision so we jump to just + // the bare date without a time. A bare date in the `/date/2022/12/16` + // endpoint represents the end of that day looking backwards so this is + // exactly what we want. + const endOfDayBeforeTs = utcMidnightOfDayBefore - 1; + newOriginServerTs = endOfDayBeforeTs; + preferredPrecision = TIME_PRECISION_VALUES.none; + } + // More than a hour gap here, we will need to back-track to the nearest hour + else if (moreThanHourGap) { + const utcTopOfHourBefore = getUtcStartOfHourTs(dateOfLastMessage); + newOriginServerTs = utcTopOfHourBefore; + preferredPrecision = TIME_PRECISION_VALUES.minutes; + } + // More than a minute gap here, we will need to back-track to the nearest minute + else if (moreThanMinuteGap) { + const utcTopOfMinuteBefore = getUtcStartOfMinuteTs(dateOfLastMessage); + newOriginServerTs = utcTopOfMinuteBefore; + preferredPrecision = TIME_PRECISION_VALUES.minutes; + } + // More than a second gap here, we will need to back-track to the nearest second + else if (moreThanSecondGap) { + const utcTopOfSecondBefore = getUtcStartOfSecondTs(dateOfLastMessage); + newOriginServerTs = utcTopOfSecondBefore; + preferredPrecision = TIME_PRECISION_VALUES.seconds; + } + // Less than a second gap here, we will give up. + // + // XXX: Maybe we can support ms here (#support-ms-time-slice) + else { + // 501 Not Implemented: the server does not support the functionality required + // to fulfill the request + res.status(501); + res.send( + `/jump ran into a problem: ${getErrorStringForTooManyMessages(archiveMessageLimit)}` + ); + return; + } } } catch (err) { const is404Error = err instanceof HTTPResponseError && err.response.status === 404; // Only throw if it's something other than a 404 error. 404 errors are fine, they - // just mean there is no more messages to paginate in that room. + // just mean there is no more messages to paginate in that room and we were + // already viewing the latest in the room. if (!is404Error) { throw err; } @@ -244,37 +462,50 @@ router.get( // If we can't find any more messages to paginate to, just progress the date by a // day in whatever direction they wanted to go so we can display the empty view for // that day. - if (!originServerTs) { - const tsDate = new Date(ts); - const yyyy = tsDate.getUTCFullYear(); - const mm = tsDate.getUTCMonth(); - const dd = tsDate.getUTCDate(); + if (!newOriginServerTs) { + let tsAtRangeBoundaryInDirection; + if (dir === DIRECTION.backward) { + tsAtRangeBoundaryInDirection = currentRangeStartTs; + } else if (dir === DIRECTION.forward) { + tsAtRangeBoundaryInDirection = currentRangeEndTs; + } - const newDayDelta = dir === 'f' ? 1 : -1; - originServerTs = Date.UTC(yyyy, mm, dd + newDayDelta); + const dateAtRangeBoundaryInDirection = new Date(tsAtRangeBoundaryInDirection); + const yyyy = dateAtRangeBoundaryInDirection.getUTCFullYear(); + const mm = dateAtRangeBoundaryInDirection.getUTCMonth(); + const dd = dateAtRangeBoundaryInDirection.getUTCDate(); + + const newDayDelta = dir === DIRECTION.forward ? 1 : -1; + newOriginServerTs = Date.UTC(yyyy, mm, dd + newDayDelta); } // Redirect to a day with messages - res.redirect( - // TODO: Add query parameter that causes the client to start the scroll at the top - // when jumping forwards so they can continue reading where they left off. - matrixPublicArchiveURLCreator.archiveUrlForDate(roomIdOrAlias, new Date(originServerTs), { + const archiveUrlToRedirecTo = matrixPublicArchiveURLCreator.archiveUrlForDate( + roomIdOrAlias, + new Date(newOriginServerTs), + { // Start the scroll at the next event from where they jumped from (seamless navigation) - scrollStartEventId: eventIdForTimestamp, - }) + scrollStartEventId: eventIdForClosestEvent, + preferredPrecision, + } ); + res.redirect(archiveUrlToRedirecTo); }) ); -// Based off of the Gitter archive routes, -// https://gitlab.com/gitterHQ/webapp/-/blob/14954e05c905e8c7cb675efebb89116c07cfaab5/server/handlers/app/archive.js#L190-297 +// Shows messages from the given date/time looking backwards up to the limit. router.get( - '/date/:yyyy(\\d{4})/:mm(\\d{2})/:dd(\\d{2})/:hourRange(\\d\\d?-\\d\\d?)?', + // The extra set of parenthesis around `((:\\d\\d?)?)` is to work around a + // `path-to-regex` bug where the `?` wasn't attaching to the capture group, see + // https://github.com/pillarjs/path-to-regexp/issues/287 + '/date/:yyyy(\\d{4})/:mm(\\d{2})/:dd(\\d{2}):time(T\\d\\d?:\\d\\d?((:\\d\\d?)?))?', timeoutMiddleware, - // eslint-disable-next-line max-statements + // eslint-disable-next-line max-statements, complexity asyncHandler(async function (req, res) { const roomIdOrAlias = getRoomIdOrAliasFromReq(req); + // We pull this fresh from the config for each request to ensure we have an + // updated value between each e2e test const archiveMessageLimit = config.get('archiveMessageLimit'); assert(archiveMessageLimit); // Synapse has a max `/messages` limit of 1000 @@ -283,47 +514,26 @@ router.get( 'archiveMessageLimit needs to be in range [1, 999]. We can only get 1000 messages at a time from Synapse and we need a buffer of at least one to see if there are too many messages on a given day so you can only configure a max of 999. If you need more messages, we will have to implement pagination' ); - const { fromTimestamp, toTimestamp, hourRange, fromHour, toHour } = - parseArchiveRangeFromReq(req); + const { toTimestamp, timeDefined, secondsDefined } = parseArchiveRangeFromReq(req); + + let precisionFromUrl = TIME_PRECISION_VALUES.none; + if (secondsDefined) { + precisionFromUrl = TIME_PRECISION_VALUES.seconds; + } else if (timeDefined) { + precisionFromUrl = TIME_PRECISION_VALUES.minutes; + } // Just 404 if anyone is trying to view the future, no need to waste resources on that const nowTs = Date.now(); - if (fromTimestamp > nowTs) { + if (toTimestamp > roundUpTimestampToUtcDay(nowTs)) { throw new StatusError( 404, `You can't view the history of a room on a future day (${new Date( - fromTimestamp + toTimestamp ).toISOString()} > ${new Date(nowTs).toISOString()}). Go back` ); } - // If the hourRange is defined, we force the range to always be 1 hour. If - // the format isn't correct, redirect to the correct hour range - if (hourRange && toHour !== fromHour + 1) { - // Pass through the query parameters - let queryParamterUrlPiece = ''; - if (req.query) { - queryParamterUrlPiece = `?${new URLSearchParams(req.query).toString()}`; - } - - res.redirect( - // FIXME: Can we use the matrixPublicArchiveURLCreator here? - `${urlJoin( - basePath, - roomIdOrAlias, - 'date', - req.params.yyyy, - req.params.mm, - req.params.dd, - `${fromHour}-${fromHour + 1}` - )}${queryParamterUrlPiece}` - ); - return; - } - - // TODO: Highlight tile that matches ?at=$xxx - //const aroundId = req.query.at; - // We have to wait for the room join to happen first before we can fetch // any of the additional room info or messages. const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via); @@ -335,16 +545,19 @@ router.get( // We over-fetch messages outside of the range of the given day so that we // can display messages from surrounding days (currently only from days // before) so that the quiet rooms don't feel as desolate and broken. + // + // When given a bare date like `2022/11/16`, we want to paginate from the end of that + // day backwards. This is why we use the `toTimestamp` here and fetch backwards. fetchEventsFromTimestampBackwards({ accessToken: matrixAccessToken, roomId, ts: toTimestamp, - // We fetch one more than the `archiveMessageLimit` so that we can see - // there are too many messages from the given day. If we have over the - // `archiveMessageLimit` number of messages fetching from the given day, - // it's acceptable to have them be from surrounding days. But if all 500 - // messages (for example) are from the same day, let's redirect to a - // smaller hour range to display. + // We fetch one more than the `archiveMessageLimit` so that we can see if there + // are too many messages from the given day. If we have over the + // `archiveMessageLimit` number of messages fetching from the given day, it's + // acceptable to have them be from surrounding days. But if all 500 messages + // (for example) are from the same day, let's redirect to a smaller hour range + // to display. limit: archiveMessageLimit + 1, }), ]); @@ -370,26 +583,6 @@ router.get( shouldIndex = roomData?.historyVisibility === `world_readable`; } - // If we have over the `archiveMessageLimit` number of messages fetching - // from the given day, it's acceptable to have them be from surrounding - // days. But if all 500 messages (for example) are from the same day, let's - // redirect to a smaller hour range to display. - if ( - // If there are too many messages, check that the event is from a previous - // day in the surroundings. - events.length >= archiveMessageLimit && - // Since we're only fetching previous days for the surroundings, we only - // need to look at the oldest event in the chronological list. - // - // XXX: In the future when we also fetch events from days after, we will - // need to change this next day check. - events[0].origin_server_ts >= fromTimestamp - ) { - res.send('TODO: Redirect user to smaller hour range'); - res.status(204); - return; - } - const hydrogenStylesUrl = urlJoin(basePath, '/hydrogen-styles.css'); const stylesUrl = urlJoin(basePath, '/css/styles.css'); const jsBundleUrl = urlJoin(basePath, '/js/entry-client-hydrogen.es.js'); @@ -397,8 +590,8 @@ router.get( const pageHtml = await renderHydrogenVmRenderScriptToPageHtml( path.resolve(__dirname, '../../shared/hydrogen-vm-render-script.js'), { - fromTimestamp, toTimestamp, + precisionFromUrl, roomData: { ...roomData, // The `canonicalAlias` will take precedence over the `roomId` when present so we only diff --git a/shared/hydrogen-vm-render-script.js b/shared/hydrogen-vm-render-script.js index e887499..c39511f 100644 --- a/shared/hydrogen-vm-render-script.js +++ b/shared/hydrogen-vm-render-script.js @@ -8,16 +8,17 @@ const assert = require('matrix-public-archive-shared/lib/assert'); const { Platform, MediaRepository, createNavigation, createRouter } = require('hydrogen-view-sdk'); +const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values'); const ArchiveRoomView = require('matrix-public-archive-shared/views/ArchiveRoomView'); const ArchiveHistory = require('matrix-public-archive-shared/lib/archive-history'); const supressBlankAnchorsReloadingThePage = require('matrix-public-archive-shared/lib/supress-blank-anchors-reloading-the-page'); const ArchiveRoomViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveRoomViewModel'); const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable'); -const fromTimestamp = window.matrixPublicArchiveContext.fromTimestamp; -assert(fromTimestamp); const toTimestamp = window.matrixPublicArchiveContext.toTimestamp; assert(toTimestamp); +const precisionFromUrl = window.matrixPublicArchiveContext.precisionFromUrl; +assert(Object.values(TIME_PRECISION_VALUES).includes(precisionFromUrl)); const roomData = window.matrixPublicArchiveContext.roomData; assert(roomData); const events = window.matrixPublicArchiveContext.events; @@ -105,8 +106,8 @@ async function mountHydrogen() { homeserverUrl: config.matrixServerUrl, room, // The timestamp from the URL that was originally visited - dayTimestampFrom: fromTimestamp, dayTimestampTo: toTimestamp, + precisionFromUrl, scrollStartEventId, events, stateEventMap, diff --git a/shared/lib/reference-values.js b/shared/lib/reference-values.js new file mode 100644 index 0000000..241ee93 --- /dev/null +++ b/shared/lib/reference-values.js @@ -0,0 +1,30 @@ +'use strict'; + +const MS_LOOKUP = { + ONE_DAY_IN_MS: 24 * 60 * 60 * 1000, + ONE_HOUR_IN_MS: 60 * 60 * 1000, + ONE_MINUTE_IN_MS: 60 * 1000, + ONE_SECOND_IN_MS: 1000, +}; + +const TIME_PRECISION_VALUES = { + // no time present - `/date/2022/11/16` + none: null, + // 23:59 - `/date/2022/11/16T23:59` + minutes: 'minutes', + // 23:59:59 - `/date/2022/11/16T23:59:59` + seconds: 'seconds', + // 23:59:59.999 - `/date/2022/11/16T23:59:59.999` + millisecond: 'millisecond', +}; + +const DIRECTION = { + forward: 'f', + backward: 'b', +}; + +module.exports = { + MS_LOOKUP, + TIME_PRECISION_VALUES, + DIRECTION, +}; diff --git a/shared/lib/timestamp-utilities.js b/shared/lib/timestamp-utilities.js new file mode 100644 index 0000000..a5ed40d --- /dev/null +++ b/shared/lib/timestamp-utilities.js @@ -0,0 +1,101 @@ +'use strict'; + +const assert = require('matrix-public-archive-shared/lib/assert'); +const { MS_LOOKUP } = require('matrix-public-archive-shared/lib/reference-values'); +const { ONE_DAY_IN_MS, ONE_HOUR_IN_MS, ONE_MINUTE_IN_MS, ONE_SECOND_IN_MS } = MS_LOOKUP; + +function roundUpTimestampToUtcDay(ts) { + // A `Date` object will cast just fine to a timestamp integer + assert(typeof ts === 'number' || ts instanceof Date); + const dateRountedUp = new Date(Math.ceil(ts / ONE_DAY_IN_MS) * ONE_DAY_IN_MS); + return dateRountedUp.getTime(); +} + +function roundUpTimestampToUtcHour(ts) { + // A `Date` object will cast just fine to a timestamp integer + assert(typeof ts === 'number' || ts instanceof Date); + const dateRountedUp = new Date(Math.ceil(ts / ONE_HOUR_IN_MS) * ONE_HOUR_IN_MS); + return dateRountedUp.getTime(); +} + +function roundUpTimestampToUtcMinute(ts) { + // A `Date` object will cast just fine to a timestamp integer + assert(typeof ts === 'number' || ts instanceof Date); + const dateRountedUp = new Date(Math.ceil(ts / ONE_MINUTE_IN_MS) * ONE_MINUTE_IN_MS); + return dateRountedUp.getTime(); +} + +function roundUpTimestampToUtcSecond(ts) { + // A `Date` object will cast just fine to a timestamp integer + assert(typeof ts === 'number' || ts instanceof Date); + const dateRountedUp = new Date(Math.ceil(ts / ONE_SECOND_IN_MS) * ONE_SECOND_IN_MS); + return dateRountedUp.getTime(); +} + +function getUtcStartOfDayTs(ts) { + assert(typeof ts === 'number' || ts instanceof Date); + const date = new Date(ts); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +function getUtcStartOfHourTs(ts) { + assert(typeof ts === 'number' || ts instanceof Date); + const date = new Date(ts); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours()); +} + +function getUtcStartOfMinuteTs(ts) { + assert(typeof ts === 'number' || ts instanceof Date); + const date = new Date(ts); + return Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes() + ); +} + +function getUtcStartOfSecondTs(ts) { + assert(typeof ts === 'number' || ts instanceof Date); + const date = new Date(ts); + return Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds() + ); +} + +function areTimestampsFromSameUtcDay(aTs, bTs) { + return getUtcStartOfDayTs(aTs) === getUtcStartOfDayTs(bTs); +} + +function areTimestampsFromSameUtcHour(aTs, bTs) { + return getUtcStartOfHourTs(aTs) === getUtcStartOfHourTs(bTs); +} + +function areTimestampsFromSameUtcMinute(aTs, bTs) { + return getUtcStartOfMinuteTs(aTs) === getUtcStartOfMinuteTs(bTs); +} + +function areTimestampsFromSameUtcSecond(aTs, bTs) { + return getUtcStartOfSecondTs(aTs) === getUtcStartOfSecondTs(bTs); +} + +module.exports = { + roundUpTimestampToUtcDay, + roundUpTimestampToUtcHour, + roundUpTimestampToUtcMinute, + roundUpTimestampToUtcSecond, + getUtcStartOfDayTs, + getUtcStartOfHourTs, + getUtcStartOfMinuteTs, + getUtcStartOfSecondTs, + areTimestampsFromSameUtcDay, + areTimestampsFromSameUtcHour, + areTimestampsFromSameUtcMinute, + areTimestampsFromSameUtcSecond, +}; diff --git a/shared/lib/url-creator.js b/shared/lib/url-creator.js index 6adefa0..a79d59e 100644 --- a/shared/lib/url-creator.js +++ b/shared/lib/url-creator.js @@ -3,6 +3,7 @@ const urlJoin = require('url-join'); const assert = require('matrix-public-archive-shared/lib/assert'); +const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values'); function qsToUrlPiece(qs) { if (qs.toString()) { @@ -65,9 +66,22 @@ class URLCreator { return `${urlJoin(this._basePath, `${urlPath}`)}${qsToUrlPiece(qs)}`; } - archiveUrlForDate(roomIdOrAlias, date, { viaServers = [], scrollStartEventId } = {}) { + archiveUrlForDate( + roomIdOrAlias, + date, + { preferredPrecision = null, viaServers = [], scrollStartEventId } = {} + ) { assert(roomIdOrAlias); assert(date); + // `preferredPrecision` is optional but if they gave a value, make sure it's something expected + if (preferredPrecision) { + assert( + Object.values(TIME_PRECISION_VALUES).includes(preferredPrecision), + `TimeSelectorViewModel: options.preferredPrecision must be one of ${JSON.stringify( + Object.values(TIME_PRECISION_VALUES) + )}` + ); + } let qs = new URLSearchParams(); [].concat(viaServers).forEach((viaServer) => { @@ -81,19 +95,36 @@ class URLCreator { // Gives the date in YYYY/mm/dd format. // date.toISOString() -> 2022-02-16T23:20:04.709Z - const urlDate = date.toISOString().split('T')[0].replaceAll('-', '/'); + const [datePiece, timePiece] = date.toISOString().split('T'); + // Get the `2022/02/16` part of it + const urlDate = datePiece.replaceAll('-', '/'); - return `${urlJoin(this._basePath, `${urlPath}/date/${urlDate}`)}${qsToUrlPiece(qs)}`; + // Get the `23:20:04` part of it (TIME_PRECISION_VALUES.seconds) + let urlTime = timePiece.split('.')[0]; + if (preferredPrecision === TIME_PRECISION_VALUES.minutes) { + // We only want to replace the seconds part of the URL if its superfluous. `23:59:00` + // does not convey more information than `23:59` so we can safely remove it if the + // desired precision is in minutes. + urlTime = urlTime.replace(/:00$/, ''); + } + const shouldIncludeTimeInUrl = !!preferredPrecision; + + return `${urlJoin( + this._basePath, + `${urlPath}/date/${urlDate}${shouldIncludeTimeInUrl ? `T${urlTime}` : ''}` + )}${qsToUrlPiece(qs)}`; } - archiveJumpUrlForRoom(roomIdOrAlias, { ts, dir }) { + archiveJumpUrlForRoom(roomIdOrAlias, { dir, currentRangeStartTs, currentRangeEndTs }) { assert(roomIdOrAlias); - assert(ts); assert(dir); + assert(currentRangeStartTs); + assert(currentRangeEndTs); let qs = new URLSearchParams(); - qs.append('ts', ts); qs.append('dir', dir); + qs.append('currentRangeStartTs', currentRangeStartTs); + qs.append('currentRangeEndTs', currentRangeEndTs); const urlPath = this._getArchiveUrlPathForRoomIdOrAlias(roomIdOrAlias); diff --git a/shared/viewmodels/ArchiveRoomViewModel.js b/shared/viewmodels/ArchiveRoomViewModel.js index 7d8427f..84b0d78 100644 --- a/shared/viewmodels/ArchiveRoomViewModel.js +++ b/shared/viewmodels/ArchiveRoomViewModel.js @@ -16,6 +16,7 @@ const assert = require('matrix-public-archive-shared/lib/assert'); const ModalViewModel = require('matrix-public-archive-shared/viewmodels/ModalViewModel'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); const CalendarViewModel = require('matrix-public-archive-shared/viewmodels/CalendarViewModel'); +const TimeSelectorViewModel = require('matrix-public-archive-shared/viewmodels/TimeSelectorViewModel'); const DeveloperOptionsContentViewModel = require('matrix-public-archive-shared/viewmodels/DeveloperOptionsContentViewModel'); const RightPanelContentView = require('matrix-public-archive-shared/views/RightPanelContentView'); const AvatarViewModel = require('matrix-public-archive-shared/viewmodels/AvatarViewModel'); @@ -23,6 +24,10 @@ const { customTileClassForEntry, } = require('matrix-public-archive-shared/lib/custom-tile-utilities'); const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable'); +const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values'); +const { + areTimestampsFromSameUtcDay, +} = require('matrix-public-archive-shared/lib/timestamp-utilities'); let txnCount = 0; function getFakeEventId() { @@ -57,14 +62,14 @@ function makeEventEntryFromEventJson(eventJson, memberEvent) { } class ArchiveRoomViewModel extends ViewModel { - // eslint-disable-next-line max-statements + // eslint-disable-next-line max-statements, complexity constructor(options) { super(options); const { homeserverUrl, room, - dayTimestampFrom, dayTimestampTo, + precisionFromUrl, scrollStartEventId, events, stateEventMap, @@ -73,15 +78,14 @@ class ArchiveRoomViewModel extends ViewModel { } = options; assert(homeserverUrl); assert(room); - assert(dayTimestampFrom); assert(dayTimestampTo); + assert(Object.values(TIME_PRECISION_VALUES).includes(precisionFromUrl)); assert(events); assert(stateEventMap); assert(shouldIndex !== undefined); assert(events); this._room = room; - this._dayTimestampFrom = dayTimestampFrom; this._dayTimestampTo = dayTimestampTo; this._currentTopPositionEventEntry = null; this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath); @@ -98,6 +102,11 @@ class ArchiveRoomViewModel extends ViewModel { stateEventMap, }); this._eventEntriesByEventId = eventEntriesByEventId; + // Since we anchor our scroll to the bottom when we page-load, it makes sense to set + // this as the bottom-most event entry by default. This variable says "TopPosition" + // but it means the top of the viewport which in the extreme case of the viewport + // being very short, should be the bottom-most event. + this._currentTopPositionEventEntry = events && eventEntriesByEventId[events[events.length - 1]]; this._roomAvatarViewModel = new AvatarViewModel({ homeserverUrlToPullMediaFrom: homeserverUrl, @@ -112,14 +121,50 @@ class ArchiveRoomViewModel extends ViewModel { entityId: this._room.id, }); - const initialDate = new Date(dayTimestampFrom); + const timelineRangeStartTimestamp = events[0]?.origin_server_ts; + const timelineRangeEndTimestamp = events[events.length - 1]?.origin_server_ts; + + const bottomMostEventDate = timelineRangeEndTimestamp && new Date(timelineRangeEndTimestamp); + const initialDate = new Date(dayTimestampTo); + // The activeDate gets updated based on what the `currentTopPositionEventEntry` is + // sob ecause we initialize with the bottom-most event as the + // `currentTopPositionEventEntry`, the starting activeDate should also be the + // timestamp from the bottom-most event. Otherwise, just fallback to the initialDate + const initialActiveDate = bottomMostEventDate || initialDate; + this._calendarViewModel = new CalendarViewModel({ // The day being shown in the archive - activeDate: initialDate, + activeDate: initialActiveDate, // The month displayed in the calendar - calendarDate: initialDate, + calendarDate: initialActiveDate, room, - basePath, + matrixPublicArchiveURLCreator: this._matrixPublicArchiveURLCreator, + }); + + const shouldShowTimeSelector = + // If there are no events, then it's possible the user navigated too far back + // before the room was created and we will let them pick a new time that might make + // more sense. But only if they are worried about time precision in the URL already. + (precisionFromUrl !== TIME_PRECISION_VALUES.none && !events.length) || + // Only show the time selector when we're showing events all from the same day. + (events.length && + areTimestampsFromSameUtcDay(timelineRangeStartTimestamp, timelineRangeEndTimestamp)); + + this._timeSelectorViewModel = new TimeSelectorViewModel({ + room, + // The time (within the given date) being displayed in the time scrubber. + activeDate: initialActiveDate, + // Prevent extra precision if it's not needed. We only need to show seconds if + // the page-loaded archive URL is worried about seconds. + preferredPrecision: + // Default to minutes for the time selector otherwise use whatever more fine + // grained precision that the URL is using + precisionFromUrl === TIME_PRECISION_VALUES.none + ? TIME_PRECISION_VALUES.minutes + : precisionFromUrl, + timelineRangeStartTimestamp, + timelineRangeEndTimestamp, + matrixPublicArchiveURLCreator: this._matrixPublicArchiveURLCreator, }); this._developerOptionsContentViewModel = new DeveloperOptionsContentViewModel( @@ -162,6 +207,8 @@ class ArchiveRoomViewModel extends ViewModel { type: 'custom', customView: RightPanelContentView, calendarViewModel: this._calendarViewModel, + shouldShowTimeSelector, + timeSelectorViewModel: this._timeSelectorViewModel, shouldIndex, get developerOptionsUrl() { return urlRouter.urlForSegments([ @@ -254,6 +301,7 @@ class ArchiveRoomViewModel extends ViewModel { return this._eventEntriesByEventId; } + // This is the event that appears at the very top of our visible timeline as you scroll around get currentTopPositionEventEntry() { return this._currentTopPositionEventEntry; } @@ -262,16 +310,19 @@ class ArchiveRoomViewModel extends ViewModel { return this._shouldShowRightPanel; } + // This is the event that appears at the very top of our visible timeline as you + // scroll around (see the IntersectionObserver) setCurrentTopPositionEventEntry(currentTopPositionEventEntry) { this._currentTopPositionEventEntry = currentTopPositionEventEntry; this.emitChange('currentTopPositionEventEntry'); - // Update the calendar + // Update the calendar and time scrubber this._calendarViewModel.setActiveDate(currentTopPositionEventEntry.timestamp); + this._timeSelectorViewModel.setActiveDate(currentTopPositionEventEntry.timestamp); } - get dayTimestampFrom() { - return this._dayTimestampFrom; + get dayTimestampTo() { + return this._dayTimestampTo; } get roomDirectoryUrl() { @@ -302,8 +353,22 @@ class ArchiveRoomViewModel extends ViewModel { _addJumpSummaryEvents(inputEventList) { const events = [...inputEventList]; + // The start of the range to use as a jumping off point to the previous activity. + // This should be the first event in the timeline (oldest) or if there are no events + // in the timeline, we can jump from day. + const jumpRangeStartTimestamp = events[0]?.origin_server_ts || this._dayTimestampTo; + // The end of the range to use as a jumping off point to the next activity. You + // might expect this to be the last event in the timeline but since we paginate from + // `_dayTimestampTo` backwards, `_dayTimestampTo` is actually the newest timestamp + // to paginate from + const jumpRangeEndTimestamp = this._dayTimestampTo; + + // Check whether the given day represented in the URL has any events on the page + // from that day. We only need to check the last event which would be closest to + // `_dayTimestampTo` anyway. + const lastEventTs = events[events.length - 1]?.origin_server_ts; const hasEventsFromGivenDay = - events[events.length - 1]?.origin_server_ts >= this._dayTimestampFrom; + lastEventTs && areTimestampsFromSameUtcDay(lastEventTs, this._dayTimestampTo); let daySummaryKind; if (events.length === 0) { daySummaryKind = 'no-events-at-all'; @@ -323,12 +388,12 @@ class ArchiveRoomViewModel extends ViewModel { type: 'org.matrix.archive.jump_to_previous_activity_summary', room_id: this._room.id, // Even though this isn't used for sort, just using the time where the event - // would logically be (at the start of the day) + // would logically be (before any of the other events in the timeline) origin_server_ts: events[0].origin_server_ts - 1, content: { canonicalAlias: this._room.canonicalAlias, - // The start of the range to use as a jumping off point to the previous activity - rangeStartTimestamp: events[0].origin_server_ts - 1, + jumpRangeStartTimestamp, + jumpRangeEndTimestamp, // This is a bit cheating but I don't know how else to pass this kind of // info to the Tile viewmodel basePath: this._basePath, @@ -343,17 +408,15 @@ class ArchiveRoomViewModel extends ViewModel { type: 'org.matrix.archive.jump_to_next_activity_summary', room_id: this._room.id, // Even though this isn't used for sort, just using the time where the event - // would logically be. - // - // -1 so we're not at 00:00:00 of the next day - origin_server_ts: this._dayTimestampTo - 1, + // would logically be (at the end of the day) + origin_server_ts: this._dayTimestampTo, content: { canonicalAlias: this._room.canonicalAlias, daySummaryKind, // The timestamp from the URL that was originally visited - dayTimestamp: this._dayTimestampFrom, - // The end of the range to use as a jumping off point to the next activity - rangeEndTimestamp: this._dayTimestampTo, + dayTimestamp: this._dayTimestampTo, + jumpRangeStartTimestamp, + jumpRangeEndTimestamp, // This is a bit cheating but I don't know how else to pass this kind of // info to the Tile viewmodel basePath: this._basePath, diff --git a/shared/viewmodels/CalendarViewModel.js b/shared/viewmodels/CalendarViewModel.js index af132e3..72e552b 100644 --- a/shared/viewmodels/CalendarViewModel.js +++ b/shared/viewmodels/CalendarViewModel.js @@ -3,23 +3,22 @@ const { ViewModel } = require('hydrogen-view-sdk'); const assert = require('matrix-public-archive-shared/lib/assert'); -const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); class CalendarViewModel extends ViewModel { constructor(options) { super(options); - const { activeDate, calendarDate, room, basePath } = options; + const { activeDate, calendarDate, room, matrixPublicArchiveURLCreator } = options; assert(activeDate); assert(calendarDate); assert(room); - assert(basePath); + assert(matrixPublicArchiveURLCreator); // The day being shown in the archive this._activeDate = activeDate; // The month displayed in the calendar this._calendarDate = calendarDate; this._room = room; - this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath); + this._matrixPublicArchiveURLCreator = matrixPublicArchiveURLCreator; } get activeDate() { diff --git a/shared/viewmodels/JumpToNextActivitySummaryTileViewModel.js b/shared/viewmodels/JumpToNextActivitySummaryTileViewModel.js index 5b2c903..fd88a0b 100644 --- a/shared/viewmodels/JumpToNextActivitySummaryTileViewModel.js +++ b/shared/viewmodels/JumpToNextActivitySummaryTileViewModel.js @@ -2,6 +2,7 @@ const { SimpleTile } = require('hydrogen-view-sdk'); +const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); const assert = require('../lib/assert'); @@ -27,17 +28,23 @@ class JumpToNextActivitySummaryTileViewModel extends SimpleTile { return this._entry?.content?.['dayTimestamp']; } + // The start of the range to use as a jumping off point to the previous activity + get jumpRangeStartTimestamp() { + return this._entry?.content?.['jumpRangeStartTimestamp']; + } + // The end of the range to use as a jumping off point to the next activity - get rangeEndTimestamp() { - return this._entry?.content?.['rangeEndTimestamp']; + get jumpRangeEndTimestamp() { + return this._entry?.content?.['jumpRangeEndTimestamp']; } get jumpToNextActivityUrl() { return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom( this._entry?.content?.['canonicalAlias'] || this._entry.roomId, { - ts: this.rangeEndTimestamp, - dir: 'f', + dir: DIRECTION.forward, + currentRangeStartTs: this.jumpRangeStartTimestamp, + currentRangeEndTs: this.jumpRangeEndTimestamp, } ); } diff --git a/shared/viewmodels/JumpToPreviousActivitySummaryTileViewModel.js b/shared/viewmodels/JumpToPreviousActivitySummaryTileViewModel.js index acb2f2b..db72b8e 100644 --- a/shared/viewmodels/JumpToPreviousActivitySummaryTileViewModel.js +++ b/shared/viewmodels/JumpToPreviousActivitySummaryTileViewModel.js @@ -2,6 +2,7 @@ const { SimpleTile } = require('hydrogen-view-sdk'); +const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); const assert = require('../lib/assert'); @@ -20,16 +21,22 @@ class JumpToPreviousActivitySummaryTileViewModel extends SimpleTile { } // The start of the range to use as a jumping off point to the previous activity - get rangeStartTimestamp() { - return this._entry?.content?.['rangeStartTimestamp']; + get jumpRangeStartTimestamp() { + return this._entry?.content?.['jumpRangeStartTimestamp']; + } + + // The end of the range to use as a jumping off point to the next activity + get jumpRangeEndTimestamp() { + return this._entry?.content?.['jumpRangeEndTimestamp']; } get jumpToPreviousActivityUrl() { return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom( this._entry?.content?.['canonicalAlias'] || this._entry.roomId, { - ts: this.rangeStartTimestamp, - dir: 'b', + dir: DIRECTION.backward, + currentRangeStartTs: this.jumpRangeStartTimestamp, + currentRangeEndTs: this.jumpRangeEndTimestamp, } ); } diff --git a/shared/viewmodels/TimeSelectorViewModel.js b/shared/viewmodels/TimeSelectorViewModel.js new file mode 100644 index 0000000..d3818af --- /dev/null +++ b/shared/viewmodels/TimeSelectorViewModel.js @@ -0,0 +1,86 @@ +'use strict'; + +const { ViewModel } = require('hydrogen-view-sdk'); +const assert = require('matrix-public-archive-shared/lib/assert'); +const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values'); + +class TimeSelectorViewModel extends ViewModel { + constructor(options) { + super(options); + const { + room, + activeDate, + preferredPrecision = TIME_PRECISION_VALUES.minutes, + timelineRangeStartTimestamp, + timelineRangeEndTimestamp, + matrixPublicArchiveURLCreator, + } = options; + assert(room); + assert(activeDate); + assert(matrixPublicArchiveURLCreator); + assert( + Object.values(TIME_PRECISION_VALUES).includes(preferredPrecision), + `TimeSelectorViewModel: options.preferredPrecision must be one of ${JSON.stringify( + Object.values(TIME_PRECISION_VALUES) + )}` + ); + + this._room = room; + // The time (within the given date) being displayed in the time scrubber. + // And we will choose a time within this day. + this._activeDate = activeDate; + this._preferredPrecision = preferredPrecision; + + this._timelineRangeStartTimestamp = timelineRangeStartTimestamp; + this._timelineRangeEndTimestamp = timelineRangeEndTimestamp; + this._matrixPublicArchiveURLCreator = matrixPublicArchiveURLCreator; + + this._isDragging = false; + } + + get activeDate() { + return this._activeDate; + } + + setActiveDate(newActiveDateInput) { + const newActiveDate = new Date(newActiveDateInput); + this._activeDate = newActiveDate; + this.emitChange('activeDate'); + } + + get goToActiveDateUrl() { + return this._matrixPublicArchiveURLCreator.archiveUrlForDate( + this._room.canonicalAlias || this._room.id, + this.activeDate, + { preferredPrecision: this.preferredPrecision } + ); + } + + get preferredPrecision() { + return this._preferredPrecision; + } + + setPreferredPrecision(preferredPrecision) { + this._preferredPrecision = preferredPrecision; + this.emitChange('preferredPrecision'); + } + + get timelineRangeStartTimestamp() { + return this._timelineRangeStartTimestamp; + } + + get timelineRangeEndTimestamp() { + return this._timelineRangeEndTimestamp; + } + + get isDragging() { + return this._isDragging; + } + + setIsDragging(isDragging) { + this._isDragging = isDragging; + this.emitChange('isDragging'); + } +} + +module.exports = TimeSelectorViewModel; diff --git a/shared/views/ArchiveRoomView.js b/shared/views/ArchiveRoomView.js index 3a4c0cb..d3cef76 100644 --- a/shared/views/ArchiveRoomView.js +++ b/shared/views/ArchiveRoomView.js @@ -92,8 +92,8 @@ class DisabledComposerView extends TemplateView { const activeDate = new Date( // If the date from our `archiveRoomViewModel` is available, use that vm?.currentTopPositionEventEntry?.timestamp || - // Otherwise, use our initial `dayTimestampFrom` - vm.dayTimestampFrom + // Otherwise, use our initial `dayTimestampTo` + vm.dayTimestampTo ); const dateString = activeDate.toISOString().split('T')[0]; return t.span(`You're viewing an archive of events from ${dateString}. Use a `); @@ -114,6 +114,19 @@ class DisabledComposerView extends TemplateView { } class ArchiveRoomView extends TemplateView { + constructor(vm) { + super(vm); + + // Keep track of the `IntersectionObserver` so we can disconnect it when necessary + this._interSectionObserverForUpdatedTopPositionEventEntry = null; + } + + unmount() { + if (this._interSectionObserverForUpdatedTopPositionEventEntry) { + this._interSectionObserverForUpdatedTopPositionEventEntry.disconnect(); + } + } + render(t, vm) { const rootElement = t.div( { @@ -126,7 +139,7 @@ class ArchiveRoomView extends TemplateView { // The red border and yellow background trail around the event that is // driving the active date as you scroll around. t.if( - (vm) => vm._developerOptionsViewModel?.debugActiveDateIntersectionObserver, + (vm) => vm._developerOptionsContentViewModel?.debugActiveDateIntersectionObserver, (t /*, vm*/) => { return t.style({}, (vm) => { return ` @@ -150,6 +163,9 @@ class ArchiveRoomView extends TemplateView { t.view(new DisabledComposerView(vm)), ]), ]), + // We can't just conditionally render the right-panel with `t.ifView(...)` based + // on `shouldShowRightPanel` because the right-panel being "hidden" only applies + // to the mobile break points and is always shown on desktop. t.view(new RightPanelView(vm.rightPanelModel)), t.mapView( (vm) => vm.lightboxViewModel, @@ -159,9 +175,11 @@ class ArchiveRoomView extends TemplateView { ] ); + // Avoid an error when server-side rendering (SSR) because it doesn't have all the + // DOM API's available (and doesn't need it for this case) if (typeof IntersectionObserver === 'function') { const scrollRoot = rootElement.querySelector('.Timeline_scroller'); - const observer = new IntersectionObserver( + this._interSectionObserverForUpdatedTopPositionEventEntry = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { @@ -188,7 +206,7 @@ class ArchiveRoomView extends TemplateView { } ); [...scrollRoot.querySelectorAll(`:scope > ul > [data-event-id]`)].forEach((el) => { - observer.observe(el); + this._interSectionObserverForUpdatedTopPositionEventEntry.observe(el); }); } diff --git a/shared/views/CalendarView.js b/shared/views/CalendarView.js index c650317..3b7709d 100644 --- a/shared/views/CalendarView.js +++ b/shared/views/CalendarView.js @@ -3,14 +3,9 @@ // Be mindful to do all date operations in UTC (the archive is all in UTC date/times) const { TemplateView } = require('hydrogen-view-sdk'); - -function sameDay(date1, date2) { - return ( - date1.getUTCFullYear() === date2.getUTCFullYear() && - date1.getUTCMonth() === date2.getUTCMonth() && - date1.getUTCDate() === date2.getUTCDate() - ); -} +const { + areTimestampsFromSameUtcDay, +} = require('matrix-public-archive-shared/lib/timestamp-utilities'); // Get the number of days in the given month where the `inputDate` lies. // @@ -162,7 +157,10 @@ class CalendarView extends TemplateView { const isDayInFuture = dayNumberDate.getTime() - todayTs > 0; // The current day displayed in the archive - const isActive = sameDay(dayNumberDate, vm.activeDate); + const isActive = areTimestampsFromSameUtcDay( + dayNumberDate.getTime(), + vm.activeDate.getTime() + ); // day number from 0 (monday) to 6 (sunday) const dayNumber = dayNumberDate.getUTCDay(); diff --git a/shared/views/JumpToNextActivitySummaryTileView.js b/shared/views/JumpToNextActivitySummaryTileView.js index 238eff5..cce1f8c 100644 --- a/shared/views/JumpToNextActivitySummaryTileView.js +++ b/shared/views/JumpToNextActivitySummaryTileView.js @@ -29,7 +29,6 @@ class JumpToNextActivitySummaryTileView extends TemplateView { // while we wait for the rest of the JavaScript to load. 'js-bottom-scroll-anchor': true, }, - 'data-event-id': vm.eventId, }, [ t.if( diff --git a/shared/views/JumpToPreviousActivitySummaryTileView.js b/shared/views/JumpToPreviousActivitySummaryTileView.js index a86b795..e00cb18 100644 --- a/shared/views/JumpToPreviousActivitySummaryTileView.js +++ b/shared/views/JumpToPreviousActivitySummaryTileView.js @@ -7,7 +7,6 @@ class JumpToPreviousActivitySummaryTileView extends TemplateView { return t.div( { className: 'JumpToPreviousActivitySummaryTileView', - 'data-event-id': vm.eventId, }, [ t.a( diff --git a/shared/views/ModalView.js b/shared/views/ModalView.js index 585e635..b72d539 100644 --- a/shared/views/ModalView.js +++ b/shared/views/ModalView.js @@ -78,8 +78,10 @@ class ModalView extends TemplateView { t.mapSideEffect( (vm) => vm.open, (open) => { - // The dialog has to be in the DOM before we can call `showModal`, etc. - // Assume this view will be mounted in the parent DOM straight away. + // The dialog has to be in the DOM before we can call `showModal`, etc. Assume + // this view will be mounted in the parent DOM straight away. + // #hydrogen-assume-view-mounted-right-away - + // https://github.com/vector-im/hydrogen-web/issues/1069 requestAnimationFrame(() => { // Prevent doing extra work if the modal is already closed or open and already // matches our intention diff --git a/shared/views/RightPanelContentView.js b/shared/views/RightPanelContentView.js index b7ceb57..59dd96c 100644 --- a/shared/views/RightPanelContentView.js +++ b/shared/views/RightPanelContentView.js @@ -3,11 +3,14 @@ const { TemplateView } = require('hydrogen-view-sdk'); const CalendarView = require('matrix-public-archive-shared/views/CalendarView'); +const TimeSelectorView = require('matrix-public-archive-shared/views/TimeSelectorView'); const assert = require('matrix-public-archive-shared/lib/assert'); class RightPanelContentView extends TemplateView { render(t, vm) { assert(vm.shouldIndex !== undefined); + assert(vm.shouldShowTimeSelector !== undefined); + let maybeIndexedMessage = 'This room is not being indexed by search engines.'; if (vm.shouldIndex) { maybeIndexedMessage = 'This room is being indexed by search engines.'; @@ -15,25 +18,25 @@ class RightPanelContentView extends TemplateView { return t.div( { - className: { - RightPanelContentView: true, - }, + className: 'RightPanelContentView', }, [ - t.view(new CalendarView(vm.calendarViewModel)), - t.div( + t.div({ className: 'RightPanelContentView_mainContent' }, [ + t.view(new CalendarView(vm.calendarViewModel)), + t.ifView( + (vm) => vm.shouldShowTimeSelector, + (vm) => new TimeSelectorView(vm.timeSelectorViewModel) + ), + ]), + t.footer( { - className: { - RightPanelContentView_footer: true, - }, + className: 'RightPanelContentView_footer', }, [ t.p(maybeIndexedMessage), t.div( { - className: { - RightPanelContentView_footerLinkList: true, - }, + className: 'RightPanelContentView_footerLinkList', }, [ t.a( diff --git a/shared/views/TimeSelectorView.js b/shared/views/TimeSelectorView.js new file mode 100644 index 0000000..035ba16 --- /dev/null +++ b/shared/views/TimeSelectorView.js @@ -0,0 +1,514 @@ +'use strict'; + +const assert = require('matrix-public-archive-shared/lib/assert'); +const { TemplateView } = require('hydrogen-view-sdk'); +const { + MS_LOOKUP, + TIME_PRECISION_VALUES, +} = require('matrix-public-archive-shared/lib/reference-values'); +const { ONE_DAY_IN_MS, ONE_HOUR_IN_MS, ONE_MINUTE_IN_MS, ONE_SECOND_IN_MS } = MS_LOOKUP; +const { getUtcStartOfDayTs } = require('matrix-public-archive-shared/lib/timestamp-utilities'); + +function clamp(input, min, max) { + assert(input !== undefined); + assert(min !== undefined); + assert(max !== undefined); + return Math.min(Math.max(input, min), max); +} + +function getTwentyFourHourTimeStringFromDate( + inputDate, + preferredPrecision = TIME_PRECISION_VALUES.minutes +) { + const date = new Date(inputDate); + + const formatValue = (input) => { + return String(input).padStart(2, '0'); + }; + + // getUTCHours() returns an integer between 0 and 23 + const hour = date.getUTCHours(); + + // getUTCHours() returns an integer between 0 and 59 + const minute = date.getUTCMinutes(); + + // getUTCSeconds() returns an integer between 0 and 59 + const second = date.getUTCSeconds(); + + let twentyFourHourDateString = `${formatValue(hour)}:${formatValue(minute)}`; + + // Prevent extra precision if it's not needed. + // This way there won't be an extra time control to worry about for users in most cases. + if (preferredPrecision === TIME_PRECISION_VALUES.seconds) { + twentyFourHourDateString += `:${formatValue(second)}`; + } + + return twentyFourHourDateString; +} + +function getLocaleTimeStringFromDate( + inputDate, + preferredPrecision = TIME_PRECISION_VALUES.minutes +) { + const date = new Date(inputDate); + + const dateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + }; + + // Prevent extra precision if it's not needed. + // This way it will match the `` text/controls + if (preferredPrecision === TIME_PRECISION_VALUES.seconds) { + dateTimeFormatOptions.second = '2-digit'; + } + + const localDateString = date.toLocaleTimeString([], dateTimeFormatOptions); + + return localDateString; +} + +class TimeSelectorView extends TemplateView { + constructor(vm) { + super(vm); + this._vm = vm; + + // Keep track of the `IntersectionObserver` so we can disconnect it when necessary + this._interSectionObserverForVisibility = null; + // Keep track of the position we started dragging from so we can derive the delta movement + this._dragPositionX = null; + // Keep track of the momentum velocity over time + this._velocityX = 0; + // Keep track of the requestAnimationFrame(...) ID so we can cancel it when necessary + this._momentumRafId = null; + // Keep track of when we should ignore scroll events from programmatic scroll + // position changes from the side-effect `activeDate` change. The scroll event + // handler is only meant to capture the user changing the scroll and therefore a new + // `activeDate` should be calculated. + this._ignoreNextScrollEvent = false; + } + + unmount() { + if (this._interSectionObserverForVisibility) { + this._interSectionObserverForVisibility.disconnect(); + } + + if (this._momentumRafId) { + cancelAnimationFrame(this._momentumRafId); + } + } + + render(t /*, vm*/) { + // Create a locally unique ID so all of the input labels correspond to only this + const inputUniqueId = `time-input-${Math.floor(Math.random() * 1000000000)}`; + + const hourIncrementStrings = [...Array(24).keys()].map((hourNumber) => { + return { + utc: new Date(Date.UTC(2022, 1, 1, hourNumber)).toLocaleTimeString([], { + hour: 'numeric', + timeZone: 'UTC', + }), + local: new Date(Date.UTC(2022, 1, 1, hourNumber)).toLocaleTimeString([], { + hour: 'numeric', + }), + }; + }); + + // Set the scroll position based on the `activeDate` whenever it changes + t.mapSideEffect( + (vm) => vm.activeDate, + (/*activeDate , _oldActiveDate*/) => { + // This makes the side-effect always run after the initial `render` and after + // bindings evaluate. + // + // For the initial render, this does assume this view will be mounted in the + // parent DOM straight away. #hydrogen-assume-view-mounted-right-away - + // https://github.com/vector-im/hydrogen-web/issues/1069 + requestAnimationFrame(() => { + this.updateScrubberScrollBasedOnActiveDate(); + }); + } + ); + + // Since this lives in the right-panel which is conditionally hidden from view with + // `display: none;`, we have to re-evaluate the scrubber scroll position when it + // becomes visible because client dimensions evaluate to `0` when something is + // hidden in the DOM. + t.mapSideEffect( + // We do this so it only runs once. We only need to run this once to bind the + // `IntersectionObserver` + (/*vm*/) => null, + () => { + // This makes the side-effect always run after the initial `render` and after + // bindings evaluate. + // + // For the initial render, this does assume this view will be mounted in the + // parent DOM straight away. #hydrogen-assume-view-mounted-right-away - + // https://github.com/vector-im/hydrogen-web/issues/1069 + requestAnimationFrame(() => { + // Bind IntersectionObserver to the target element + this._interSectionObserverForVisibility = new IntersectionObserver((entries) => { + const isScrubberVisible = entries[0].isIntersecting; + if (isScrubberVisible) { + this.updateScrubberScrollBasedOnActiveDate(); + } + }); + this._interSectionObserverForVisibility.observe(this.scrubberScrollNode); + }); + } + ); + + const timeInput = t.input({ + type: 'time', + value: (vm) => getTwentyFourHourTimeStringFromDate(vm.activeDate, vm.preferredPrecision), + step: (vm) => { + // `step="1"` is a "hack" to get the time selector to always show second precision + if (vm.preferredPrecision === TIME_PRECISION_VALUES.seconds) { + return 1; + } + + return undefined; + }, + onChange: (e) => { + this.onTimeInputChange(e); + }, + className: 'TimeSelectorView_timeInput', + id: inputUniqueId, + }); + + // Set the time input `.value` property + t.mapSideEffect( + (vm) => vm.activeDate, + (activeDate /*, _oldActiveDate*/) => { + const newValue = getTwentyFourHourTimeStringFromDate( + activeDate, + this._vm.preferredPrecision + ); + // Ideally, the input would reflect whatever the `value` attribute was set as in + // the DOM. But it seems to ignore the attribute after using the time input to + // select a time. We have to manually set the `.value` property of the input in + // order for it to actually reflect the value in the UI. + timeInput.value = newValue; + } + ); + + return t.section( + { + className: { + TimeSelectorView: true, + }, + 'data-testid': 'time-selector', + }, + [ + t.header({ className: 'TimeSelectorView_header' }, [ + t.label({ for: inputUniqueId }, [ + t.span({ className: 'TimeSelectorView_primaryTimezoneLabel' }, 'UTC +0'), + ]), + timeInput, + t.a( + { + className: 'TimeSelectorView_goAction', + href: (vm) => vm.goToActiveDateUrl, + }, + 'Go' + ), + ]), + t.main( + { + className: 'TimeSelectorView_scrubber', + // We'll hide this away for screen reader users because they should use the + // native `` instead of this weird scrolling time scrubber thing + 'aria-hidden': true, + }, + [ + t.div( + { + className: { + TimeSelectorView_scrubberScrollWrapper: true, + 'is-dragging': (vm) => vm.isDragging, + 'js-scrubber': true, + }, + // Emulate momentum scrolling for mouse click and dragging. Still allows + // for native momentum scrolling on touch devices because those don't + // trigger mouse events. + onMousedown: (event) => { + this.onMousedown(event); + }, + onMouseup: (event) => { + this.onMouseup(event); + }, + onMousemove: (event) => { + this.onMousemove(event); + }, + onMouseleave: (event) => { + this.onMouseleave(event); + }, + onWheel: (event) => { + this.onWheel(event); + }, + onScroll: (event) => { + this.onScroll(event); + }, + }, + [ + t.ul({ className: 'TimeSelectorView_dial' }, [ + ...hourIncrementStrings.map((hourIncrementStringData) => { + return t.li({ className: 'TimeSelectorView_incrementLabel' }, [ + t.div( + { className: 'TimeSelectorView_incrementLabelText' }, + hourIncrementStringData.utc + ), + t.div( + { className: 'TimeSelectorView_incrementLabelTextSecondary' }, + hourIncrementStringData.local + ), + ]); + }), + + // The magnifier highlights the time range of messages in the timeline on this page + t.map( + // This is just a trick to get this element to update whenever either of these values change (not fool-proof) + (vm) => vm.timelineRangeStartTimestamp + vm.timelineRangeEndTimestamp, + (_value, t /*, vm*/) => { + return t.div({ + className: 'TimeSelectorView_magnifierBubble', + style: (vm) => { + const msInRange = + vm.timelineRangeEndTimestamp - vm.timelineRangeStartTimestamp; + + // No messages in the timeline, means nothing to highlight + if (!msInRange) { + return 'display: none;'; + } + // If the timeline has messages from more than one day, then + // just just hide it and log a warning. There is no point in + // highlighting the whole range of time. + else if (msInRange > ONE_DAY_IN_MS) { + console.warn( + 'Timeline has messages from more than one day but TimeSelectorView is being used. We only expect to show the TimeSelectorView when there is less than a day of messages.' + ); + return 'display: none;'; + } + + // Get the timestamp from the beginning of whatever day the active day is set to + const startOfDayTimestamp = getUtcStartOfDayTs(this._vm.activeDate); + + const widthRatio = msInRange / ONE_DAY_IN_MS; + const msFromStartOfDay = + vm.timelineRangeStartTimestamp - startOfDayTimestamp; + const leftPositionRatio = msFromStartOfDay / ONE_DAY_IN_MS; + + return `width: ${100 * widthRatio}%; left: ${100 * leftPositionRatio}%;`; + }, + }); + } + ), + ]), + ] + ), + ] + ), + t.footer({ className: 'TimeSelectorView_footer' }, [ + t.label({ for: inputUniqueId }, [ + t.time( + { + className: 'TimeSelectorView_secondaryTime', + datetime: (vm) => new Date(vm.activeDate).toISOString(), + }, + t.map( + (vm) => vm.activeDate, + (_activeDate, t, vm) => { + return t.span(getLocaleTimeStringFromDate(vm.activeDate, vm.preferredPrecision)); + } + ) + ), + ]), + t.label({ for: inputUniqueId }, [ + t.span({ className: 'TimeSelectorView_secondaryTimezoneLabel' }, 'Local Time'), + ]), + ]), + ] + ); + } + + get scrubberScrollNode() { + if (!this._scrubberScrollNode) { + this._scrubberScrollNode = this.root()?.querySelector('.js-scrubber'); + } + + return this._scrubberScrollNode; + } + + onTimeInputChange(event) { + const prevActiveDate = this._vm.activeDate; + + const newTimeString = event.target.value; + if (newTimeString) { + const [hourString, minuteString, secondString = '0'] = newTimeString.split(':'); + const hourInMs = parseInt(hourString, 10) * ONE_HOUR_IN_MS; + const minuteInMs = parseInt(minuteString, 10) * ONE_MINUTE_IN_MS; + const secondInMs = parseInt(secondString, 10) * ONE_SECOND_IN_MS; + const timeInMs = hourInMs + minuteInMs + secondInMs; + + // Get the timestamp from the beginning of whatever day the active day is set to + const startOfDayTimestamp = getUtcStartOfDayTs(prevActiveDate); + + const newActiveDate = new Date(startOfDayTimestamp + timeInMs); + this._vm.setActiveDate(newActiveDate); + } + } + + // Set the scrubber scroll position based on the `activeDate` + updateScrubberScrollBasedOnActiveDate() { + const activeDate = this._vm.activeDate; + + // Get the timestamp from the beginning of whatever day the active day is set to + const startOfDayTimestamp = getUtcStartOfDayTs(activeDate); + + // Next, we'll find how many ms have elapsed so far in the day since the start of the day + const msSoFarInDay = activeDate.getTime() - startOfDayTimestamp; + const timeInDayRatio = msSoFarInDay / ONE_DAY_IN_MS; + + // Ignore scroll changes before the node is rendered to the page + if (this.scrubberScrollNode) { + // These will evaluate to `0` if this is `display: none;` which happens on + // mobile when the right-panel is hidden. + const currentScrollWidth = this.scrubberScrollNode.scrollWidth; + const currentClientWidth = this.scrubberScrollNode.clientWidth; + + // Change the scroll position to the represented date + this.scrubberScrollNode.scrollLeft = + timeInDayRatio * (currentScrollWidth - currentClientWidth); + + // We can't just keep track of the `scrollLeft` position and compare it in the + // scroll event handler because there are rounding differences (Chrome rounds + // any decimal down). `scrollLeft` normally rounds down to integers but gets + // wonky once you introduce display scaling and will give decimal values. And + // we don't want to lookup `scrollLeft` from the element after we just set it + // because that will cause a layout recalculation (thrashing) which isn't + // performant. + // + // So instead, we rely on ignoring the next scroll event that will be fired + // from scroll change just above. We know that all of the DOM event stuff all + // happens in the main thread so should be no races there and assume that + // there are not other changes to the scroll in this same loop. + this._ignoreNextScrollEvent = true; + } + } + + onScroll(/*event*/) { + const currentScrollLeft = this.scrubberScrollNode.scrollLeft; + // Ignore scroll events caused by programmatic scroll position changes by the + // side-effect `activeDate` change handler. + // + // We don't need to recalculate the `activeDate` in the scroll handler here if we + // programmatically changed the scroll based on the updated `activeDate` we already + // know about. + if (this._ignoreNextScrollEvent) { + // Reset once we've seen a scroll event + this._ignoreNextScrollEvent = false; + return; + } + + // The width of content in the time selector scrubber + const currentScrollWidth = this.scrubberScrollNode.scrollWidth; + // The width of time selector that we can see + const currentClientWidth = this.scrubberScrollNode.clientWidth; + + // Ratio from 0-1 of how much has been scrolled in the scrubber (0 is the start of + // the day, 1 is the end of the day). We clamp to protect from people overscrolling + // in either direction and accidately advancing to the next/previous day. + const scrollRatio = clamp(currentScrollLeft / (currentScrollWidth - currentClientWidth), 0, 1); + + // Next, we'll derive how many ms in the day are represented by that scroll + // position. + // + // We use a `clamp` instead of `scrollRatio * (ONE_DAY_IN_MS - 1)` to avoid every + // position we move to having 59 seconds (`03:25:59`) as the result. The math works + // out that way because there is exactly 60 pixels between each hour in the time + // selector and there are 60 minutes in an hour so we always get back minute + // increments. + const msSoFarInDay = clamp( + scrollRatio * ONE_DAY_IN_MS, + 0, + // We `- 1` from `ONE_DAY_IN_MS` because we don't want to accidenatally + // advance to the next day at the extremity because midnight + 24 hours = the next + // day instead of the same day. + ONE_DAY_IN_MS - 1 + ); + + // Get the timestamp from the beginning of whatever day the active day is set to + const startOfDayTimestamp = getUtcStartOfDayTs(this._vm.activeDate); + + // And craft a new date based on the scroll position + const newActiveDate = new Date(startOfDayTimestamp + msSoFarInDay); + this._vm.setActiveDate(newActiveDate); + } + + onMousedown(event) { + this._vm.setIsDragging(true); + this._dragPositionX = event.pageX; + } + + onMouseup(/*event*/) { + this._vm.setIsDragging(false); + this.startMomentumTracking(); + } + + onMousemove(event) { + if (this._vm.isDragging) { + const delta = event.pageX - this._dragPositionX; + + this.scrubberScrollNode.scrollLeft = this.scrubberScrollNode.scrollLeft - delta; + // Ignore momentum for delta's of 1px or below because slowly moving by 1px + // shouldn't really have momentum. Imagine you're trying to precisely move to a + // spot, you don't want it to move again after you let go. + this._velocityX = Math.abs(delta) > 1 ? delta : 0; + + this._dragPositionX = event.pageX; + } + } + + onMouseleave(/*event*/) { + this._vm.setIsDragging(false); + } + + onWheel(/*event*/) { + this._velocityX = 0; + // If someone is using the horizontal mouse wheel, they already know what they're + // doing. Don't mess with it. + this.cancelMomentumTracking(); + } + + startMomentumTracking() { + this.cancelMomentumTracking(); + const momentumRafId = requestAnimationFrame(this.momentumLoop.bind(this)); + this._momentumRafId = momentumRafId; + } + + cancelMomentumTracking() { + cancelAnimationFrame(this._momentumRafId); + } + + momentumLoop() { + const velocityXAtStartOfLoop = this._velocityX; + // Apply the momentum movement to the scroll + const currentScrollLeft = this.scrubberScrollNode.scrollLeft; + const newScrollLeftPosition = currentScrollLeft - velocityXAtStartOfLoop * 2; + this.scrubberScrollNode.scrollLeft = newScrollLeftPosition; + + const DAMPING_FACTOR = 0.95; + const DEADZONE = 0.5; + + // Scrub off some momentum each run of the loop (friction) + const newVelocityX = velocityXAtStartOfLoop * DAMPING_FACTOR; + if (Math.abs(newVelocityX) > DEADZONE) { + const momentumRafId = requestAnimationFrame(this.momentumLoop.bind(this)); + this._momentumRafId = momentumRafId; + } + + this._velocityX = newVelocityX; + } +} + +module.exports = TimeSelectorView; diff --git a/test/e2e-tests.js b/test/e2e-tests.js index 3e97c14..1ca6409 100644 --- a/test/e2e-tests.js +++ b/test/e2e-tests.js @@ -10,11 +10,19 @@ const escapeStringRegexp = require('escape-string-regexp'); const { parseHTML } = require('linkedom'); const { readFile } = require('fs').promises; +const RethrownError = require('../server/lib/rethrown-error'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); const { fetchEndpointAsText, fetchEndpointAsJson } = require('../server/lib/fetch-endpoint'); const config = require('../server/lib/config'); +const { + MS_LOOKUP, + TIME_PRECISION_VALUES, + DIRECTION, +} = require('matrix-public-archive-shared/lib/reference-values'); +const { ONE_DAY_IN_MS, ONE_HOUR_IN_MS, ONE_MINUTE_IN_MS, ONE_SECOND_IN_MS } = MS_LOOKUP; const { + getTestClientForAs, getTestClientForHs, createTestRoom, getCanonicalAlias, @@ -24,8 +32,8 @@ const { createMessagesInRoom, updateProfile, uploadContent, -} = require('./lib/client-utils'); -const TestError = require('./lib/test-error'); +} = require('./test-utils/client-utils'); +const TestError = require('./test-utils/test-error'); const testMatrixServerUrl1 = config.get('testMatrixServerUrl1'); const testMatrixServerUrl2 = config.get('testMatrixServerUrl2'); @@ -41,6 +49,27 @@ const HOMESERVER_URL_TO_PRETTY_NAME_MAP = { [testMatrixServerUrl1]: 'hs1', [testMatrixServerUrl2]: 'hs2', }; +function assertExpectedTimePrecisionAgainstUrl(expectedTimePrecision, url) { + const urlObj = new URL(url, basePath); + const urlPathname = urlObj.pathname; + + // First check the URL has the appropriate time precision + if (expectedTimePrecision === null) { + assert.doesNotMatch( + urlPathname, + /T[\d:]*?$/, + `Expected the URL to *not* have any time precision but saw ${urlPathname}` + ); + } else if (expectedTimePrecision === TIME_PRECISION_VALUES.minutes) { + assert.match(urlPathname, /T\d\d:\d\d$/); + } else if (expectedTimePrecision === TIME_PRECISION_VALUES.seconds) { + assert.match(urlPathname, /T\d\d:\d\d:\d\d$/); + } else { + throw new Error( + `\`expectedTimePrecision\` was an unexpected value ${expectedTimePrecision} which we don't know how to assert here` + ); + } +} describe('matrix-public-archive', () => { let server; @@ -64,7 +93,7 @@ describe('matrix-public-archive', () => { // Create a room on hs2 const hs2RoomId = await createTestRoom(hs2Client); - const room2EventIds = await createMessagesInRoom({ + const { eventIds: room2EventIds } = await createMessagesInRoom({ client: hs2Client, roomId: hs2RoomId, numMessages: 10, @@ -431,7 +460,7 @@ describe('matrix-public-archive', () => { // Create a room on hs2 const hs2RoomId = await createTestRoom(hs2Client); - const room2EventIds = await createMessagesInRoom({ + const { eventIds: room2EventIds } = await createMessagesInRoom({ client: hs2Client, roomId: hs2RoomId, numMessages: 3, @@ -460,7 +489,7 @@ describe('matrix-public-archive', () => { ); }); - it('redirects to last day with message history', async () => { + it('redirects to most recent day with message history', async () => { const client = await getTestClientForHs(testMatrixServerUrl1); const roomId = await createTestRoom(client); @@ -475,7 +504,7 @@ describe('matrix-public-archive', () => { }); const expectedEventIdsOnDay = [eventId]; - // Visit `/:roomIdOrAlias` and expect to be redirected to the last day with events + // Visit `/:roomIdOrAlias` and expect to be redirected to the most recent day with events archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForRoom(roomId); const { data: archivePageHtml } = await fetchEndpointAsText(archiveUrl); @@ -538,7 +567,7 @@ describe('matrix-public-archive', () => { ); }); - it('shows no events summary when no messages at or before the given day', async () => { + it('shows no events summary when no messages at or before the given day (empty view)', async () => { const client = await getTestClientForHs(testMatrixServerUrl1); const roomId = await createTestRoom(client); @@ -557,84 +586,16 @@ describe('matrix-public-archive', () => { ); }); - it(`will redirect to hour pagination when there are too many messages on the same day`, async () => { - const client = await getTestClientForHs(testMatrixServerUrl1); - const roomId = await createTestRoom(client); - // Set this low so we can easily create more than the limit - config.set('archiveMessageLimit', 3); - - // Create more messages than the limit - await createMessagesInRoom({ - client, - roomId: roomId, - // This is larger than the `archiveMessageLimit` we set - numMessages: 5, - prefix: 'events in room', - timestamp: archiveDate.getTime(), - }); - - archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate); - const { data: archivePageHtml } = await fetchEndpointAsText(archiveUrl); - - assert.match(archivePageHtml, /TODO: Redirect user to smaller hour range/); - }); - - it(`will not redirect to hour pagination when there are too many messages from surrounding days`, async () => { - const client = await getTestClientForHs(testMatrixServerUrl1); - const roomId = await createTestRoom(client); - // Set this low so we can easily create more than the limit - config.set('archiveMessageLimit', 3); - - // Create more messages than the limit on a previous day - const previousArchiveDate = new Date(Date.UTC(2022, 0, 2)); - assert( - previousArchiveDate < archiveDate, - `The previousArchiveDate=${previousArchiveDate} should be before the archiveDate=${archiveDate}` - ); - const surroundEventIds = await createMessagesInRoom({ - client, - roomId: roomId, - numMessages: 2, - prefix: 'events in room', - timestamp: previousArchiveDate.getTime(), - }); - - // Create more messages than the limit - const eventIdsOnDay = await createMessagesInRoom({ - client, - roomId: roomId, - numMessages: 2, - prefix: 'events in room', - timestamp: archiveDate.getTime(), - }); - - archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate); - const { data: archivePageHtml } = await fetchEndpointAsText(archiveUrl); - - const dom = parseHTML(archivePageHtml); - - // Make sure the messages are displayed - const expectedEventIdsToBeDisplayed = [].concat(surroundEventIds).concat(eventIdsOnDay); - assert.deepStrictEqual( - expectedEventIdsToBeDisplayed.map((eventId) => { - return dom.document - .querySelector(`[data-event-id="${eventId}"]`) - ?.getAttribute('data-event-id'); - }), - expectedEventIdsToBeDisplayed - ); - }); - it('404 when trying to view a future day', async () => { const client = await getTestClientForHs(testMatrixServerUrl1); const roomId = await createTestRoom(client); try { - const TWO_DAY_MS = 2 * 24 * 60 * 60 * 1000; + const TWO_DAYS_IN_MS = 2 * ONE_DAY_IN_MS; await fetchEndpointAsText( matrixPublicArchiveURLCreator.archiveUrlForDate( roomId, - new Date(Date.now() + TWO_DAY_MS) + new Date(Date.now() + TWO_DAYS_IN_MS) ) ); assert.fail( @@ -655,236 +616,1387 @@ describe('matrix-public-archive', () => { } }); + describe('time selector', () => { + it('shows time selector when there are too many messages from the same day', async () => { + // Set this low so it's easy to hit the limit + config.set('archiveMessageLimit', 3); + + const client = await getTestClientForHs(testMatrixServerUrl1); + const roomId = await createTestRoom(client); + + await createMessagesInRoom({ + client, + roomId, + // This should be greater than the `archiveMessageLimit` + numMessages: 10, + prefix: `foo`, + timestamp: archiveDate.getTime(), + // Just spread things out a bit so the event times are more obvious + // and stand out from each other while debugging + increment: ONE_HOUR_IN_MS, + }); + + archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate); + const { data: archivePageHtml } = await fetchEndpointAsText(archiveUrl); + const dom = parseHTML(archivePageHtml); + + // Make sure the time selector is visible + const timeSelectorElement = dom.document.querySelector(`[data-testid="time-selector"]`); + assert(timeSelectorElement); + }); + + it('shows time selector when there are too many messages from the same day but paginated forward into days with no messages', async () => { + // Set this low so it's easy to hit the limit + config.set('archiveMessageLimit', 3); + + const client = await getTestClientForHs(testMatrixServerUrl1); + const roomId = await createTestRoom(client); + + await createMessagesInRoom({ + client, + roomId, + // This should be greater than the `archiveMessageLimit` + numMessages: 10, + prefix: `foo`, + timestamp: archiveDate.getTime(), + // Just spread things out a bit so the event times are more obvious + // and stand out from each other while debugging + increment: ONE_HOUR_IN_MS, + }); + + // Visit a day after when the messages were sent but there weren't + const visitArchiveDate = new Date(Date.UTC(2022, 0, 20)); + assert( + visitArchiveDate > archiveDate, + 'The date we visit the archive (`visitArchiveDate`) should be after where the messages were sent (`archiveDate`)' + ); + archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, visitArchiveDate); + const { data: archivePageHtml } = await fetchEndpointAsText(archiveUrl); + const dom = parseHTML(archivePageHtml); + + // Make sure the time selector is visible + const timeSelectorElement = dom.document.querySelector(`[data-testid="time-selector"]`); + assert(timeSelectorElement); + }); + + it('does not show time selector when all events from the same day but not over the limit', async () => { + // Set this low so we don't have to deal with many messages in the tests + config.set('archiveMessageLimit', 5); + + const client = await getTestClientForHs(testMatrixServerUrl1); + const roomId = await createTestRoom(client); + + await createMessagesInRoom({ + client, + roomId, + // This should be lesser than the `archiveMessageLimit` + numMessages: 3, + prefix: `foo`, + timestamp: archiveDate.getTime(), + // Just spread things out a bit so the event times are more obvious + // and stand out from each other while debugging + increment: ONE_HOUR_IN_MS, + }); + + archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate); + const { data: archivePageHtml } = await fetchEndpointAsText(archiveUrl); + const dom = parseHTML(archivePageHtml); + + // Make sure the time selector is *NOT* visible + const timeSelectorElement = dom.document.querySelector(`[data-testid="time-selector"]`); + assert.strictEqual(timeSelectorElement, null); + }); + + it('does not show time selector when showing events from multiple days', async () => { + // Set this low so we don't have to deal with many messages in the tests + config.set('archiveMessageLimit', 5); + + const client = await getTestClientForHs(testMatrixServerUrl1); + const roomId = await createTestRoom(client); + + // Create more messages than the archiveMessageLimit across many days but we + // should not go over the limit on a daily basis + const dayBeforeArchiveDateTs = Date.UTC( + archiveDate.getUTCFullYear(), + archiveDate.getUTCMonth(), + archiveDate.getUTCDate() - 1 + ); + await createMessagesInRoom({ + client, + roomId, + // This should be lesser than the `archiveMessageLimit` + numMessages: 3, + prefix: `foo`, + timestamp: dayBeforeArchiveDateTs, + // Just spread things out a bit so the event times are more obvious + // and stand out from each other while debugging + increment: ONE_HOUR_IN_MS, + }); + await createMessagesInRoom({ + client, + roomId, + // This should be lesser than the `archiveMessageLimit` + numMessages: 3, + prefix: `foo`, + timestamp: archiveDate.getTime(), + // Just spread things out a bit so the event times are more obvious + // and stand out from each other while debugging + increment: ONE_HOUR_IN_MS, + }); + + archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate); + const { data: archivePageHtml } = await fetchEndpointAsText(archiveUrl); + const dom = parseHTML(archivePageHtml); + + // Make sure the time selector is *NOT* visible + const timeSelectorElement = dom.document.querySelector(`[data-testid="time-selector"]`); + assert.strictEqual(timeSelectorElement, null); + }); + }); + describe('Jump forwards and backwards', () => { - let client; - let roomId; - let previousDayToEventMap; - beforeEach(async () => { - // Set this low so we can easily create more than the limit - config.set('archiveMessageLimit', 4); + const jumpTestCases = [ + { + // In order to jump from the 1st page to the 2nd, we first jump forward 4 + // messages, then back-track to the first date boundary which is day3. We do + // this so that we don't start from day4 backwards which would miss messages + // because there are more than 5 messages in between day4 and day2. + // + // Even though there is overlap between the pages, our scroll continues from + // the event where the 1st page starts. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 + // [day1 ] [day2 ] [day3 ] [day4 ] + // [1st page ] + // |--jump-fwd-4-messages-->| + // [2nd page ] + testName: 'can jump forward to the next activity', + // Create enough surround messages on nearby days that overflow the page + // limit but don't overflow the limit on a single day basis. We create 4 + // days of messages so we can see a seamless continuation from page1 to + // page2. + dayAndMessageStructure: [3, 3, 3, 3], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/02', + page1: { + urlDate: '2022/01/02', + // (end of day2 backwards) + events: [ + // Some of day1 + 'day1.event1', + 'day1.event2', + // All of day2 + 'day2.event0', + 'day2.event1', + 'day2.event2', + ], + action: 'next', + }, + page2: { + urlDate: '2022/01/03', + // Continuing from the first event of day3 + continueAtEvent: 'day3.event0', + // (end of day3 backwards) + events: [ + // Some of day2 + 'day2.event1', + 'day2.event2', + // All of day3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + ], + action: null, + }, + }, + { + // This test is just slightly different and jumps further into day4 (just a + // slight variation to make sure it still does the correct thing) + // + // In order to jump from the 1st page to the 2nd, we first jump forward 4 + // messages, then back-track to the first date boundary which is day3. There + // is exactly 5 messages between day4 and day2 which would be a perfect next + // page but because we hit the middle of day4, we have no idea how many more + // messages are in day4. + // + // Even though there is overlap between the pages, our scroll continues from + // the event where the 1st page starts. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 + // [day1 ] [day2 ] [day3 ] [day4 ] + // [1st page ] + // |--jump-fwd-4-messages-->| + // [2nd page ] + testName: 'can jump forward to the next activity2', + dayAndMessageStructure: [3, 3, 2, 3], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/02', + page1: { + urlDate: '2022/01/02', + // (end of day2 backwards) + events: [ + // Some of day1 + 'day1.event1', + 'day1.event2', + // All of day2 + 'day2.event0', + 'day2.event1', + 'day2.event2', + ], + action: 'next', + }, + page2: { + urlDate: '2022/01/03', + // Continuing from the first event of day3 + continueAtEvent: 'day3.event0', + // (end of day3 backwards) + events: [ + // All of day2 + 'day2.event0', + 'day2.event1', + 'day2.event2', + // All of day3 + 'day3.event0', + 'day3.event1', + ], + action: null, + }, + }, + { + // In order to jump from the 1st page to the 2nd, we first "jump" forward 4 + // messages by paginating `/messages?limit=4` but it only returns 2x + // messages (event11 and event12) which is less than our limit of 4, so we + // know we reached the end and can simply display the day that the latest + // event occured on. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 + // [day1 ] [day2 ] [day3 ] [day4 ] + // [1st page ] + // |--jump-fwd-4-messages-->| + // [2nd page ] + testName: 'can jump forward to the latest activity in the room (same day)', + dayAndMessageStructure: [3, 3, 3, 3], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/04T01:00', + page1: { + urlDate: '2022/01/04T01:00', + events: [ + // Some of day2 + 'day2.event2', + // Some of day3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + // All of day4 + 'day4.event0', + ], + action: 'next', + }, + page2: { + urlDate: '2022/01/04', + continueAtEvent: 'day4.event1', + events: [ + // Some of day3 + 'day3.event1', + 'day3.event2', + // All of day4 + 'day4.event0', + 'day4.event1', + 'day4.event2', + ], + action: null, + }, + }, + { + // In order to jump from the 1st page to the 2nd, we first "jump" forward 4 + // messages by paginating `/messages?limit=4` but it only returns 3x + // messages (event10, event11, event12) which is less than our limit of 4, + // so we know we reached the end and can simply display the day that the + // latest event occured on. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 + // [day1 ] [day2 ] [day3 ] [day4 ] + // [1st page ] + // |---jump-fwd-4-messages--->| + // [2nd page ] + testName: 'can jump forward to the latest activity in the room (different day)', + dayAndMessageStructure: [3, 3, 3, 3], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/04T02:00', + page1: { + urlDate: '2022/01/04T02:00', + events: [ + // Some of day3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + // All of day4 + 'day4.event0', + 'day4.event1', + ], + action: 'next', + }, + page2: { + urlDate: '2022/01/04', + continueAtEvent: 'day4.event2', + events: [ + // Some of day3 + 'day3.event1', + 'day3.event2', + // All of day4 + 'day4.event0', + 'day4.event1', + 'day4.event2', + ], + action: null, + }, + }, + // This test currently doesn't work because it finds the primordial room + // creation events which are created in now time vs the timestamp massaging we + // do for the message fixtures. We can uncomment this once Synapse supports + // timestamp massaging for `/createRoom`, see + // https://github.com/matrix-org/synapse/issues/15346 + // + // { + // // In order to jump from the 1st page to the 2nd, we first "jump" forward 4 + // // messages by paginating `/messages?limit=4` but it returns no messages + // // which is less than our limit of 4, so we know we reached the end and can + // // simply TODO + // // + // // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 + // // [day1 ] [day2 ] [day3 ] [day4 ] + // // [1st page ] + // // |---jump-fwd-4-messages--->| + // // [2nd page ] + // testName: + // 'can jump forward to the latest activity in the room (when already viewing the latest activity)', + // dayAndMessageStructure: [3, 3, 3, 3], + // // The page limit is 4 but each page will show 5 messages because we + // // fetch one extra to determine overflow. + // archiveMessageLimit: 4, + // startUrlDate: '2022/01/04', + // page1: { + // urlDate: '2022/01/04', + // events: [ + // // Some of day3 + // 'day3.event1', + // 'day3.event2', + // // All of day4 + // 'day4.event0', + // 'day4.event1', + // 'day4.event2', + // ], + // action: 'next', + // }, + // page2: { + // // If we can't find any more messages to paginate to, we just progress the + // // date forward by a day so we can display the empty view for that day. + // urlDate: '2022/01/05', + // // TODO: This page probably doesn't need a continue event + // continueAtEvent: 'TODO', + // events: [ + // // Some of day3 + // 'day3.event1', + // 'day3.event2', + // // All of day4 + // 'day4.event0', + // 'day4.event1', + // 'day4.event2', + // ], + // action: null, + // }, + // }, + { + // Test to make sure we can jump from the 1st page to the 2nd page forwards + // even when it exactly paginates to the last message of the next day. We're + // testing this specifically to ensure that you actually jump to the next + // day (previously failed with naive flawed code). + // + // In order to jump from the 1st page to the 2nd, we first jump forward 3 + // messages, then back-track to the first date boundary which is the nearest + // hour backwards from event9. We use the nearest hour because there is + // less than a day of gap between event6 and event9 and we fallback from + // nearest day to hour boundary. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 + // [day1 ] [day2 ] [day3 ] [day4 ] + // [1st page ] + // |-jump-fwd-3-msg->| + // [2nd page ] + testName: + 'can jump forward to the next activity even when it exactly goes to the end of the next day', + dayAndMessageStructure: [3, 3, 3, 3], + // The page limit is 3 but each page will show 4 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 3, + startUrlDate: '2022/01/02', + page1: { + urlDate: '2022/01/02', + events: [ + // Some of day1 + 'day1.event2', + // All of day2 + 'day2.event0', + 'day2.event1', + 'day2.event2', + ], + action: 'next', + }, + page2: { + // We expect the URL to look like `T02:00` because we're rendering part way + // through day3 and while we could get away with just hour precision, the + // default precision has hours and minutes. + urlDate: '2022/01/03T02:00', + // Continuing from the first event of day3 + continueAtEvent: 'day3.event0', + events: [ + // Some of day2 + 'day2.event1', + 'day2.event2', + // Some of day3 + 'day3.event0', + 'day3.event1', + ], + action: null, + }, + }, + { + // From the first page with too many messages, starting at event5(page1 + // rangeStart), we look backwards for the closest event. Because we find + // event4 as the closest, which is from a different day from event9(page1 + // rangeEnd), we can just display the day where event5 resides. + // + // Even though there is overlap between + // the pages, our scroll continues from the event where the 1st page starts. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 + // [day1 ] [day2 ] [day3 ] [day4 ] + // [1st page ] + // [2nd page ] + testName: 'can jump backward to the previous activity', + dayAndMessageStructure: [3, 3, 3, 3], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/03', + page1: { + urlDate: '2022/01/03', + events: [ + // Some of day2 + 'day2.event1', + 'day2.event2', + // All of day3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + ], + action: 'previous', + }, + page2: { + urlDate: '2022/01/02', + // Continuing from the first event of day2 since we already saw the rest + // of day2 in the first page + continueAtEvent: 'day2.event0', + events: [ + // Some of day1 + 'day1.event1', + 'day1.event2', + // All of day2 + 'day2.event0', + 'day2.event1', + 'day2.event2', + ], + action: null, + }, + }, + { + // In order to jump from the 1st page to the 2nd, we first jump forward 8 + // messages, then back-track to the first date boundary which is the nearest + // day backwards from event20. We use the nearest day because there is more + // than a day of gap between event12 and event20. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 <-- 15 <-- 16 <-- 17 <-- 18 <-- 19 <-- 20 <-- 21 + // [day1 ] [day2 ] [day3 ] [day4 ] [day5 ] [day6 ] [day7 ] [day8 ] + // [1st page ] + // |------------------jump-fwd-8-msg---------------------->| + // [2nd page ] + testName: + 'can jump forward over many quiet days without losing any messages in the gap', + dayAndMessageStructure: [3, 3, 3, 3, 2, 2, 2, 3], + // The page limit is 8 but each page will show 9 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 8, + startUrlDate: '2022/01/04', + page1: { + urlDate: '2022/01/04', + events: [ + // All of day2 + 'day2.event0', + 'day2.event1', + 'day2.event2', + // All of day3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + // All of day4 + 'day4.event0', + 'day4.event1', + 'day4.event2', + ], + action: 'next', + }, + page2: { + urlDate: '2022/01/07', + // Continuing from the first event of day5 + continueAtEvent: 'day5.event0', + events: [ + // All of day4 + 'day4.event0', + 'day4.event1', + 'day4.event2', + // All of day5 + 'day5.event0', + 'day5.event1', + // All of day6 + 'day6.event0', + 'day6.event1', + // All of day7 + 'day7.event0', + 'day7.event1', + ], + action: null, + }, + }, + { + // From the first page with too many messages, starting at event13 (page1 + // rangeStart), we look backwards for the closest event. Because we find + // event12 as the closest, which is from the a different day from event21 + // (page1 rangeEnd), we can just display the day where event12 resides. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 <-- 15 <-- 16 <-- 17 <-- 18 <-- 19 <-- 20 <-- 21 + // [day1 ] [day2 ] [day3 ] [day4 ] [day5 ] [day6 ] [day7 ] [day8 ] [day9 ] + // [1st page ] + // [2nd page ] + testName: 'can jump backward to the previous activity with many small quiet days', + dayAndMessageStructure: [2, 2, 2, 2, 2, 2, 3, 3, 3], + // The page limit is 8 but each page will show 9 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 8, + startUrlDate: '2022/01/09', + page1: { + urlDate: '2022/01/09', + events: [ + // All of day7 + 'day7.event0', + 'day7.event1', + 'day7.event2', + // All of day8 + 'day8.event0', + 'day8.event1', + 'day8.event2', + // All of day9 + 'day9.event0', + 'day9.event1', + 'day9.event2', + ], + action: 'previous', + }, + page2: { + urlDate: '2022/01/06', + // Continuing from the last event of day6 + continueAtEvent: 'day6.event1', + events: [ + // Some of day2 + 'day2.event1', + // All of day3 + 'day3.event0', + 'day3.event1', + // All of day4 + 'day4.event0', + 'day4.event1', + // All of day5 + 'day5.event0', + 'day5.event1', + // All of day6 + 'day6.event0', + 'day6.event1', + ], + action: null, + }, + }, + { + // Test to make sure we can jump forwards from the 1st page to the 2nd page + // with too many messages to display on a single day. + // + // We jump forward 4 messages (`archiveMessageLimit`), then back-track to + // the nearest hour which starts us from event9, and then we display 5 + // messages because we fetch one more than `archiveMessageLimit` to + // determine overflow. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 <-- 15 + // [day1 ] [day2 ] [day3 ] [day4 ] + // [1st page ] + // |--jump-fwd-4-messages-->| + // [2nd page ] + testName: 'can jump forward to the next activity and land in too many messages', + dayAndMessageStructure: [3, 3, 6, 3], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/02', + page1: { + urlDate: '2022/01/02', + events: [ + // Some of day 1 + 'day1.event1', + 'day1.event2', + // All of day 2 + 'day2.event0', + 'day2.event1', + 'day2.event2', + ], + action: 'next', + }, + page2: { + // We expect the URL to look like `T03:00` because we're rendering part way + // through day3 and while we could get away with just hour precision, the + // default precision has hours and minutes. + urlDate: '2022/01/03T03:00', + // Continuing from the first event of day3 + continueAtEvent: 'day3.event0', + events: [ + // Some of day 2 + 'day2.event1', + 'day2.event2', + // Some of day 3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + ], + action: null, + }, + }, + { + // Test to make sure we can jump backwards from the 1st page to the 2nd page + // with too many messages to display on a single day. + // + // From the first page with too many messages, starting at event10 (page1 + // rangeStart), we look backwards for the closest event. Because we find + // event9 as the closest, which is from the a different day from event14 + // (page1 rangeEnd), we can just display the day where event9 resides. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 + // [day1 ] [day2 ] [day3 ] [day4 ] + // [1st page ] + // [2nd page ] + testName: 'can jump backward to the previous activity and land in too many messages', + dayAndMessageStructure: [3, 6, 3, 2], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/04', + page1: { + urlDate: '2022/01/04', + events: [ + // All of day 3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + // All of day 4 + 'day4.event0', + 'day4.event1', + ], + action: 'previous', + }, + page2: { + urlDate: '2022/01/02', + // Continuing from the last event of day2 + continueAtEvent: 'day2.event5', + events: [ + // Most of day 2 + 'day2.event1', + 'day2.event2', + 'day2.event3', + 'day2.event4', + 'day2.event5', + ], + action: null, + }, + }, + { + // We jump forward 4 messages (`archiveMessageLimit`) to event12, then + // back-track to the nearest hour which starts off at event11 and render the + // page with 5 messages because we fetch one more than `archiveMessageLimit` + // to determine overflow. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 + // [day1 ] [day2 ] [day3 ] + // [1st page ] + // |---jump-fwd-4-messages--->| + // [2nd page ] + testName: + 'can jump forward from one day with too many messages into the next day with too many messages', + dayAndMessageStructure: [2, 6, 6], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/02', + page1: { + urlDate: '2022/01/02', + events: [ + // Some of day 2 + 'day2.event1', + 'day2.event2', + 'day2.event3', + 'day2.event4', + 'day2.event5', + ], + action: 'next', + }, + page2: { + urlDate: '2022/01/03T03:00', + // Continuing from the first event of day3 + continueAtEvent: 'day3.event0', + events: [ + // Some of day 2 + 'day2.event4', + 'day2.event5', + // Some of day 3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + ], + action: null, + }, + }, + { + // From the first page with too many messages, starting at event10 (page1 + // rangeStart), we look backwards for the closest event. Because we find + // event9 as the closest, which is from the same day as event14 (page1 + // rangeEnd), we round up to the nearest hour so that the URL encompasses it + // when looking backwards. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 + // [day1 ] [day2 ] [day3 ] + // [1st page ] + // [2nd page ] + testName: + 'can jump backward from one day with too many messages into the previous day with too many messages', + dayAndMessageStructure: [2, 6, 6], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/03', + page1: { + urlDate: '2022/01/03', + events: [ + // Some of day 3 + 'day3.event1', + 'day3.event2', + 'day3.event3', + 'day3.event4', + 'day3.event5', + ], + action: 'previous', + }, + page2: { + urlDate: '2022/01/03T01:00', + // Continuing from the first event of day3 + continueAtEvent: 'day3.event0', + events: [ + // Some of day 2 + 'day2.event2', + 'day2.event3', + 'day2.event4', + 'day2.event5', + // Some of day 3 + 'day3.event0', + ], + action: null, + }, + }, + { + // We jump forward 4 messages (`archiveMessageLimit`) to event12, then + // back-track to the nearest hour which starts off at event11 and render the + // page with 5 messages because we fetch one more than `archiveMessageLimit` + // to determine overflow. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 + // [day1 ] [day2 ] + // [1st page ] + // |---jump-fwd-4-messages--->| + // [2nd page ] + testName: + 'can jump forward from one day with too many messages into the same day with too many messages', + dayAndMessageStructure: [2, 12], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/02T6:00', + page1: { + urlDate: '2022/01/02T6:00', + events: [ + // Some of day 2 + 'day2.event1', + 'day2.event2', + 'day2.event3', + 'day2.event4', + 'day2.event5', + ], + action: 'next', + }, + page2: { + urlDate: '2022/01/02T09:00', + // Continuing from the first new event on the page + continueAtEvent: 'day2.event6', + events: [ + // More of day 2 + 'day2.event4', + 'day2.event5', + 'day2.event6', + 'day2.event7', + 'day2.event8', + ], + action: null, + }, + }, + { + // From the first page with too many messages, starting at event10 (page1 + // rangeStart), we look backwards for the closest event. Because we find + // event9 as the closest, which is from the same day as event14 (page1 + // rangeEnd), we round up to the nearest hour so that the URL encompasses it + // when looking backwards. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 + // [day1 ] [day2 ] + // [1st page ] + // [2nd page ] + testName: + 'can jump backward from one day with too many messages into the same day with too many messages', + dayAndMessageStructure: [2, 12], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/02T11:00', + page1: { + urlDate: '2022/01/02T11:00', + events: [ + // Some of day 2 + 'day2.event6', + 'day2.event7', + 'day2.event8', + 'day2.event9', + 'day2.event10', + ], + action: 'previous', + }, + page2: { + urlDate: '2022/01/02T06:00', + // Continuing from the first new event on the page + continueAtEvent: 'day2.event5', + events: [ + // More of day 2 + 'day2.event1', + 'day2.event2', + 'day2.event3', + 'day2.event4', + 'day2.event5', + ], + action: null, + }, + }, + { + // We jump forward 4 messages (`archiveMessageLimit`) to event12, then + // back-track to the nearest hour which starts off at event11 and render the + // page with 5 messages because we fetch one more than `archiveMessageLimit` + // to determine overflow. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 + // [day1 ] [day2 ] [day3 ] + // [1st page ] + // |---jump-fwd-4-messages--->| + // [2nd page ] + testName: + 'can jump forward from the middle of one day with too many messages into the next day with too many messages', + dayAndMessageStructure: [2, 7, 5], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/02T06:00', + page1: { + urlDate: '2022/01/02T06:00', + events: [ + // Some of day 2 + 'day2.event1', + 'day2.event2', + 'day2.event3', + 'day2.event4', + 'day2.event5', + ], + action: 'next', + }, + page2: { + urlDate: '2022/01/03T02:00', + // Continuing from the unseen event in day2 + continueAtEvent: 'day2.event6', + events: [ + // Some of day 2 + 'day2.event4', + 'day2.event5', + 'day2.event6', + // Some of day 3 + 'day3.event0', + 'day3.event1', + ], + action: null, + }, + }, + { + // From the first page with too many messages, starting at event10 (page1 + // rangeStart), we look backwards for the closest event. Because we find + // event9 as the closest, which is from the same day as event14 (page1 + // rangeEnd), we round up to the nearest hour so that the URL encompasses it + // when looking backwards. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 + // [day1 ] [day2 ] [day3 ] + // [1st page ] + // [2nd page ] + testName: + 'can jump backward from the middle of one day with too many messages into the previous day with too many messages', + dayAndMessageStructure: [2, 5, 7], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/03T06:00', + page1: { + urlDate: '2022/01/03T06:00', + events: [ + // Some of day 3 + 'day3.event1', + 'day3.event2', + 'day3.event3', + 'day3.event4', + 'day3.event5', + ], + action: 'previous', + }, + page2: { + urlDate: '2022/01/03T01:00', + // Continuing from the first event of day3 + continueAtEvent: 'day3.event0', + events: [ + // Some of day 2 + 'day2.event1', + 'day2.event2', + 'day2.event3', + 'day2.event4', + // Some of day 3 + 'day3.event0', + ], + action: null, + }, + }, + { + // From the first page with too many messages, starting at event8 (page1 + // rangeStart), we look backwards for the closest event. Because we find + // event7 as the closest, which is from a different day than event12 (page1 + // rangeEnd), we can just display the day where event7 resides. + // + // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 <-- 13 <-- 14 + // [day1 ] [day2 ] [day3 ] + // [1st page ] + // [2nd page ] + testName: + 'can jump backward from the start of one day with too many messages into the previous day with exactly the limit', + dayAndMessageStructure: [2, 5, 7], + // The page limit is 4 but each page will show 5 messages because we + // fetch one extra to determine overflow. + archiveMessageLimit: 4, + startUrlDate: '2022/01/03T05:00', + page1: { + urlDate: '2022/01/03T05:00', + events: [ + // Some of day 3 + 'day3.event0', + 'day3.event1', + 'day3.event2', + 'day3.event3', + 'day3.event4', + ], + action: 'previous', + }, + page2: { + urlDate: '2022/01/02', + continueAtEvent: 'day2.event4', + events: [ + // All of day 2 + 'day2.event0', + 'day2.event1', + 'day2.event2', + 'day2.event3', + 'day2.event4', + ], + action: null, + }, + }, + ]; - client = await getTestClientForHs(testMatrixServerUrl1); - roomId = await createTestRoom(client); + jumpTestCases.forEach((testCase) => { + // eslint-disable-next-line max-statements + it(testCase.testName, async () => { + // Setup + // -------------------------------------- + // -------------------------------------- + const eventMap = {}; + const fancyIdentifierToEventMap = {}; - // Create enough surround messages on previous days that overflow the page limit - // but don't overflow the limit on a single day basis. - // - // We create 4 days of messages so we can see a seamless continuation from - // page1 to page2. The page limit is 4 but each page will show up-to 5 - // messages because we fetch one extra to determine overflow. - // - // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 - // [day 1 ] [day 2 ] [day 3 ] [day 4 ] - // - previousDayToEventMap = new Map(); - for (let i = 1; i < 5; i++) { - // The date should be just past midnight so we don't run into inclusive - // bounds showing messages from more days than we expect in the tests. - const previousArchiveDate = new Date(Date.UTC(2022, 0, i, 1, 0, 0, 1)); + function convertFancyIdentifierListToDebugEventIds(fancyEventIdentifiers) { + // eslint-disable-next-line max-nested-callbacks + return fancyEventIdentifiers.map((fancyId) => { + const eventId = fancyIdentifierToEventMap[fancyId]; + if (!eventId) { + throw new Error( + `Unable to find ${fancyId} in the fancyIdentifierToEventMap=${JSON.stringify( + fancyIdentifierToEventMap, + null, + 2 + )}` + ); + } + const ts = eventMap[eventId]?.originServerTs; + const tsDebugString = ts && `${new Date(ts).toISOString()} (${ts})`; + return `${eventId} (${fancyId}) - ${tsDebugString}`; + }); + } + + function convertEventIdsToDebugEventIds(eventIds) { + // eslint-disable-next-line max-nested-callbacks + return eventIds.map((eventId) => { + const [fancyId] = + Object.entries(fancyIdentifierToEventMap).find( + // eslint-disable-next-line max-nested-callbacks, no-unused-vars -- It's more clear to leave `fancyId` so we can see what we're destructuring from + ([fancyId, eventIdFromFancyMap]) => { + return eventIdFromFancyMap === eventId; + } + ) ?? []; + if (!fancyId) { + throw new Error( + `Unable to find ${eventId} in the fancyIdentifierToEventMap=${JSON.stringify( + fancyIdentifierToEventMap, + null, + 2 + )}` + ); + } + const ts = eventMap[eventId]?.originServerTs; + const tsDebugString = ts && `${new Date(ts).toISOString()} (${ts})`; + return `${eventId} (${fancyId}) - ${tsDebugString}`; + }); + } + + const client = await getTestClientForHs(testMatrixServerUrl1); + const roomId = await createTestRoom(client); + + // Join the archive user to the room before we create the test messages to + // avoid problems jumpting to the latest activity since we can't control the + // timestamp of the membership event. + const archiveAppServiceUserClient = await getTestClientForAs(); + await joinRoom({ + client: archiveAppServiceUserClient, + roomId: roomId, + }); + + const dayIdentifierToDateMap = {}; + const numberOfDaysToConstruct = testCase.dayAndMessageStructure.length; + for (let i = 0; i < numberOfDaysToConstruct; i++) { + const dayNumber = i + 1; + const numMessagesOnDay = testCase.dayAndMessageStructure[i]; + assert( + numMessagesOnDay < 24, + 'Expected less than 24 messages on any given day. Because we increment by an hour ' + + ' for each message, having more than 24 messages would mean that messages would ' + + 'leak into the next day.' + ); + + // The date should be just past midnight so we don't run into inclusive + // bounds leaking messages from one day into another. + const archiveDate = new Date(Date.UTC(2022, 0, dayNumber, 0, 0, 0, 1)); + + dayIdentifierToDateMap[`day${dayNumber}`] = archiveDate; + + const { eventIds: createdEventIds, eventMap: createdEventMap } = + await createMessagesInRoom({ + client, + roomId, + numMessages: numMessagesOnDay, + prefix: `day ${dayNumber} - events in room`, + timestamp: archiveDate.getTime(), + // Just spread things out a bit so the event times are more obvious + // and stand out from each other while debugging and so we just have + // to deal with hour time slicing + increment: ONE_HOUR_IN_MS, + }); + // Make sure we created the same number of events as we expect + assert.strictEqual(createdEventIds.length, numMessagesOnDay); + + // eslint-disable-next-line max-nested-callbacks + createdEventIds.forEach((eventId, i) => { + fancyIdentifierToEventMap[`day${dayNumber}.event${i}`] = eventId; + eventMap[eventId] = createdEventMap.get(eventId); + }); + } + + // Now Test + // -------------------------------------- + // -------------------------------------- + + // Make sure the archive is configured as the test expects assert( - previousArchiveDate < archiveDate, - `The previousArchiveDate=${previousArchiveDate} should be before the archiveDate=${archiveDate}` + Number.isInteger(testCase.archiveMessageLimit) && testCase.archiveMessageLimit > 0, + `testCase.archiveMessageLimit=${testCase.archiveMessageLimit} must be an integer and greater than 0` ); - const eventIds = await createMessagesInRoom({ - client, - roomId, - numMessages: 3, - prefix: `day ${i} - events in room`, - timestamp: previousArchiveDate.getTime(), + config.set('archiveMessageLimit', testCase.archiveMessageLimit); + + // eslint-disable-next-line max-nested-callbacks + const pagesKeyList = Object.keys(testCase).filter((key) => { + const isPageKey = key.startsWith('page'); + if (isPageKey) { + assert.match(key, /page\d+/); + return true; + } + + return false; }); - previousDayToEventMap.set(previousArchiveDate, eventIds); - } + assert( + pagesKeyList.length > 0, + 'You must have at least one `pageX` of expectations in your jump test case' + ); + // Make sure the page are in order + // eslint-disable-next-line max-nested-callbacks + pagesKeyList.reduce((prevPageCount, currentPageKey) => { + const pageNumber = parseInt(currentPageKey.match(/\d+$/)[0], 10); + assert( + prevPageCount + 1 === pageNumber, + `Page numbers must be sorted in each test case but found ` + + `${pageNumber} after ${prevPageCount} - pagesList=${pagesKeyList}` + ); + return pageNumber; + }, 0); + + // Get the URL for the first page to fetch + // + // Set the `archiveUrl` for debugging if the test fails here + const startUrlDate = testCase.startUrlDate; + assert( + startUrlDate, + `\`startUrlDate\` must be defined in your test case: ${JSON.stringify( + testCase, + null, + 2 + )}` + ); + archiveUrl = `${matrixPublicArchiveURLCreator.archiveUrlForRoom( + roomId + )}/date/${startUrlDate}`; + + // Loop through all of the pages of the test and ensure expectations + let alreadyEncounteredLastPage = false; + for (const pageKey of pagesKeyList) { + try { + if (alreadyEncounteredLastPage) { + assert.fail( + 'We should not see any more pages after we already saw a page without an action ' + + `which signals the end of expecations. Encountered ${pageKey} in ${pagesKeyList} ` + + 'after we already thought we were done' + ); + } + + const pageTestMeta = testCase[pageKey]; + + // Fetch the given page. + const { data: archivePageHtml, res: pageRes } = await fetchEndpointAsText( + archiveUrl + ); + const pageDom = parseHTML(archivePageHtml); + + // Assert the correct time precision in the URL + assert.match(pageRes.url, new RegExp(`/date/${pageTestMeta.urlDate}(\\?|$)`)); + + // If provided, assert that it's a smooth continuation to more messages. + // First by checking where the scroll is going to start from + if (pageTestMeta.continueAtEvent) { + const [expectedContinuationDebugEventId] = + convertFancyIdentifierListToDebugEventIds([pageTestMeta.continueAtEvent]); + const urlObj = new URL(pageRes.url, basePath); + const qs = new URLSearchParams(urlObj.search); + const continuationEventId = qs.get('at'); + if (!continuationEventId) { + throw new Error( + `Expected ?at=$xxx query parameter to be defined in the URL=${pageRes.url} but it was ${continuationEventId}. We expect it to match ${expectedContinuationDebugEventId}` + ); + } + const [continationDebugEventId] = convertEventIdsToDebugEventIds([ + continuationEventId, + ]); + assert.strictEqual(continationDebugEventId, expectedContinuationDebugEventId); + } + + const eventIdsOnPage = [...pageDom.document.querySelectorAll(`[data-event-id]`)] + // eslint-disable-next-line max-nested-callbacks + .map((eventEl) => { + return eventEl.getAttribute('data-event-id'); + }); + + // Assert that the page contains all expected events + assert.deepEqual( + convertEventIdsToDebugEventIds(eventIdsOnPage), + convertFancyIdentifierListToDebugEventIds(pageTestMeta.events), + `Events on ${pageKey} should be as expected` + ); + + // Follow the next activity link. Aka, fetch messages for the 2nd page + let actionLinkSelector; + if (pageTestMeta.action === 'next') { + actionLinkSelector = '[data-testid="jump-to-next-activity-link"]'; + } else if (pageTestMeta.action === 'previous') { + actionLinkSelector = '[data-testid="jump-to-previous-activity-link"]'; + } else if (pageTestMeta.action === null) { + // No more pages to test ✅, move on + alreadyEncounteredLastPage = true; + continue; + } else { + throw new Error( + `Unexpected value for ${pageKey}.action=${pageTestMeta.action} that we don't know what to do with` + ); + } + const jumpToActivityLinkEl = pageDom.document.querySelector(actionLinkSelector); + const jumpToActivityLinkHref = jumpToActivityLinkEl.getAttribute('href'); + // Move to the next iteration of the loop + // + // Set this for debugging if the test fails here + archiveUrl = jumpToActivityLinkHref; + } catch (err) { + const errorWithContext = new RethrownError( + `Encountered error while asserting ${pageKey}: ${err.message}`, + err + ); + // Copy these over so mocha generates a nice diff for us + if (err instanceof assert.AssertionError) { + errorWithContext.actual = err.actual; + errorWithContext.expected = err.expected; + } + throw errorWithContext; + } + } + }); }); - it('can jump forward to the next activity', async () => { - // Test to make sure we can jump from the 1st page to the 2nd page forwards. - // - // `previousDayToEventMap` maps each day to the events in that day (3 events - // per day). The page limit is 4 but each page will show 5 messages because we - // fetch one extra to determine overflow. - // - // In order to jump from the 1st page to the 2nd, we first jump forward 4 - // messages, then back-track to the first date boundary which is day 3. We do - // this so that we don't start from day 4 backwards which would miss messages - // because there are more than 5 messages in between day 4 and day 2. - // - // Even though there is overlap between the pages, our scroll continues from - // the event where the 1st page starts. - // - // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 - // [day 1 ] [day 2 ] [day 3 ] [day 4 ] - // [1st page ] - // |--jump-fwd-4-messages-->| - // [2nd page ] - const previousArchiveDates = Array.from(previousDayToEventMap.keys()); - assert.strictEqual( - previousArchiveDates.length, - 4, - `This test expects to work with 4 days of history, each with 3 messages and a page limit of 4 messages previousArchiveDates=${previousArchiveDates}` - ); + const jumpPrecisionTestCases = [ + { + durationMinLabel: 'day', + durationMinMs: ONE_DAY_IN_MS, + durationMaxLabel: 'multiple days', + durationMaxMs: 5 * ONE_DAY_IN_MS, + expectedTimePrecision: TIME_PRECISION_VALUES.none, + }, + { + durationMinLabel: 'hour', + durationMinMs: ONE_HOUR_IN_MS, + durationMaxLabel: 'day', + durationMaxMs: ONE_DAY_IN_MS, + expectedTimePrecision: TIME_PRECISION_VALUES.minutes, + }, + { + durationMinLabel: 'minute', + durationMinMs: ONE_MINUTE_IN_MS, + durationMaxLabel: 'hour', + durationMaxMs: ONE_HOUR_IN_MS, + expectedTimePrecision: TIME_PRECISION_VALUES.minutes, + }, + { + durationMinLabel: 'second', + durationMinMs: ONE_SECOND_IN_MS, + durationMaxLabel: 'minute', + durationMaxMs: ONE_MINUTE_IN_MS, + expectedTimePrecision: TIME_PRECISION_VALUES.seconds, + }, + // This one is expected to fail but we could support it (#support-ms-time-slice) + // { + // durationMinLabel: 'millisecond', + // durationMinMs: 1, + // durationMaxLabel: 'second', + // durationMaxMs: ONE_SECOND_IN_MS, + // // #support-ms-time-slice + // expectedTimePrecision: TIME_PRECISION_VALUES.milliseconds, + // }, + ]; - // Fetch messages for the 1st page (day 2 backwards) - const day2Date = previousArchiveDates[1]; - const firstPageArchiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate( - roomId, - day2Date - ); - // Set this for debugging if the test fails here - archiveUrl = firstPageArchiveUrl; - const { data: firstPageArchivePageHtml } = await fetchEndpointAsText(firstPageArchiveUrl); - const firstPageDom = parseHTML(firstPageArchivePageHtml); + [ + { + directionLabel: 'backward', + directionValue: DIRECTION.backward, + }, + { + directionLabel: 'forward', + directionValue: DIRECTION.forward, + }, + ].forEach(({ directionLabel, directionValue }) => { + describe(`/jump redirects to \`/date/...\` URL that encompasses closest event when looking ${directionLabel}`, () => { + // eslint-disable-next-line max-nested-callbacks + jumpPrecisionTestCases.forEach((testCase) => { + let testTitle; + if (directionValue === DIRECTION.backward) { + testTitle = `will jump to the nearest ${testCase.durationMinLabel} rounded up when the closest event is from the same ${testCase.durationMaxLabel} (but further than a ${testCase.durationMinLabel} away) as our currently displayed range of events`; + } else if (directionValue === DIRECTION.forward) { + testTitle = `will jump to the nearest ${testCase.durationMinLabel} rounded down when the last event in the next range is more than a ${testCase.durationMinLabel} away from our currently displayed range of events`; + } - const eventIdsOnFirstPage = [...firstPageDom.document.querySelectorAll(`[data-event-id]`)] - .map((eventEl) => { - return eventEl.getAttribute('data-event-id'); - }) - .filter((eventId) => { - // Only return valid events. Filter out our `fake-event-id-xxx--x` events - return eventId.startsWith('$'); + // eslint-disable-next-line max-nested-callbacks + it(testTitle, async () => { + // The date should be just past midnight so we don't run into inclusive + // bounds leaking messages from one day into another. + const archiveDate = new Date(Date.UTC(2022, 0, 1, 0, 0, 0, 1)); + + config.set('archiveMessageLimit', 3); + + const client = await getTestClientForHs(testMatrixServerUrl1); + const roomId = await createTestRoom(client); + + const { eventIds, eventMap } = await createMessagesInRoom({ + client, + roomId, + // Make sure there is enough space before and after the selected range + // for another page of history + numMessages: 10, + prefix: `foo`, + timestamp: archiveDate.getTime(), + increment: testCase.durationMinMs, + }); + const fourthEvent = eventMap.get(eventIds[3]); + const sixthEvent = eventMap.get(eventIds[5]); + + const jumpUrl = `${matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(roomId, { + dir: directionValue, + currentRangeStartTs: fourthEvent.originServerTs, + currentRangeEndTs: sixthEvent.originServerTs, + })}`; + // Fetch the given page. + const { res } = await fetchEndpointAsText(jumpUrl); + + // Assert the correct time precision in the URL + assertExpectedTimePrecisionAgainstUrl(testCase.expectedTimePrecision, res.url); + }); }); - - // Assert that the first page contains 5 events (day 2 and day 1) - assert.deepEqual(eventIdsOnFirstPage, [ - // Some of day 1 - ...previousDayToEventMap.get(previousArchiveDates[0]).slice(-2), - // All of day 2 - ...previousDayToEventMap.get(previousArchiveDates[1]), - ]); - - // Follow the next activity link. Aka, fetch messages for the 2nd page - const nextActivityLinkEl = firstPageDom.document.querySelector( - '[data-testid="jump-to-next-activity-link"]' - ); - const nextActivityLink = nextActivityLinkEl.getAttribute('href'); - // Set this for debugging if the test fails here - archiveUrl = nextActivityLink; - const { data: nextActivityArchivePageHtml, res: nextActivityRes } = - await fetchEndpointAsText(nextActivityLink); - const nextActivityDom = parseHTML(nextActivityArchivePageHtml); - - // Assert that it's a smooth continuation to more messages - // - // First by checking where the scroll is going to start from - const urlObj = new URL(nextActivityRes.url, basePath); - const qs = new URLSearchParams(urlObj.search); - assert.strictEqual( - qs.get('at'), - // Continuing from the first event of day 3 - previousDayToEventMap.get(previousArchiveDates[2])[0] - ); - - // Then check the events are on the page correctly - const eventIdsOnNextDay = [ - ...nextActivityDom.document.querySelectorAll(`[data-event-id]`), - ] - .map((eventEl) => { - return eventEl.getAttribute('data-event-id'); - }) - .filter((eventId) => { - // Only return valid events. Filter out our `fake-event-id-xxx--x` events - return eventId.startsWith('$'); - }); - - // Assert that the 2nd page contains 5 events (day 3 and day 2) - assert.deepEqual(eventIdsOnNextDay, [ - // Some of day 2 - ...previousDayToEventMap.get(previousArchiveDates[1]).slice(-2), - // All of day 3 - ...previousDayToEventMap.get(previousArchiveDates[2]), - ]); - }); - - it('can jump backward to the previous activity', async () => { - // Test to make sure we can jump from the 1st page to the 2nd page backwards - // - // `previousDayToEventMap` maps each day to the events in that day (3 events - // per day). The page limit is 4 but each page will show 5 messages because we - // fetch one extra to determine overflow. - // - // The 2nd page continues from the *day* where the 1st page starts. Even - // though there is overlap between the pages, our scroll continues from the - // event where the 1st page starts. - // - // 1 <-- 2 <-- 3 <-- 4 <-- 5 <-- 6 <-- 7 <-- 8 <-- 9 <-- 10 <-- 11 <-- 12 - // [day 1 ] [day 2 ] [day 3 ] [day 4 ] - // [1st page ] - // [2nd page ] - const previousArchiveDates = Array.from(previousDayToEventMap.keys()); - assert.strictEqual( - previousArchiveDates.length, - 4, - `This test expects to work with 4 days of history, each with 3 messages and a page limit of 4 messages previousArchiveDates=${previousArchiveDates}` - ); - - // Fetch messages for the 1st page (day 3 backwards) - const day3Date = previousArchiveDates[2]; - const firstPageArchiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate( - roomId, - day3Date - ); - // Set this for debugging if the test fails here - archiveUrl = firstPageArchiveUrl; - const { data: firstPageArchivePageHtml } = await fetchEndpointAsText(firstPageArchiveUrl); - const firstPageDom = parseHTML(firstPageArchivePageHtml); - - const eventIdsOnFirstPage = [...firstPageDom.document.querySelectorAll(`[data-event-id]`)] - .map((eventEl) => { - return eventEl.getAttribute('data-event-id'); - }) - .filter((eventId) => { - // Only return valid events. Filter out our `fake-event-id-xxx--x` events - return eventId.startsWith('$'); - }); - - // Assert that the first page contains 4 events (day 3 and day 2) - assert.deepEqual(eventIdsOnFirstPage, [ - // Some of day 2 - ...previousDayToEventMap.get(previousArchiveDates[1]).slice(-2), - // All of day 3 - ...previousDayToEventMap.get(previousArchiveDates[2]), - ]); - - // Follow the previous activity link - const previousActivityLinkEl = firstPageDom.document.querySelector( - '[data-testid="jump-to-previous-activity-link"]' - ); - const previousActivityLink = previousActivityLinkEl.getAttribute('href'); - // Set this for debugging if the test fails here - archiveUrl = previousActivityLink; - const { data: previousActivityArchivePageHtml, res: previousActivityRes } = - await fetchEndpointAsText(previousActivityLink); - const previousActivityDom = parseHTML(previousActivityArchivePageHtml); - - // Assert that it's a smooth continuation to more messages - // - // First by checking where the scroll is going to start from - const urlObj = new URL(previousActivityRes.url, basePath); - const qs = new URLSearchParams(urlObj.search); - assert.strictEqual( - qs.get('at'), - // Continuing from the first event of day 2 - previousDayToEventMap.get(previousArchiveDates[1])[0] - ); - - // Then check the events are on the page correctly - const eventIdsOnPreviousDay = [ - ...previousActivityDom.document.querySelectorAll(`[data-event-id]`), - ] - .map((eventEl) => { - return eventEl.getAttribute('data-event-id'); - }) - .filter((eventId) => { - // Only return valid events. Filter out our `fake-event-id-xxx--x` events - return eventId.startsWith('$'); - }); - - // Assert that the 2nd page contains 4 events (day 2 and day 1) - assert.deepEqual(eventIdsOnPreviousDay, [ - // All of day 1 - ...previousDayToEventMap.get(previousArchiveDates[0]).slice(-2), - // All of day 2 - ...previousDayToEventMap.get(previousArchiveDates[1]), - ]); + }); }); }); }); diff --git a/test/shared/lib/timestamp-utilties-tests.js b/test/shared/lib/timestamp-utilties-tests.js new file mode 100644 index 0000000..f456d5b --- /dev/null +++ b/test/shared/lib/timestamp-utilties-tests.js @@ -0,0 +1,499 @@ +'use strict'; + +const assert = require('assert'); + +const { + roundUpTimestampToUtcDay, + roundUpTimestampToUtcHour, + roundUpTimestampToUtcMinute, + roundUpTimestampToUtcSecond, + getUtcStartOfDayTs, + getUtcStartOfHourTs, + getUtcStartOfMinuteTs, + getUtcStartOfSecondTs, + areTimestampsFromSameUtcDay, + areTimestampsFromSameUtcHour, + areTimestampsFromSameUtcMinute, + areTimestampsFromSameUtcSecond, +} = require('matrix-public-archive-shared/lib/timestamp-utilities'); + +describe('timestamp-utilities', () => { + describe('roundUpTimestampToUtcX', () => { + function testRoundUpFunction(roundUpFunctionToTest, testMeta) { + it(`${new Date(testMeta.inputTs).toISOString()} -> ${new Date( + testMeta.expectedTs + ).toISOString()}`, () => { + assert(testMeta.inputTs); + assert(testMeta.expectedTs); + const actualTs = roundUpFunctionToTest(testMeta.inputTs); + assert.strictEqual( + actualTs, + testMeta.expectedTs, + `Expected actualTs=${new Date(actualTs).toISOString()} to be expectedTs=${new Date( + testMeta.expectedTs + ).toISOString()}` + ); + }); + } + + describe('roundUpTimestampToUtcDay', () => { + [ + { + inputTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + expectedTs: new Date('2022-01-16T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.001Z').getTime(), + expectedTs: new Date('2022-01-16T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T00:00:00.001Z').getTime(), + expectedTs: new Date('2022-01-16T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expectedTs: new Date('2022-01-16T00:00:00.000Z').getTime(), + }, + ].forEach((testMeta) => { + testRoundUpFunction(roundUpTimestampToUtcDay, testMeta); + }); + }); + + describe('roundUpTimestampToUtcHour', () => { + [ + { + inputTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + expectedTs: new Date('2022-01-15T06:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.001Z').getTime(), + expectedTs: new Date('2022-01-15T06:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T00:00:00.001Z').getTime(), + expectedTs: new Date('2022-01-15T01:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expectedTs: new Date('2022-01-16T00:00:00.000Z').getTime(), + }, + ].forEach((testMeta) => { + testRoundUpFunction(roundUpTimestampToUtcHour, testMeta); + }); + }); + + describe('roundUpTimestampToUtcMinute', () => { + [ + { + inputTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + expectedTs: new Date('2022-01-15T05:06:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.001Z').getTime(), + expectedTs: new Date('2022-01-15T05:06:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T00:00:00.001Z').getTime(), + expectedTs: new Date('2022-01-15T00:01:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expectedTs: new Date('2022-01-16T00:00:00.000Z').getTime(), + }, + ].forEach((testMeta) => { + testRoundUpFunction(roundUpTimestampToUtcMinute, testMeta); + }); + }); + + describe('roundUpTimestampToUtcSecond', () => { + [ + { + inputTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + expectedTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.001Z').getTime(), + expectedTs: new Date('2022-01-15T05:05:06.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T00:00:00.001Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:01.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expectedTs: new Date('2022-01-16T00:00:00.000Z').getTime(), + }, + ].forEach((testMeta) => { + testRoundUpFunction(roundUpTimestampToUtcSecond, testMeta); + }); + }); + }); + + describe('getUtcStartOfXTs', () => { + function testGetUtcStartOfXFunction(getUtcStartOfXFunctionToTest, testMeta) { + it(`${new Date(testMeta.inputTs).toISOString()} -> ${new Date( + testMeta.expectedTs + ).toISOString()}`, () => { + assert(testMeta.inputTs); + assert(testMeta.expectedTs); + const actualTs = getUtcStartOfXFunctionToTest(testMeta.inputTs); + assert.strictEqual( + actualTs, + testMeta.expectedTs, + `Expected actualTs=${new Date(actualTs).toISOString()} to be expectedTs=${new Date( + testMeta.expectedTs + ).toISOString()}` + ); + }); + } + + describe('getUtcStartOfDayTs', () => { + [ + { + inputTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T15:35:35.750Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T00:00:00.001Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + ].forEach((testMeta) => { + testGetUtcStartOfXFunction(getUtcStartOfDayTs, testMeta); + }); + }); + + describe('getUtcStartOfHourTs', () => { + [ + { + inputTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + expectedTs: new Date('2022-01-15T05:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T15:35:35.750Z').getTime(), + expectedTs: new Date('2022-01-15T15:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T00:00:00.001Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expectedTs: new Date('2022-01-15T23:00:00.000Z').getTime(), + }, + ].forEach((testMeta) => { + testGetUtcStartOfXFunction(getUtcStartOfHourTs, testMeta); + }); + }); + + describe('getUtcStartOfMinuteTs', () => { + [ + { + inputTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + expectedTs: new Date('2022-01-15T05:05:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T15:35:35.750Z').getTime(), + expectedTs: new Date('2022-01-15T15:35:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T00:00:00.001Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expectedTs: new Date('2022-01-15T23:59:00.000Z').getTime(), + }, + ].forEach((testMeta) => { + testGetUtcStartOfXFunction(getUtcStartOfMinuteTs, testMeta); + }); + }); + + describe('getUtcStartOfSecondTs', () => { + [ + { + inputTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + expectedTs: new Date('2022-01-15T05:05:05.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T15:35:35.750Z').getTime(), + expectedTs: new Date('2022-01-15T15:35:35.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T00:00:00.001Z').getTime(), + expectedTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + }, + { + inputTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expectedTs: new Date('2022-01-15T23:59:59.000Z').getTime(), + }, + ].forEach((testMeta) => { + testGetUtcStartOfXFunction(getUtcStartOfSecondTs, testMeta); + }); + }); + }); + + describe('areTimestampsFromSameUtcX', () => { + function testAreTimestampsFromSameXFunction(areTimestampsFromSameXFunctionToTest, testMeta) { + it(`${testMeta.description} -- A=${new Date( + testMeta.inputATs + ).toISOString()} and B=${new Date(testMeta.inputBTs).toISOString()} should${ + testMeta.expected ? '' : ' *NOT*' + } be from the same day`, () => { + assert(testMeta.inputATs); + assert(testMeta.inputBTs); + assert(testMeta.expected !== undefined); + + const actualValue = areTimestampsFromSameXFunctionToTest( + testMeta.inputATs, + testMeta.inputBTs + ); + assert.strictEqual(actualValue, testMeta.expected); + }); + } + + describe('areTimestampsFromSameUtcDay', () => { + [ + { + description: 'same timestamp is considered from the same day', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expected: true, + }, + { + description: 'timestamp from same day', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T05:05:05.005Z').getTime(), + expected: true, + }, + { + description: 'timestamp at extremes of the day', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expected: true, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same day', + inputATs: new Date('2022-01-14T23:59:59.999Z').getTime(), + inputBTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same day (A and B switched)', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-14T23:59:59.999Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is less than a day apart but from different days should *NOT* be considered from the same day', + inputATs: new Date('2022-01-15T04:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-14T20:00:00.000Z').getTime(), + expected: false, + }, + ].forEach((testMeta) => { + testAreTimestampsFromSameXFunction(areTimestampsFromSameUtcDay, testMeta); + }); + }); + + describe('areTimestampsFromSameUtcHour', () => { + [ + { + description: 'same timestamp is considered from the same hour', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expected: true, + }, + { + description: 'timestamp from same hour', + inputATs: new Date('2022-01-15T05:05:05.005Z').getTime(), + inputBTs: new Date('2022-01-15T05:35:35.035Z').getTime(), + expected: true, + }, + { + description: 'timestamp at extremes of the hour', + inputATs: new Date('2022-01-15T23:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expected: true, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same hour', + inputATs: new Date('2022-01-14T23:59:59.999Z').getTime(), + inputBTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same hour (A and B switched)', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-14T23:59:59.999Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is only 1ms from the other but from another hour should *NOT* be considered from the same hour (A and B switched)', + inputATs: new Date('2022-01-14T05:59:59.999Z').getTime(), + inputBTs: new Date('2022-01-15T06:00:00.000Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is less than a hour apart but from different hours should *NOT* be considered from the same hour', + inputATs: new Date('2022-01-15T04:45:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T05:10:00.000Z').getTime(), + expected: false, + }, + ].forEach((testMeta) => { + testAreTimestampsFromSameXFunction(areTimestampsFromSameUtcHour, testMeta); + }); + }); + + describe('areTimestampsFromSameUtcMinute', () => { + [ + { + description: 'same timestamp is considered from the same minute', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expected: true, + }, + { + description: 'timestamp from same minute', + inputATs: new Date('2022-01-15T05:05:05.005Z').getTime(), + inputBTs: new Date('2022-01-15T05:05:35.035Z').getTime(), + expected: true, + }, + { + description: 'timestamp at extremes of the minute', + inputATs: new Date('2022-01-15T23:59:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expected: true, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same minute', + inputATs: new Date('2022-01-14T23:59:59.999Z').getTime(), + inputBTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same minute (A and B switched)', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-14T23:59:59.999Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is only 1ms from the other but from another minute should *NOT* be considered from the same minute (A and B switched)', + inputATs: new Date('2022-01-14T05:05:59.999Z').getTime(), + inputBTs: new Date('2022-01-15T05:06:00.000Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is less than a minute apart but from different minutes should *NOT* be considered from the same minute', + inputATs: new Date('2022-01-15T04:45:45.000Z').getTime(), + inputBTs: new Date('2022-01-15T05:46:10.000Z').getTime(), + expected: false, + }, + ].forEach((testMeta) => { + testAreTimestampsFromSameXFunction(areTimestampsFromSameUtcMinute, testMeta); + }); + }); + + describe('areTimestampsFromSameUtcSecond', () => { + [ + { + description: 'same timestamp is considered from the same second', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expected: true, + }, + { + description: 'timestamp from same second', + inputATs: new Date('2022-01-15T05:05:35.005Z').getTime(), + inputBTs: new Date('2022-01-15T05:05:35.035Z').getTime(), + expected: true, + }, + { + description: 'timestamp at extremes of the second', + inputATs: new Date('2022-01-15T23:59:59.000Z').getTime(), + inputBTs: new Date('2022-01-15T23:59:59.999Z').getTime(), + expected: true, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same second', + inputATs: new Date('2022-01-14T23:59:59.999Z').getTime(), + inputBTs: new Date('2022-01-15T00:00:00.000Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same second (A and B switched)', + inputATs: new Date('2022-01-15T00:00:00.000Z').getTime(), + inputBTs: new Date('2022-01-14T23:59:59.999Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is only 1ms from the other but from another day should *NOT* be considered from the same second (A and B switched)', + inputATs: new Date('2022-01-14T05:05:59.999Z').getTime(), + inputBTs: new Date('2022-01-15T05:06:00.000Z').getTime(), + expected: false, + }, + { + description: + 'timestamp that is less than a second apart but from different seconds should *NOT* be considered from the same second', + inputATs: new Date('2022-01-15T04:45:45.750Z').getTime(), + inputBTs: new Date('2022-01-15T05:45:46.110Z').getTime(), + expected: false, + }, + ].forEach((testMeta) => { + testAreTimestampsFromSameXFunction(areTimestampsFromSameUtcSecond, testMeta); + }); + }); + }); +}); diff --git a/test/lib/client-utils.js b/test/test-utils/client-utils.js similarity index 93% rename from test/lib/client-utils.js rename to test/test-utils/client-utils.js index 12af7d7..4abc50c 100644 --- a/test/lib/client-utils.js +++ b/test/test-utils/client-utils.js @@ -220,28 +220,43 @@ async function sendMessage({ client, roomId, content, timestamp }) { } // Create a number of messages in the given room -async function createMessagesInRoom({ client, roomId, numMessages, prefix, timestamp }) { +async function createMessagesInRoom({ + client, + roomId, + numMessages, + prefix, + timestamp, + // The amount of time between each message + increment = 1, +}) { let eventIds = []; + let eventMap = new Map(); for (let i = 0; i < numMessages; i++) { + const originServerTs = timestamp + i * increment; + const content = { + msgtype: 'm.text', + body: `${prefix} - message${i}`, + }; const eventId = await sendMessage({ client, roomId, - content: { - msgtype: 'm.text', - body: `${prefix} - message${i}`, - }, - // The timestamp doesn't matter if it's the same anymore (since - // https://github.com/matrix-org/synapse/pull/13658) but it still seems - // like a good idea to make the tests more clear. - timestamp: timestamp + i, + content, + // Technically, we don't have to set the timestamp to be unique or sequential but + // it still seems like a good idea to make the tests more clear. + timestamp: originServerTs, }); eventIds.push(eventId); + eventMap.set(eventId, { + roomId, + originServerTs, + content, + }); } // Sanity check that we actually sent some messages assert.strictEqual(eventIds.length, numMessages); - return eventIds; + return { eventIds, eventMap }; } async function updateProfile({ client, displayName, avatarUrl }) { diff --git a/test/lib/test-error.js b/test/test-utils/test-error.js similarity index 100% rename from test/lib/test-error.js rename to test/test-utils/test-error.js