Add a way to select time of day (#139)
- Fix https://github.com/matrix-org/matrix-public-archive/issues/7 - A URL with time looks like - `/r/too-many-messages-on-day:my.synapse.server/date/2022/11/16T23:59` - Or when more precision is required (seconds): `/r/too-many-messages-on-day:my.synapse.server/date/2022/11/16T23:59:59` - Add new custom time picker/scrubber (pictured below) with momentum scrubbing - Native built-in `<input type="time">` for easier picking if you prefer that and accessibility. - Uses localized time strings - Design inspired by Thiago Sanchez's *Time Zone Translate* concept, https://dribbble.com/shots/14590546-Time-Zone-Translate
This commit is contained in:
parent
b0cee80253
commit
954b22995a
|
@ -11,8 +11,8 @@
|
||||||
"build": "node ./build/do-client-build.js",
|
"build": "node ./build/do-client-build.js",
|
||||||
"start": "node server/server.js",
|
"start": "node server/server.js",
|
||||||
"start-dev": "node server/start-dev.js",
|
"start-dev": "node server/start-dev.js",
|
||||||
"test": "npm run mocha -- test/e2e-tests.js --timeout 15000",
|
"test": "npm run mocha -- test/**/*-tests.js --timeout 15000",
|
||||||
"test-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --interactive",
|
"test-e2e-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --interactive",
|
||||||
"nodemon": "nodemon",
|
"nodemon": "nodemon",
|
||||||
"vite": "vite",
|
"vite": "vite",
|
||||||
"mocha": "mocha",
|
"mocha": "mocha",
|
||||||
|
|
|
@ -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 */
|
/* apply a natural box layout model to all elements, but allowing components to change */
|
||||||
html {
|
html {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
@ -131,12 +138,17 @@ summary {
|
||||||
}
|
}
|
||||||
|
|
||||||
.RightPanelContentView {
|
.RightPanelContentView {
|
||||||
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.RightPanelContentView_mainContent > * + * {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
.RightPanelContentView_footer {
|
.RightPanelContentView_footer {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
|
@ -270,18 +282,284 @@ summary {
|
||||||
}
|
}
|
||||||
|
|
||||||
.CalendarView_dayLink_active {
|
.CalendarView_dayLink_active {
|
||||||
background-color: #0dbd8b;
|
background-color: var(--primary-access-color);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.CalendarView_dayLink_active:hover {
|
.CalendarView_dayLink_active:hover {
|
||||||
background-color: #0a8f69;
|
background-color: var(--primary-access-color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.CalendarView_dayLink_disabled {
|
.CalendarView_dayLink_disabled {
|
||||||
opacity: 0.5;
|
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 <input type="time"> 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 */
|
/* Some custom timeline, tiles stuff */
|
||||||
|
|
||||||
.JumpToPreviousActivitySummaryTileView,
|
.JumpToPreviousActivitySummaryTileView,
|
||||||
|
|
|
@ -45,7 +45,6 @@ async function serializeError(err) {
|
||||||
// If we don't listen for these events, the child will exit with status code 1
|
// If we don't listen for these events, the child will exit with status code 1
|
||||||
// (error) when they occur.
|
// (error) when they occur.
|
||||||
process.on('uncaughtException', async (err /*, origin*/) => {
|
process.on('uncaughtException', async (err /*, origin*/) => {
|
||||||
console.log('2 uncaughtException', err);
|
|
||||||
await serializeError(new RethrownError('uncaughtException in child process', err));
|
await serializeError(new RethrownError('uncaughtException in child process', err));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const { traceFunction } = require('../../tracing/trace-utilities');
|
const { traceFunction } = require('../../tracing/trace-utilities');
|
||||||
|
|
||||||
|
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
|
||||||
const timestampToEvent = require('./timestamp-to-event');
|
const timestampToEvent = require('./timestamp-to-event');
|
||||||
const getMessagesResponseFromEventId = require('./get-messages-response-from-event-id');
|
const getMessagesResponseFromEventId = require('./get-messages-response-from-event-id');
|
||||||
|
|
||||||
|
@ -40,7 +41,7 @@ async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limi
|
||||||
accessToken,
|
accessToken,
|
||||||
roomId,
|
roomId,
|
||||||
ts,
|
ts,
|
||||||
direction: 'b',
|
direction: DIRECTION.backward,
|
||||||
});
|
});
|
||||||
eventIdForTimestamp = eventId;
|
eventIdForTimestamp = eventId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -67,7 +68,7 @@ async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limi
|
||||||
eventId: eventIdForTimestamp,
|
eventId: eventIdForTimestamp,
|
||||||
// We go backwards because that's the direction that backfills events (Synapse
|
// We go backwards because that's the direction that backfills events (Synapse
|
||||||
// doesn't backfill in the forward direction)
|
// doesn't backfill in the forward direction)
|
||||||
dir: 'b',
|
dir: DIRECTION.backward,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const urlJoin = require('url-join');
|
const urlJoin = require('url-join');
|
||||||
|
|
||||||
|
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
|
||||||
const { fetchEndpointAsJson } = require('../fetch-endpoint');
|
const { fetchEndpointAsJson } = require('../fetch-endpoint');
|
||||||
|
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
@ -32,6 +33,17 @@ async function getMessagesResponseFromEventId({ accessToken, roomId, eventId, di
|
||||||
accessToken,
|
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
|
// Add `filter={"lazy_load_members":true}` to only get member state events for
|
||||||
// the messages included in the response
|
// the messages included in the response
|
||||||
const messagesEndpoint = urlJoin(
|
const messagesEndpoint = urlJoin(
|
||||||
|
@ -39,7 +51,7 @@ async function getMessagesResponseFromEventId({ accessToken, roomId, eventId, di
|
||||||
`_matrix/client/r0/rooms/${encodeURIComponent(
|
`_matrix/client/r0/rooms/${encodeURIComponent(
|
||||||
roomId
|
roomId
|
||||||
)}/messages?dir=${dir}&from=${encodeURIComponent(
|
)}/messages?dir=${dir}&from=${encodeURIComponent(
|
||||||
contextResData.end
|
paginationToken
|
||||||
)}&limit=${limit}&filter={"lazy_load_members":true}`
|
)}&limit=${limit}&filter={"lazy_load_members":true}`
|
||||||
);
|
);
|
||||||
const { data: messageResData } = await fetchEndpointAsJson(messagesEndpoint, {
|
const { data: messageResData } = await fetchEndpointAsJson(messagesEndpoint, {
|
||||||
|
|
|
@ -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 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 renderHydrogenVmRenderScriptToPageHtml = require('../hydrogen-render/render-hydrogen-vm-render-script-to-page-html');
|
||||||
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
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 config = require('../lib/config');
|
||||||
const basePath = config.get('basePath');
|
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 validSigilList = Object.values(VALID_ENTITY_DESCRIPTOR_TO_SIGIL_MAP);
|
||||||
const sigilRe = new RegExp(`^(${validSigilList.join('|')})`);
|
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) {
|
function getRoomIdOrAliasFromReq(req) {
|
||||||
const entityDescriptor = req.params.entityDescriptor;
|
const entityDescriptor = req.params.entityDescriptor;
|
||||||
// This could be with or with our without the sigil. Although the correct thing here
|
// 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}`;
|
return `${sigil}${roomIdOrAliasWithoutSigil}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line max-statements, complexity
|
||||||
function parseArchiveRangeFromReq(req) {
|
function parseArchiveRangeFromReq(req) {
|
||||||
const yyyy = parseInt(req.params.yyyy, 10);
|
const yyyy = parseInt(req.params.yyyy, 10);
|
||||||
// Month is the only zero-based index in this group
|
// Month is the only zero-based index in this group
|
||||||
const mm = parseInt(req.params.mm, 10) - 1;
|
const mm = parseInt(req.params.mm, 10) - 1;
|
||||||
const dd = parseInt(req.params.dd, 10);
|
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;
|
if (!timeMatches) {
|
||||||
let toHour = 0;
|
throw new StatusError(
|
||||||
if (hourRange) {
|
404,
|
||||||
const hourMatches = hourRange.match(/^(\d\d?)-(\d\d?)$/);
|
'Time was unable to be parsed from URL. It should be in 24-hour format 23:59:59'
|
||||||
|
);
|
||||||
if (!hourMatches) {
|
|
||||||
throw new StatusError(404, 'Hour was unable to be parsed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fromHour = parseInt(hourMatches[1], 10);
|
const hour = timeMatches[1] && parseInt(timeMatches[1], 10);
|
||||||
toHour = parseInt(hourMatches[2], 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) {
|
timeDefined = !!timeMatches;
|
||||||
throw new StatusError(404, 'From hour can only be in range 0-23');
|
// 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;
|
||||||
let toTimestamp = Date.UTC(yyyy, mm, dd + 1, fromHour);
|
if (timeInMs) {
|
||||||
if (hourRange) {
|
const startOfDayTimestamp = Date.UTC(yyyy, mm, dd);
|
||||||
toTimestamp = Date.UTC(yyyy, mm, dd, toHour);
|
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 {
|
return {
|
||||||
fromTimestamp,
|
|
||||||
toTimestamp,
|
toTimestamp,
|
||||||
yyyy,
|
yyyy,
|
||||||
mm,
|
mm,
|
||||||
dd,
|
dd,
|
||||||
hourRange,
|
// Whether the req included time `T23:59`
|
||||||
fromHour,
|
timeDefined,
|
||||||
toHour,
|
// 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.
|
// any of the additional room info or messages.
|
||||||
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
|
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({
|
const { originServerTs } = await timestampToEvent({
|
||||||
accessToken: matrixAccessToken,
|
accessToken: matrixAccessToken,
|
||||||
roomId,
|
roomId,
|
||||||
ts: dateBeforeJoin,
|
ts: dateBeforeJoin,
|
||||||
direction: 'b',
|
direction: DIRECTION.backward,
|
||||||
});
|
});
|
||||||
if (!originServerTs) {
|
if (!originServerTs) {
|
||||||
throw new StatusError(404, 'Unable to find day with history');
|
throw new StatusError(404, 'Unable to find day with history');
|
||||||
|
@ -153,33 +209,139 @@ router.get(
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/jump',
|
'/jump',
|
||||||
// eslint-disable-next-line max-statements
|
// eslint-disable-next-line max-statements, complexity
|
||||||
asyncHandler(async function (req, res) {
|
asyncHandler(async function (req, res) {
|
||||||
const roomIdOrAlias = getRoomIdOrAliasFromReq(req);
|
const roomIdOrAlias = getRoomIdOrAliasFromReq(req);
|
||||||
|
|
||||||
const ts = parseInt(req.query.ts, 10);
|
const currentRangeStartTs = parseInt(req.query.currentRangeStartTs, 10);
|
||||||
assert(!Number.isNaN(ts), '?ts query parameter must be a number');
|
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;
|
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
|
// We have to wait for the room join to happen first before we can use the jump to
|
||||||
// date endpoint
|
// date endpoint
|
||||||
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
|
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
|
||||||
|
|
||||||
let eventIdForTimestamp;
|
let eventIdForClosestEvent;
|
||||||
let originServerTs;
|
let tsForClosestEvent;
|
||||||
|
let newOriginServerTs;
|
||||||
|
let preferredPrecision = null;
|
||||||
try {
|
try {
|
||||||
// Find the closest day to today with messages
|
// We pull this fresh from the config for each request to ensure we have an
|
||||||
({ eventId: eventIdForTimestamp, originServerTs } = await timestampToEvent({
|
// updated value between each e2e test
|
||||||
accessToken: matrixAccessToken,
|
const archiveMessageLimit = config.get('archiveMessageLimit');
|
||||||
roomId,
|
|
||||||
ts: ts,
|
|
||||||
direction: dir,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The goal is to go forward 100 messages, so that when we view the room at that
|
// Find the closest event to the given timestamp
|
||||||
// point going backwards 100 messages, we end up at the perfect sam continuation
|
({ eventId: eventIdForClosestEvent, originServerTs: tsForClosestEvent } =
|
||||||
// spot in the room.
|
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
|
// 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
|
// 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?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
|
// messages backfilled in the forwards direction would be picked up the same going
|
||||||
// backwards.
|
// backwards.
|
||||||
if (dir === 'f') {
|
if (dir === DIRECTION.forward) {
|
||||||
// Use `/messages?dir=f` and get the `end` pagination token to paginate from. And
|
// 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.
|
// 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({
|
const messageResData = await getMessagesResponseFromEventId({
|
||||||
accessToken: matrixAccessToken,
|
accessToken: matrixAccessToken,
|
||||||
roomId,
|
roomId,
|
||||||
eventId: eventIdForTimestamp,
|
eventId: eventIdForClosestEvent,
|
||||||
dir: 'f',
|
dir: DIRECTION.forward,
|
||||||
limit: archiveMessageLimit,
|
limit: archiveMessageLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!messageResData.chunk?.length) {
|
if (!messageResData.chunk?.length) {
|
||||||
throw new StatusError(
|
throw new StatusError(
|
||||||
404,
|
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;
|
messageResData.chunk[messageResData.chunk.length - 1].origin_server_ts;
|
||||||
const dateOfLastMessage = new Date(timestampOfLastMessage);
|
const dateOfLastMessage = new Date(timestampOfLastMessage);
|
||||||
|
|
||||||
// Back track from the last message timestamp to the date boundary. This will
|
// Back-track from the last message timestamp to the nearest date boundary.
|
||||||
// gurantee some overlap with the previous page we jumped from so we don't lose
|
// Because we're back-tracking a couple events here, when we paginate back out
|
||||||
// any messages in the gap.
|
// 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
|
// We could choose to jump to the exact timestamp of the last message instead of
|
||||||
// chunks or time slices
|
// back-tracking but then we get ugly URL's every time you jump instead of being
|
||||||
// (https://github.com/matrix-org/matrix-public-archive/issues/7). For example
|
// able to back-track and round down to the nearest hour in a lot of cases. The
|
||||||
// if we reached into the next day but it has too many messages to show for a
|
// other reason not to return the exact date is maybe there multiple messages at
|
||||||
// given page, we would want to back track until a suitable time slice boundary.
|
// the same timestamp and we will lose messages in the gap it displays more than
|
||||||
// Maybe we need to add a new URL parameter here `?time-slice=true` to indicate
|
// we thought.
|
||||||
// that it's okay to break it up by time slice based on previously having to
|
const msGapFromJumpPointToLastMessage = timestampOfLastMessage - ts;
|
||||||
// view by time slice. We wouldn't want to give
|
const moreThanDayGap = msGapFromJumpPointToLastMessage > ONE_DAY_IN_MS;
|
||||||
const utcMidnightOfDayBefore = Date.UTC(
|
const moreThanHourGap = msGapFromJumpPointToLastMessage > ONE_HOUR_IN_MS;
|
||||||
dateOfLastMessage.getUTCFullYear(),
|
const moreThanMinuteGap = msGapFromJumpPointToLastMessage > ONE_MINUTE_IN_MS;
|
||||||
dateOfLastMessage.getUTCMonth(),
|
const moreThanSecondGap = msGapFromJumpPointToLastMessage > ONE_SECOND_IN_MS;
|
||||||
dateOfLastMessage.getUTCDate()
|
|
||||||
);
|
|
||||||
// We minus 1 from UTC midnight to get to the day before
|
|
||||||
const endOfDayBeforeDate = new Date(utcMidnightOfDayBefore - 1);
|
|
||||||
|
|
||||||
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) {
|
} catch (err) {
|
||||||
const is404Error = err instanceof HTTPResponseError && err.response.status === 404;
|
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
|
// 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) {
|
if (!is404Error) {
|
||||||
throw err;
|
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
|
// 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
|
// day in whatever direction they wanted to go so we can display the empty view for
|
||||||
// that day.
|
// that day.
|
||||||
if (!originServerTs) {
|
if (!newOriginServerTs) {
|
||||||
const tsDate = new Date(ts);
|
let tsAtRangeBoundaryInDirection;
|
||||||
const yyyy = tsDate.getUTCFullYear();
|
if (dir === DIRECTION.backward) {
|
||||||
const mm = tsDate.getUTCMonth();
|
tsAtRangeBoundaryInDirection = currentRangeStartTs;
|
||||||
const dd = tsDate.getUTCDate();
|
} else if (dir === DIRECTION.forward) {
|
||||||
|
tsAtRangeBoundaryInDirection = currentRangeEndTs;
|
||||||
|
}
|
||||||
|
|
||||||
const newDayDelta = dir === 'f' ? 1 : -1;
|
const dateAtRangeBoundaryInDirection = new Date(tsAtRangeBoundaryInDirection);
|
||||||
originServerTs = Date.UTC(yyyy, mm, dd + newDayDelta);
|
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
|
// Redirect to a day with messages
|
||||||
res.redirect(
|
const archiveUrlToRedirecTo = matrixPublicArchiveURLCreator.archiveUrlForDate(
|
||||||
// TODO: Add query parameter that causes the client to start the scroll at the top
|
roomIdOrAlias,
|
||||||
// when jumping forwards so they can continue reading where they left off.
|
new Date(newOriginServerTs),
|
||||||
matrixPublicArchiveURLCreator.archiveUrlForDate(roomIdOrAlias, new Date(originServerTs), {
|
{
|
||||||
// Start the scroll at the next event from where they jumped from (seamless navigation)
|
// 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,
|
// Shows messages from the given date/time looking backwards up to the limit.
|
||||||
// https://gitlab.com/gitterHQ/webapp/-/blob/14954e05c905e8c7cb675efebb89116c07cfaab5/server/handlers/app/archive.js#L190-297
|
|
||||||
router.get(
|
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,
|
timeoutMiddleware,
|
||||||
// eslint-disable-next-line max-statements
|
// eslint-disable-next-line max-statements, complexity
|
||||||
asyncHandler(async function (req, res) {
|
asyncHandler(async function (req, res) {
|
||||||
const roomIdOrAlias = getRoomIdOrAliasFromReq(req);
|
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');
|
const archiveMessageLimit = config.get('archiveMessageLimit');
|
||||||
assert(archiveMessageLimit);
|
assert(archiveMessageLimit);
|
||||||
// Synapse has a max `/messages` limit of 1000
|
// 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'
|
'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 } =
|
const { toTimestamp, timeDefined, secondsDefined } = parseArchiveRangeFromReq(req);
|
||||||
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
|
// Just 404 if anyone is trying to view the future, no need to waste resources on that
|
||||||
const nowTs = Date.now();
|
const nowTs = Date.now();
|
||||||
if (fromTimestamp > nowTs) {
|
if (toTimestamp > roundUpTimestampToUtcDay(nowTs)) {
|
||||||
throw new StatusError(
|
throw new StatusError(
|
||||||
404,
|
404,
|
||||||
`You can't view the history of a room on a future day (${new Date(
|
`You can't view the history of a room on a future day (${new Date(
|
||||||
fromTimestamp
|
toTimestamp
|
||||||
).toISOString()} > ${new Date(nowTs).toISOString()}). Go back`
|
).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
|
// We have to wait for the room join to happen first before we can fetch
|
||||||
// any of the additional room info or messages.
|
// any of the additional room info or messages.
|
||||||
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
|
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
|
// 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
|
// can display messages from surrounding days (currently only from days
|
||||||
// before) so that the quiet rooms don't feel as desolate and broken.
|
// 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({
|
fetchEventsFromTimestampBackwards({
|
||||||
accessToken: matrixAccessToken,
|
accessToken: matrixAccessToken,
|
||||||
roomId,
|
roomId,
|
||||||
ts: toTimestamp,
|
ts: toTimestamp,
|
||||||
// We fetch one more than the `archiveMessageLimit` so that we can see
|
// We fetch one more than the `archiveMessageLimit` so that we can see if there
|
||||||
// there are too many messages from the given day. If we have over the
|
// are too many messages from the given day. If we have over the
|
||||||
// `archiveMessageLimit` number of messages fetching from the given day,
|
// `archiveMessageLimit` number of messages fetching from the given day, it's
|
||||||
// it's acceptable to have them be from surrounding days. But if all 500
|
// acceptable to have them be from surrounding days. But if all 500 messages
|
||||||
// messages (for example) are from the same day, let's redirect to a
|
// (for example) are from the same day, let's redirect to a smaller hour range
|
||||||
// smaller hour range to display.
|
// to display.
|
||||||
limit: archiveMessageLimit + 1,
|
limit: archiveMessageLimit + 1,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
@ -370,26 +583,6 @@ router.get(
|
||||||
shouldIndex = roomData?.historyVisibility === `world_readable`;
|
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 hydrogenStylesUrl = urlJoin(basePath, '/hydrogen-styles.css');
|
||||||
const stylesUrl = urlJoin(basePath, '/css/styles.css');
|
const stylesUrl = urlJoin(basePath, '/css/styles.css');
|
||||||
const jsBundleUrl = urlJoin(basePath, '/js/entry-client-hydrogen.es.js');
|
const jsBundleUrl = urlJoin(basePath, '/js/entry-client-hydrogen.es.js');
|
||||||
|
@ -397,8 +590,8 @@ router.get(
|
||||||
const pageHtml = await renderHydrogenVmRenderScriptToPageHtml(
|
const pageHtml = await renderHydrogenVmRenderScriptToPageHtml(
|
||||||
path.resolve(__dirname, '../../shared/hydrogen-vm-render-script.js'),
|
path.resolve(__dirname, '../../shared/hydrogen-vm-render-script.js'),
|
||||||
{
|
{
|
||||||
fromTimestamp,
|
|
||||||
toTimestamp,
|
toTimestamp,
|
||||||
|
precisionFromUrl,
|
||||||
roomData: {
|
roomData: {
|
||||||
...roomData,
|
...roomData,
|
||||||
// The `canonicalAlias` will take precedence over the `roomId` when present so we only
|
// The `canonicalAlias` will take precedence over the `roomId` when present so we only
|
||||||
|
|
|
@ -8,16 +8,17 @@
|
||||||
const assert = require('matrix-public-archive-shared/lib/assert');
|
const assert = require('matrix-public-archive-shared/lib/assert');
|
||||||
const { Platform, MediaRepository, createNavigation, createRouter } = require('hydrogen-view-sdk');
|
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 ArchiveRoomView = require('matrix-public-archive-shared/views/ArchiveRoomView');
|
||||||
const ArchiveHistory = require('matrix-public-archive-shared/lib/archive-history');
|
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 supressBlankAnchorsReloadingThePage = require('matrix-public-archive-shared/lib/supress-blank-anchors-reloading-the-page');
|
||||||
const ArchiveRoomViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveRoomViewModel');
|
const ArchiveRoomViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveRoomViewModel');
|
||||||
const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable');
|
const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable');
|
||||||
|
|
||||||
const fromTimestamp = window.matrixPublicArchiveContext.fromTimestamp;
|
|
||||||
assert(fromTimestamp);
|
|
||||||
const toTimestamp = window.matrixPublicArchiveContext.toTimestamp;
|
const toTimestamp = window.matrixPublicArchiveContext.toTimestamp;
|
||||||
assert(toTimestamp);
|
assert(toTimestamp);
|
||||||
|
const precisionFromUrl = window.matrixPublicArchiveContext.precisionFromUrl;
|
||||||
|
assert(Object.values(TIME_PRECISION_VALUES).includes(precisionFromUrl));
|
||||||
const roomData = window.matrixPublicArchiveContext.roomData;
|
const roomData = window.matrixPublicArchiveContext.roomData;
|
||||||
assert(roomData);
|
assert(roomData);
|
||||||
const events = window.matrixPublicArchiveContext.events;
|
const events = window.matrixPublicArchiveContext.events;
|
||||||
|
@ -105,8 +106,8 @@ async function mountHydrogen() {
|
||||||
homeserverUrl: config.matrixServerUrl,
|
homeserverUrl: config.matrixServerUrl,
|
||||||
room,
|
room,
|
||||||
// The timestamp from the URL that was originally visited
|
// The timestamp from the URL that was originally visited
|
||||||
dayTimestampFrom: fromTimestamp,
|
|
||||||
dayTimestampTo: toTimestamp,
|
dayTimestampTo: toTimestamp,
|
||||||
|
precisionFromUrl,
|
||||||
scrollStartEventId,
|
scrollStartEventId,
|
||||||
events,
|
events,
|
||||||
stateEventMap,
|
stateEventMap,
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -3,6 +3,7 @@
|
||||||
const urlJoin = require('url-join');
|
const urlJoin = require('url-join');
|
||||||
|
|
||||||
const assert = require('matrix-public-archive-shared/lib/assert');
|
const assert = require('matrix-public-archive-shared/lib/assert');
|
||||||
|
const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values');
|
||||||
|
|
||||||
function qsToUrlPiece(qs) {
|
function qsToUrlPiece(qs) {
|
||||||
if (qs.toString()) {
|
if (qs.toString()) {
|
||||||
|
@ -65,9 +66,22 @@ class URLCreator {
|
||||||
return `${urlJoin(this._basePath, `${urlPath}`)}${qsToUrlPiece(qs)}`;
|
return `${urlJoin(this._basePath, `${urlPath}`)}${qsToUrlPiece(qs)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveUrlForDate(roomIdOrAlias, date, { viaServers = [], scrollStartEventId } = {}) {
|
archiveUrlForDate(
|
||||||
|
roomIdOrAlias,
|
||||||
|
date,
|
||||||
|
{ preferredPrecision = null, viaServers = [], scrollStartEventId } = {}
|
||||||
|
) {
|
||||||
assert(roomIdOrAlias);
|
assert(roomIdOrAlias);
|
||||||
assert(date);
|
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();
|
let qs = new URLSearchParams();
|
||||||
[].concat(viaServers).forEach((viaServer) => {
|
[].concat(viaServers).forEach((viaServer) => {
|
||||||
|
@ -81,19 +95,36 @@ class URLCreator {
|
||||||
|
|
||||||
// Gives the date in YYYY/mm/dd format.
|
// Gives the date in YYYY/mm/dd format.
|
||||||
// date.toISOString() -> 2022-02-16T23:20:04.709Z
|
// 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(roomIdOrAlias);
|
||||||
assert(ts);
|
|
||||||
assert(dir);
|
assert(dir);
|
||||||
|
assert(currentRangeStartTs);
|
||||||
|
assert(currentRangeEndTs);
|
||||||
|
|
||||||
let qs = new URLSearchParams();
|
let qs = new URLSearchParams();
|
||||||
qs.append('ts', ts);
|
|
||||||
qs.append('dir', dir);
|
qs.append('dir', dir);
|
||||||
|
qs.append('currentRangeStartTs', currentRangeStartTs);
|
||||||
|
qs.append('currentRangeEndTs', currentRangeEndTs);
|
||||||
|
|
||||||
const urlPath = this._getArchiveUrlPathForRoomIdOrAlias(roomIdOrAlias);
|
const urlPath = this._getArchiveUrlPathForRoomIdOrAlias(roomIdOrAlias);
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ const assert = require('matrix-public-archive-shared/lib/assert');
|
||||||
const ModalViewModel = require('matrix-public-archive-shared/viewmodels/ModalViewModel');
|
const ModalViewModel = require('matrix-public-archive-shared/viewmodels/ModalViewModel');
|
||||||
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
||||||
const CalendarViewModel = require('matrix-public-archive-shared/viewmodels/CalendarViewModel');
|
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 DeveloperOptionsContentViewModel = require('matrix-public-archive-shared/viewmodels/DeveloperOptionsContentViewModel');
|
||||||
const RightPanelContentView = require('matrix-public-archive-shared/views/RightPanelContentView');
|
const RightPanelContentView = require('matrix-public-archive-shared/views/RightPanelContentView');
|
||||||
const AvatarViewModel = require('matrix-public-archive-shared/viewmodels/AvatarViewModel');
|
const AvatarViewModel = require('matrix-public-archive-shared/viewmodels/AvatarViewModel');
|
||||||
|
@ -23,6 +24,10 @@ const {
|
||||||
customTileClassForEntry,
|
customTileClassForEntry,
|
||||||
} = require('matrix-public-archive-shared/lib/custom-tile-utilities');
|
} = require('matrix-public-archive-shared/lib/custom-tile-utilities');
|
||||||
const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable');
|
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;
|
let txnCount = 0;
|
||||||
function getFakeEventId() {
|
function getFakeEventId() {
|
||||||
|
@ -57,14 +62,14 @@ function makeEventEntryFromEventJson(eventJson, memberEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ArchiveRoomViewModel extends ViewModel {
|
class ArchiveRoomViewModel extends ViewModel {
|
||||||
// eslint-disable-next-line max-statements
|
// eslint-disable-next-line max-statements, complexity
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
const {
|
const {
|
||||||
homeserverUrl,
|
homeserverUrl,
|
||||||
room,
|
room,
|
||||||
dayTimestampFrom,
|
|
||||||
dayTimestampTo,
|
dayTimestampTo,
|
||||||
|
precisionFromUrl,
|
||||||
scrollStartEventId,
|
scrollStartEventId,
|
||||||
events,
|
events,
|
||||||
stateEventMap,
|
stateEventMap,
|
||||||
|
@ -73,15 +78,14 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
} = options;
|
} = options;
|
||||||
assert(homeserverUrl);
|
assert(homeserverUrl);
|
||||||
assert(room);
|
assert(room);
|
||||||
assert(dayTimestampFrom);
|
|
||||||
assert(dayTimestampTo);
|
assert(dayTimestampTo);
|
||||||
|
assert(Object.values(TIME_PRECISION_VALUES).includes(precisionFromUrl));
|
||||||
assert(events);
|
assert(events);
|
||||||
assert(stateEventMap);
|
assert(stateEventMap);
|
||||||
assert(shouldIndex !== undefined);
|
assert(shouldIndex !== undefined);
|
||||||
assert(events);
|
assert(events);
|
||||||
|
|
||||||
this._room = room;
|
this._room = room;
|
||||||
this._dayTimestampFrom = dayTimestampFrom;
|
|
||||||
this._dayTimestampTo = dayTimestampTo;
|
this._dayTimestampTo = dayTimestampTo;
|
||||||
this._currentTopPositionEventEntry = null;
|
this._currentTopPositionEventEntry = null;
|
||||||
this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
|
this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
|
||||||
|
@ -98,6 +102,11 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
stateEventMap,
|
stateEventMap,
|
||||||
});
|
});
|
||||||
this._eventEntriesByEventId = eventEntriesByEventId;
|
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({
|
this._roomAvatarViewModel = new AvatarViewModel({
|
||||||
homeserverUrlToPullMediaFrom: homeserverUrl,
|
homeserverUrlToPullMediaFrom: homeserverUrl,
|
||||||
|
@ -112,14 +121,50 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
entityId: this._room.id,
|
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({
|
this._calendarViewModel = new CalendarViewModel({
|
||||||
// The day being shown in the archive
|
// The day being shown in the archive
|
||||||
activeDate: initialDate,
|
activeDate: initialActiveDate,
|
||||||
// The month displayed in the calendar
|
// The month displayed in the calendar
|
||||||
calendarDate: initialDate,
|
calendarDate: initialActiveDate,
|
||||||
room,
|
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(
|
this._developerOptionsContentViewModel = new DeveloperOptionsContentViewModel(
|
||||||
|
@ -162,6 +207,8 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
customView: RightPanelContentView,
|
customView: RightPanelContentView,
|
||||||
calendarViewModel: this._calendarViewModel,
|
calendarViewModel: this._calendarViewModel,
|
||||||
|
shouldShowTimeSelector,
|
||||||
|
timeSelectorViewModel: this._timeSelectorViewModel,
|
||||||
shouldIndex,
|
shouldIndex,
|
||||||
get developerOptionsUrl() {
|
get developerOptionsUrl() {
|
||||||
return urlRouter.urlForSegments([
|
return urlRouter.urlForSegments([
|
||||||
|
@ -254,6 +301,7 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
return this._eventEntriesByEventId;
|
return this._eventEntriesByEventId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is the event that appears at the very top of our visible timeline as you scroll around
|
||||||
get currentTopPositionEventEntry() {
|
get currentTopPositionEventEntry() {
|
||||||
return this._currentTopPositionEventEntry;
|
return this._currentTopPositionEventEntry;
|
||||||
}
|
}
|
||||||
|
@ -262,16 +310,19 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
return this._shouldShowRightPanel;
|
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) {
|
setCurrentTopPositionEventEntry(currentTopPositionEventEntry) {
|
||||||
this._currentTopPositionEventEntry = currentTopPositionEventEntry;
|
this._currentTopPositionEventEntry = currentTopPositionEventEntry;
|
||||||
this.emitChange('currentTopPositionEventEntry');
|
this.emitChange('currentTopPositionEventEntry');
|
||||||
|
|
||||||
// Update the calendar
|
// Update the calendar and time scrubber
|
||||||
this._calendarViewModel.setActiveDate(currentTopPositionEventEntry.timestamp);
|
this._calendarViewModel.setActiveDate(currentTopPositionEventEntry.timestamp);
|
||||||
|
this._timeSelectorViewModel.setActiveDate(currentTopPositionEventEntry.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
get dayTimestampFrom() {
|
get dayTimestampTo() {
|
||||||
return this._dayTimestampFrom;
|
return this._dayTimestampTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
get roomDirectoryUrl() {
|
get roomDirectoryUrl() {
|
||||||
|
@ -302,8 +353,22 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
_addJumpSummaryEvents(inputEventList) {
|
_addJumpSummaryEvents(inputEventList) {
|
||||||
const events = [...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 =
|
const hasEventsFromGivenDay =
|
||||||
events[events.length - 1]?.origin_server_ts >= this._dayTimestampFrom;
|
lastEventTs && areTimestampsFromSameUtcDay(lastEventTs, this._dayTimestampTo);
|
||||||
let daySummaryKind;
|
let daySummaryKind;
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
daySummaryKind = 'no-events-at-all';
|
daySummaryKind = 'no-events-at-all';
|
||||||
|
@ -323,12 +388,12 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
type: 'org.matrix.archive.jump_to_previous_activity_summary',
|
type: 'org.matrix.archive.jump_to_previous_activity_summary',
|
||||||
room_id: this._room.id,
|
room_id: this._room.id,
|
||||||
// Even though this isn't used for sort, just using the time where the event
|
// 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,
|
origin_server_ts: events[0].origin_server_ts - 1,
|
||||||
content: {
|
content: {
|
||||||
canonicalAlias: this._room.canonicalAlias,
|
canonicalAlias: this._room.canonicalAlias,
|
||||||
// The start of the range to use as a jumping off point to the previous activity
|
jumpRangeStartTimestamp,
|
||||||
rangeStartTimestamp: events[0].origin_server_ts - 1,
|
jumpRangeEndTimestamp,
|
||||||
// This is a bit cheating but I don't know how else to pass this kind of
|
// This is a bit cheating but I don't know how else to pass this kind of
|
||||||
// info to the Tile viewmodel
|
// info to the Tile viewmodel
|
||||||
basePath: this._basePath,
|
basePath: this._basePath,
|
||||||
|
@ -343,17 +408,15 @@ class ArchiveRoomViewModel extends ViewModel {
|
||||||
type: 'org.matrix.archive.jump_to_next_activity_summary',
|
type: 'org.matrix.archive.jump_to_next_activity_summary',
|
||||||
room_id: this._room.id,
|
room_id: this._room.id,
|
||||||
// Even though this isn't used for sort, just using the time where the event
|
// Even though this isn't used for sort, just using the time where the event
|
||||||
// would logically be.
|
// would logically be (at the end of the day)
|
||||||
//
|
origin_server_ts: this._dayTimestampTo,
|
||||||
// -1 so we're not at 00:00:00 of the next day
|
|
||||||
origin_server_ts: this._dayTimestampTo - 1,
|
|
||||||
content: {
|
content: {
|
||||||
canonicalAlias: this._room.canonicalAlias,
|
canonicalAlias: this._room.canonicalAlias,
|
||||||
daySummaryKind,
|
daySummaryKind,
|
||||||
// The timestamp from the URL that was originally visited
|
// The timestamp from the URL that was originally visited
|
||||||
dayTimestamp: this._dayTimestampFrom,
|
dayTimestamp: this._dayTimestampTo,
|
||||||
// The end of the range to use as a jumping off point to the next activity
|
jumpRangeStartTimestamp,
|
||||||
rangeEndTimestamp: this._dayTimestampTo,
|
jumpRangeEndTimestamp,
|
||||||
// This is a bit cheating but I don't know how else to pass this kind of
|
// This is a bit cheating but I don't know how else to pass this kind of
|
||||||
// info to the Tile viewmodel
|
// info to the Tile viewmodel
|
||||||
basePath: this._basePath,
|
basePath: this._basePath,
|
||||||
|
|
|
@ -3,23 +3,22 @@
|
||||||
const { ViewModel } = require('hydrogen-view-sdk');
|
const { ViewModel } = require('hydrogen-view-sdk');
|
||||||
|
|
||||||
const assert = require('matrix-public-archive-shared/lib/assert');
|
const assert = require('matrix-public-archive-shared/lib/assert');
|
||||||
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
|
||||||
|
|
||||||
class CalendarViewModel extends ViewModel {
|
class CalendarViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
const { activeDate, calendarDate, room, basePath } = options;
|
const { activeDate, calendarDate, room, matrixPublicArchiveURLCreator } = options;
|
||||||
assert(activeDate);
|
assert(activeDate);
|
||||||
assert(calendarDate);
|
assert(calendarDate);
|
||||||
assert(room);
|
assert(room);
|
||||||
assert(basePath);
|
assert(matrixPublicArchiveURLCreator);
|
||||||
|
|
||||||
// The day being shown in the archive
|
// The day being shown in the archive
|
||||||
this._activeDate = activeDate;
|
this._activeDate = activeDate;
|
||||||
// The month displayed in the calendar
|
// The month displayed in the calendar
|
||||||
this._calendarDate = calendarDate;
|
this._calendarDate = calendarDate;
|
||||||
this._room = room;
|
this._room = room;
|
||||||
this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
|
this._matrixPublicArchiveURLCreator = matrixPublicArchiveURLCreator;
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeDate() {
|
get activeDate() {
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
const { SimpleTile } = require('hydrogen-view-sdk');
|
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 MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
||||||
const assert = require('../lib/assert');
|
const assert = require('../lib/assert');
|
||||||
|
|
||||||
|
@ -27,17 +28,23 @@ class JumpToNextActivitySummaryTileViewModel extends SimpleTile {
|
||||||
return this._entry?.content?.['dayTimestamp'];
|
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
|
// The end of the range to use as a jumping off point to the next activity
|
||||||
get rangeEndTimestamp() {
|
get jumpRangeEndTimestamp() {
|
||||||
return this._entry?.content?.['rangeEndTimestamp'];
|
return this._entry?.content?.['jumpRangeEndTimestamp'];
|
||||||
}
|
}
|
||||||
|
|
||||||
get jumpToNextActivityUrl() {
|
get jumpToNextActivityUrl() {
|
||||||
return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(
|
return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(
|
||||||
this._entry?.content?.['canonicalAlias'] || this._entry.roomId,
|
this._entry?.content?.['canonicalAlias'] || this._entry.roomId,
|
||||||
{
|
{
|
||||||
ts: this.rangeEndTimestamp,
|
dir: DIRECTION.forward,
|
||||||
dir: 'f',
|
currentRangeStartTs: this.jumpRangeStartTimestamp,
|
||||||
|
currentRangeEndTs: this.jumpRangeEndTimestamp,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
const { SimpleTile } = require('hydrogen-view-sdk');
|
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 MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
||||||
const assert = require('../lib/assert');
|
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
|
// The start of the range to use as a jumping off point to the previous activity
|
||||||
get rangeStartTimestamp() {
|
get jumpRangeStartTimestamp() {
|
||||||
return this._entry?.content?.['rangeStartTimestamp'];
|
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() {
|
get jumpToPreviousActivityUrl() {
|
||||||
return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(
|
return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(
|
||||||
this._entry?.content?.['canonicalAlias'] || this._entry.roomId,
|
this._entry?.content?.['canonicalAlias'] || this._entry.roomId,
|
||||||
{
|
{
|
||||||
ts: this.rangeStartTimestamp,
|
dir: DIRECTION.backward,
|
||||||
dir: 'b',
|
currentRangeStartTs: this.jumpRangeStartTimestamp,
|
||||||
|
currentRangeEndTs: this.jumpRangeEndTimestamp,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -92,8 +92,8 @@ class DisabledComposerView extends TemplateView {
|
||||||
const activeDate = new Date(
|
const activeDate = new Date(
|
||||||
// If the date from our `archiveRoomViewModel` is available, use that
|
// If the date from our `archiveRoomViewModel` is available, use that
|
||||||
vm?.currentTopPositionEventEntry?.timestamp ||
|
vm?.currentTopPositionEventEntry?.timestamp ||
|
||||||
// Otherwise, use our initial `dayTimestampFrom`
|
// Otherwise, use our initial `dayTimestampTo`
|
||||||
vm.dayTimestampFrom
|
vm.dayTimestampTo
|
||||||
);
|
);
|
||||||
const dateString = activeDate.toISOString().split('T')[0];
|
const dateString = activeDate.toISOString().split('T')[0];
|
||||||
return t.span(`You're viewing an archive of events from ${dateString}. Use a `);
|
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 {
|
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) {
|
render(t, vm) {
|
||||||
const rootElement = t.div(
|
const rootElement = t.div(
|
||||||
{
|
{
|
||||||
|
@ -126,7 +139,7 @@ class ArchiveRoomView extends TemplateView {
|
||||||
// The red border and yellow background trail around the event that is
|
// The red border and yellow background trail around the event that is
|
||||||
// driving the active date as you scroll around.
|
// driving the active date as you scroll around.
|
||||||
t.if(
|
t.if(
|
||||||
(vm) => vm._developerOptionsViewModel?.debugActiveDateIntersectionObserver,
|
(vm) => vm._developerOptionsContentViewModel?.debugActiveDateIntersectionObserver,
|
||||||
(t /*, vm*/) => {
|
(t /*, vm*/) => {
|
||||||
return t.style({}, (vm) => {
|
return t.style({}, (vm) => {
|
||||||
return `
|
return `
|
||||||
|
@ -150,6 +163,9 @@ class ArchiveRoomView extends TemplateView {
|
||||||
t.view(new DisabledComposerView(vm)),
|
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.view(new RightPanelView(vm.rightPanelModel)),
|
||||||
t.mapView(
|
t.mapView(
|
||||||
(vm) => vm.lightboxViewModel,
|
(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') {
|
if (typeof IntersectionObserver === 'function') {
|
||||||
const scrollRoot = rootElement.querySelector('.Timeline_scroller');
|
const scrollRoot = rootElement.querySelector('.Timeline_scroller');
|
||||||
const observer = new IntersectionObserver(
|
this._interSectionObserverForUpdatedTopPositionEventEntry = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
|
@ -188,7 +206,7 @@ class ArchiveRoomView extends TemplateView {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
[...scrollRoot.querySelectorAll(`:scope > ul > [data-event-id]`)].forEach((el) => {
|
[...scrollRoot.querySelectorAll(`:scope > ul > [data-event-id]`)].forEach((el) => {
|
||||||
observer.observe(el);
|
this._interSectionObserverForUpdatedTopPositionEventEntry.observe(el);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,14 +3,9 @@
|
||||||
// Be mindful to do all date operations in UTC (the archive is all in UTC date/times)
|
// Be mindful to do all date operations in UTC (the archive is all in UTC date/times)
|
||||||
|
|
||||||
const { TemplateView } = require('hydrogen-view-sdk');
|
const { TemplateView } = require('hydrogen-view-sdk');
|
||||||
|
const {
|
||||||
function sameDay(date1, date2) {
|
areTimestampsFromSameUtcDay,
|
||||||
return (
|
} = require('matrix-public-archive-shared/lib/timestamp-utilities');
|
||||||
date1.getUTCFullYear() === date2.getUTCFullYear() &&
|
|
||||||
date1.getUTCMonth() === date2.getUTCMonth() &&
|
|
||||||
date1.getUTCDate() === date2.getUTCDate()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the number of days in the given month where the `inputDate` lies.
|
// 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;
|
const isDayInFuture = dayNumberDate.getTime() - todayTs > 0;
|
||||||
|
|
||||||
// The current day displayed in the archive
|
// 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)
|
// day number from 0 (monday) to 6 (sunday)
|
||||||
const dayNumber = dayNumberDate.getUTCDay();
|
const dayNumber = dayNumberDate.getUTCDay();
|
||||||
|
|
|
@ -29,7 +29,6 @@ class JumpToNextActivitySummaryTileView extends TemplateView {
|
||||||
// while we wait for the rest of the JavaScript to load.
|
// while we wait for the rest of the JavaScript to load.
|
||||||
'js-bottom-scroll-anchor': true,
|
'js-bottom-scroll-anchor': true,
|
||||||
},
|
},
|
||||||
'data-event-id': vm.eventId,
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
t.if(
|
t.if(
|
||||||
|
|
|
@ -7,7 +7,6 @@ class JumpToPreviousActivitySummaryTileView extends TemplateView {
|
||||||
return t.div(
|
return t.div(
|
||||||
{
|
{
|
||||||
className: 'JumpToPreviousActivitySummaryTileView',
|
className: 'JumpToPreviousActivitySummaryTileView',
|
||||||
'data-event-id': vm.eventId,
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
t.a(
|
t.a(
|
||||||
|
|
|
@ -78,8 +78,10 @@ class ModalView extends TemplateView {
|
||||||
t.mapSideEffect(
|
t.mapSideEffect(
|
||||||
(vm) => vm.open,
|
(vm) => vm.open,
|
||||||
(open) => {
|
(open) => {
|
||||||
// The dialog has to be in the DOM before we can call `showModal`, etc.
|
// The dialog has to be in the DOM before we can call `showModal`, etc. Assume
|
||||||
// Assume this view will be mounted in the parent DOM straight away.
|
// 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(() => {
|
requestAnimationFrame(() => {
|
||||||
// Prevent doing extra work if the modal is already closed or open and already
|
// Prevent doing extra work if the modal is already closed or open and already
|
||||||
// matches our intention
|
// matches our intention
|
||||||
|
|
|
@ -3,11 +3,14 @@
|
||||||
const { TemplateView } = require('hydrogen-view-sdk');
|
const { TemplateView } = require('hydrogen-view-sdk');
|
||||||
|
|
||||||
const CalendarView = require('matrix-public-archive-shared/views/CalendarView');
|
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');
|
const assert = require('matrix-public-archive-shared/lib/assert');
|
||||||
|
|
||||||
class RightPanelContentView extends TemplateView {
|
class RightPanelContentView extends TemplateView {
|
||||||
render(t, vm) {
|
render(t, vm) {
|
||||||
assert(vm.shouldIndex !== undefined);
|
assert(vm.shouldIndex !== undefined);
|
||||||
|
assert(vm.shouldShowTimeSelector !== undefined);
|
||||||
|
|
||||||
let maybeIndexedMessage = 'This room is not being indexed by search engines.';
|
let maybeIndexedMessage = 'This room is not being indexed by search engines.';
|
||||||
if (vm.shouldIndex) {
|
if (vm.shouldIndex) {
|
||||||
maybeIndexedMessage = 'This room is being indexed by search engines.';
|
maybeIndexedMessage = 'This room is being indexed by search engines.';
|
||||||
|
@ -15,25 +18,25 @@ class RightPanelContentView extends TemplateView {
|
||||||
|
|
||||||
return t.div(
|
return t.div(
|
||||||
{
|
{
|
||||||
className: {
|
className: 'RightPanelContentView',
|
||||||
RightPanelContentView: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
t.view(new CalendarView(vm.calendarViewModel)),
|
t.div({ className: 'RightPanelContentView_mainContent' }, [
|
||||||
t.div(
|
t.view(new CalendarView(vm.calendarViewModel)),
|
||||||
|
t.ifView(
|
||||||
|
(vm) => vm.shouldShowTimeSelector,
|
||||||
|
(vm) => new TimeSelectorView(vm.timeSelectorViewModel)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
t.footer(
|
||||||
{
|
{
|
||||||
className: {
|
className: 'RightPanelContentView_footer',
|
||||||
RightPanelContentView_footer: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
t.p(maybeIndexedMessage),
|
t.p(maybeIndexedMessage),
|
||||||
t.div(
|
t.div(
|
||||||
{
|
{
|
||||||
className: {
|
className: 'RightPanelContentView_footerLinkList',
|
||||||
RightPanelContentView_footerLinkList: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
t.a(
|
t.a(
|
||||||
|
|
|
@ -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 `<input type="time">` 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 <input>
|
||||||
|
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 `<input>` 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;
|
1704
test/e2e-tests.js
1704
test/e2e-tests.js
File diff suppressed because it is too large
Load Diff
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -220,28 +220,43 @@ async function sendMessage({ client, roomId, content, timestamp }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a number of messages in the given room
|
// 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 eventIds = [];
|
||||||
|
let eventMap = new Map();
|
||||||
for (let i = 0; i < numMessages; i++) {
|
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({
|
const eventId = await sendMessage({
|
||||||
client,
|
client,
|
||||||
roomId,
|
roomId,
|
||||||
content: {
|
content,
|
||||||
msgtype: 'm.text',
|
// Technically, we don't have to set the timestamp to be unique or sequential but
|
||||||
body: `${prefix} - message${i}`,
|
// it still seems like a good idea to make the tests more clear.
|
||||||
},
|
timestamp: originServerTs,
|
||||||
// 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,
|
|
||||||
});
|
});
|
||||||
eventIds.push(eventId);
|
eventIds.push(eventId);
|
||||||
|
eventMap.set(eventId, {
|
||||||
|
roomId,
|
||||||
|
originServerTs,
|
||||||
|
content,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanity check that we actually sent some messages
|
// Sanity check that we actually sent some messages
|
||||||
assert.strictEqual(eventIds.length, numMessages);
|
assert.strictEqual(eventIds.length, numMessages);
|
||||||
|
|
||||||
return eventIds;
|
return { eventIds, eventMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile({ client, displayName, avatarUrl }) {
|
async function updateProfile({ client, displayName, avatarUrl }) {
|
Loading…
Reference in New Issue