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