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",
|
||||
"start": "node server/server.js",
|
||||
"start-dev": "node server/start-dev.js",
|
||||
"test": "npm run mocha -- test/e2e-tests.js --timeout 15000",
|
||||
"test-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --interactive",
|
||||
"test": "npm run mocha -- test/**/*-tests.js --timeout 15000",
|
||||
"test-e2e-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --interactive",
|
||||
"nodemon": "nodemon",
|
||||
"vite": "vite",
|
||||
"mocha": "mocha",
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
:root {
|
||||
--primary-access-color: #0dbd8b;
|
||||
--primary-access-color-hover: #0a8f69;
|
||||
|
||||
--text-color-rgb: 46, 47, 50;
|
||||
}
|
||||
|
||||
/* apply a natural box layout model to all elements, but allowing components to change */
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
|
@ -131,12 +138,17 @@ summary {
|
|||
}
|
||||
|
||||
.RightPanelContentView {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.RightPanelContentView_mainContent > * + * {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.RightPanelContentView_footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
|
@ -270,18 +282,284 @@ summary {
|
|||
}
|
||||
|
||||
.CalendarView_dayLink_active {
|
||||
background-color: #0dbd8b;
|
||||
background-color: var(--primary-access-color);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.CalendarView_dayLink_active:hover {
|
||||
background-color: #0a8f69;
|
||||
background-color: var(--primary-access-color-hover);
|
||||
}
|
||||
|
||||
.CalendarView_dayLink_disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.TimeSelectorView {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.TimeSelectorView_header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.TimeSelectorView_primaryTimezoneLabel {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
|
||||
background-color: var(--primary-access-color);
|
||||
/* Always make a pill shape */
|
||||
border-radius: 9999px;
|
||||
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.TimeSelectorView_timeInput {
|
||||
margin-top: 6px;
|
||||
padding: 2px 18px;
|
||||
|
||||
background: transparent;
|
||||
border: 0;
|
||||
/* Always make a pill shape */
|
||||
border-radius: 9999px;
|
||||
|
||||
color: var(--primary-access-color);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Chrome <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 */
|
||||
|
||||
.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
|
||||
// (error) when they occur.
|
||||
process.on('uncaughtException', async (err /*, origin*/) => {
|
||||
console.log('2 uncaughtException', err);
|
||||
await serializeError(new RethrownError('uncaughtException in child process', err));
|
||||
});
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
const assert = require('assert');
|
||||
const { traceFunction } = require('../../tracing/trace-utilities');
|
||||
|
||||
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
|
||||
const timestampToEvent = require('./timestamp-to-event');
|
||||
const getMessagesResponseFromEventId = require('./get-messages-response-from-event-id');
|
||||
|
||||
|
@ -40,7 +41,7 @@ async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limi
|
|||
accessToken,
|
||||
roomId,
|
||||
ts,
|
||||
direction: 'b',
|
||||
direction: DIRECTION.backward,
|
||||
});
|
||||
eventIdForTimestamp = eventId;
|
||||
} catch (err) {
|
||||
|
@ -67,7 +68,7 @@ async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limi
|
|||
eventId: eventIdForTimestamp,
|
||||
// We go backwards because that's the direction that backfills events (Synapse
|
||||
// doesn't backfill in the forward direction)
|
||||
dir: 'b',
|
||||
dir: DIRECTION.backward,
|
||||
limit,
|
||||
});
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
const assert = require('assert');
|
||||
const urlJoin = require('url-join');
|
||||
|
||||
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
|
||||
const { fetchEndpointAsJson } = require('../fetch-endpoint');
|
||||
|
||||
const config = require('../config');
|
||||
|
@ -32,6 +33,17 @@ async function getMessagesResponseFromEventId({ accessToken, roomId, eventId, di
|
|||
accessToken,
|
||||
});
|
||||
|
||||
// We want to re-paginte over the same event so it's included in the response.
|
||||
//
|
||||
// When going backwards, that means starting using the paginatin token after the event
|
||||
// so we can see it looking backwards again.
|
||||
let paginationToken = contextResData.end;
|
||||
// When going forwards, that means starting using the paginatin token before the event
|
||||
// so we can see it looking forwards again.
|
||||
if (dir === DIRECTION.forward) {
|
||||
paginationToken = contextResData.start;
|
||||
}
|
||||
|
||||
// Add `filter={"lazy_load_members":true}` to only get member state events for
|
||||
// the messages included in the response
|
||||
const messagesEndpoint = urlJoin(
|
||||
|
@ -39,7 +51,7 @@ async function getMessagesResponseFromEventId({ accessToken, roomId, eventId, di
|
|||
`_matrix/client/r0/rooms/${encodeURIComponent(
|
||||
roomId
|
||||
)}/messages?dir=${dir}&from=${encodeURIComponent(
|
||||
contextResData.end
|
||||
paginationToken
|
||||
)}&limit=${limit}&filter={"lazy_load_members":true}`
|
||||
);
|
||||
const { data: messageResData } = await fetchEndpointAsJson(messagesEndpoint, {
|
||||
|
|
|
@ -18,6 +18,26 @@ const timestampToEvent = require('../lib/matrix-utils/timestamp-to-event');
|
|||
const getMessagesResponseFromEventId = require('../lib/matrix-utils/get-messages-response-from-event-id');
|
||||
const renderHydrogenVmRenderScriptToPageHtml = require('../hydrogen-render/render-hydrogen-vm-render-script-to-page-html');
|
||||
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
||||
const {
|
||||
MS_LOOKUP,
|
||||
TIME_PRECISION_VALUES,
|
||||
DIRECTION,
|
||||
} = require('matrix-public-archive-shared/lib/reference-values');
|
||||
const { ONE_DAY_IN_MS, ONE_HOUR_IN_MS, ONE_MINUTE_IN_MS, ONE_SECOND_IN_MS } = MS_LOOKUP;
|
||||
const {
|
||||
roundUpTimestampToUtcDay,
|
||||
roundUpTimestampToUtcHour,
|
||||
roundUpTimestampToUtcMinute,
|
||||
roundUpTimestampToUtcSecond,
|
||||
getUtcStartOfDayTs,
|
||||
getUtcStartOfHourTs,
|
||||
getUtcStartOfMinuteTs,
|
||||
getUtcStartOfSecondTs,
|
||||
areTimestampsFromSameUtcDay,
|
||||
areTimestampsFromSameUtcHour,
|
||||
areTimestampsFromSameUtcMinute,
|
||||
areTimestampsFromSameUtcSecond,
|
||||
} = require('matrix-public-archive-shared/lib/timestamp-utilities');
|
||||
|
||||
const config = require('../lib/config');
|
||||
const basePath = config.get('basePath');
|
||||
|
@ -43,6 +63,15 @@ const VALID_ENTITY_DESCRIPTOR_TO_SIGIL_MAP = {
|
|||
const validSigilList = Object.values(VALID_ENTITY_DESCRIPTOR_TO_SIGIL_MAP);
|
||||
const sigilRe = new RegExp(`^(${validSigilList.join('|')})`);
|
||||
|
||||
function getErrorStringForTooManyMessages(archiveMessageLimit) {
|
||||
const message =
|
||||
`Too many messages were sent all within a second for us to display ` +
|
||||
`(more than ${archiveMessageLimit} in one second). We're unable to redirect you to ` +
|
||||
`a smaller time range to view them without losing a few between each page. ` +
|
||||
`Since this is probably pretty rare, we've decided not to support it for now.`;
|
||||
return message;
|
||||
}
|
||||
|
||||
function getRoomIdOrAliasFromReq(req) {
|
||||
const entityDescriptor = req.params.entityDescriptor;
|
||||
// This could be with or with our without the sigil. Although the correct thing here
|
||||
|
@ -60,46 +89,73 @@ function getRoomIdOrAliasFromReq(req) {
|
|||
return `${sigil}${roomIdOrAliasWithoutSigil}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-statements, complexity
|
||||
function parseArchiveRangeFromReq(req) {
|
||||
const yyyy = parseInt(req.params.yyyy, 10);
|
||||
// Month is the only zero-based index in this group
|
||||
const mm = parseInt(req.params.mm, 10) - 1;
|
||||
const dd = parseInt(req.params.dd, 10);
|
||||
|
||||
const hourRange = req.params.hourRange;
|
||||
const timeString = req.params.time;
|
||||
let timeInMs = 0;
|
||||
let timeDefined = false;
|
||||
let secondsDefined = false;
|
||||
if (timeString) {
|
||||
const timeMatches = timeString.match(/^T(\d\d?):(\d\d?)(?::(\d\d?))?$/);
|
||||
|
||||
let fromHour = 0;
|
||||
let toHour = 0;
|
||||
if (hourRange) {
|
||||
const hourMatches = hourRange.match(/^(\d\d?)-(\d\d?)$/);
|
||||
|
||||
if (!hourMatches) {
|
||||
throw new StatusError(404, 'Hour was unable to be parsed');
|
||||
if (!timeMatches) {
|
||||
throw new StatusError(
|
||||
404,
|
||||
'Time was unable to be parsed from URL. It should be in 24-hour format 23:59:59'
|
||||
);
|
||||
}
|
||||
|
||||
fromHour = parseInt(hourMatches[1], 10);
|
||||
toHour = parseInt(hourMatches[2], 10);
|
||||
const hour = timeMatches[1] && parseInt(timeMatches[1], 10);
|
||||
const minute = timeMatches[2] && parseInt(timeMatches[2], 10);
|
||||
const second = timeMatches[3] ? parseInt(timeMatches[3], 10) : 0;
|
||||
|
||||
if (Number.isNaN(fromHour) || fromHour < 0 || fromHour > 23) {
|
||||
throw new StatusError(404, 'From hour can only be in range 0-23');
|
||||
timeDefined = !!timeMatches;
|
||||
// Whether the timestamp included seconds
|
||||
secondsDefined = !!timeMatches[3];
|
||||
|
||||
if (Number.isNaN(hour) || hour < 0 || hour > 23) {
|
||||
throw new StatusError(404, `Hour can only be in range 0-23 -> ${hour}`);
|
||||
}
|
||||
if (Number.isNaN(minute) || minute < 0 || minute > 59) {
|
||||
throw new StatusError(404, `Minute can only be in range 0-59 -> ${minute}`);
|
||||
}
|
||||
if (Number.isNaN(second) || second < 0 || second > 59) {
|
||||
throw new StatusError(404, `Second can only be in range 0-59 -> ${second}`);
|
||||
}
|
||||
|
||||
const hourInMs = hour * ONE_HOUR_IN_MS;
|
||||
const minuteInMs = minute * ONE_MINUTE_IN_MS;
|
||||
const secondInMs = second * ONE_SECOND_IN_MS;
|
||||
|
||||
timeInMs = hourInMs + minuteInMs + secondInMs;
|
||||
}
|
||||
|
||||
const fromTimestamp = Date.UTC(yyyy, mm, dd, fromHour);
|
||||
let toTimestamp = Date.UTC(yyyy, mm, dd + 1, fromHour);
|
||||
if (hourRange) {
|
||||
toTimestamp = Date.UTC(yyyy, mm, dd, toHour);
|
||||
let toTimestamp;
|
||||
if (timeInMs) {
|
||||
const startOfDayTimestamp = Date.UTC(yyyy, mm, dd);
|
||||
toTimestamp = startOfDayTimestamp + timeInMs;
|
||||
}
|
||||
// If no time specified, then we assume end-of-day
|
||||
else {
|
||||
// We `- 1` from UTC midnight to get the timestamp that is a millisecond before the
|
||||
// next day T23:59:59.999
|
||||
toTimestamp = Date.UTC(yyyy, mm, dd + 1) - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
fromTimestamp,
|
||||
toTimestamp,
|
||||
yyyy,
|
||||
mm,
|
||||
dd,
|
||||
hourRange,
|
||||
fromHour,
|
||||
toHour,
|
||||
// Whether the req included time `T23:59`
|
||||
timeDefined,
|
||||
// Whether the req included seconds in the time `T23:59:59`
|
||||
secondsDefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -119,12 +175,12 @@ router.get(
|
|||
// any of the additional room info or messages.
|
||||
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
|
||||
|
||||
// Find the closest day to today with messages
|
||||
// Find the closest day to the current time with messages
|
||||
const { originServerTs } = await timestampToEvent({
|
||||
accessToken: matrixAccessToken,
|
||||
roomId,
|
||||
ts: dateBeforeJoin,
|
||||
direction: 'b',
|
||||
direction: DIRECTION.backward,
|
||||
});
|
||||
if (!originServerTs) {
|
||||
throw new StatusError(404, 'Unable to find day with history');
|
||||
|
@ -153,33 +209,139 @@ router.get(
|
|||
|
||||
router.get(
|
||||
'/jump',
|
||||
// eslint-disable-next-line max-statements
|
||||
// eslint-disable-next-line max-statements, complexity
|
||||
asyncHandler(async function (req, res) {
|
||||
const roomIdOrAlias = getRoomIdOrAliasFromReq(req);
|
||||
|
||||
const ts = parseInt(req.query.ts, 10);
|
||||
assert(!Number.isNaN(ts), '?ts query parameter must be a number');
|
||||
const currentRangeStartTs = parseInt(req.query.currentRangeStartTs, 10);
|
||||
assert(
|
||||
!Number.isNaN(currentRangeStartTs),
|
||||
'?currentRangeStartTs query parameter must be a number'
|
||||
);
|
||||
const currentRangeEndTs = parseInt(req.query.currentRangeEndTs, 10);
|
||||
assert(!Number.isNaN(currentRangeEndTs), '?currentRangeEndTs query parameter must be a number');
|
||||
const dir = req.query.dir;
|
||||
assert(['f', 'b'].includes(dir), '?dir query parameter must be [f|b]');
|
||||
assert(
|
||||
[DIRECTION.forward, DIRECTION.backward].includes(dir),
|
||||
'?dir query parameter must be [f|b]'
|
||||
);
|
||||
|
||||
let ts;
|
||||
if (dir === DIRECTION.backward) {
|
||||
// We `- 1` so we don't jump to the same event because the endpoint is inclusive.
|
||||
//
|
||||
// XXX: This is probably an edge-case flaw when there could be multiple events at
|
||||
// the same timestamp
|
||||
ts = currentRangeStartTs - 1;
|
||||
} else if (dir === DIRECTION.forward) {
|
||||
// We `+ 1` so we don't jump to the same event because the endpoint is inclusive
|
||||
//
|
||||
// XXX: This is probably an edge-case flaw when there could be multiple events at
|
||||
// the same timestamp
|
||||
ts = currentRangeEndTs + 1;
|
||||
} else {
|
||||
throw new Error(`Unable to handle unknown dir=${dir} in /jump`);
|
||||
}
|
||||
|
||||
// We have to wait for the room join to happen first before we can use the jump to
|
||||
// date endpoint
|
||||
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
|
||||
|
||||
let eventIdForTimestamp;
|
||||
let originServerTs;
|
||||
let eventIdForClosestEvent;
|
||||
let tsForClosestEvent;
|
||||
let newOriginServerTs;
|
||||
let preferredPrecision = null;
|
||||
try {
|
||||
// Find the closest day to today with messages
|
||||
({ eventId: eventIdForTimestamp, originServerTs } = await timestampToEvent({
|
||||
accessToken: matrixAccessToken,
|
||||
roomId,
|
||||
ts: ts,
|
||||
direction: dir,
|
||||
}));
|
||||
// We pull this fresh from the config for each request to ensure we have an
|
||||
// updated value between each e2e test
|
||||
const archiveMessageLimit = config.get('archiveMessageLimit');
|
||||
|
||||
// The goal is to go forward 100 messages, so that when we view the room at that
|
||||
// point going backwards 100 messages, we end up at the perfect sam continuation
|
||||
// spot in the room.
|
||||
// Find the closest event to the given timestamp
|
||||
({ eventId: eventIdForClosestEvent, originServerTs: tsForClosestEvent } =
|
||||
await timestampToEvent({
|
||||
accessToken: matrixAccessToken,
|
||||
roomId,
|
||||
ts: ts,
|
||||
direction: dir,
|
||||
}));
|
||||
|
||||
// Based on what we found was the closest, figure out the URL that will represent
|
||||
// the next chunk in the desired direction.
|
||||
// ==============================
|
||||
//
|
||||
// When jumping backwards, since a given room archive URL represents the end of
|
||||
// the day/time-period looking backward (scroll is also anchored to the bottom),
|
||||
// we just need to get the user to the previous time-period.
|
||||
//
|
||||
// We are trying to avoid sending the user to the same time period they were just
|
||||
// viewing. i.e, if they were visiting `/2020/01/02T16:00:00` (displays messages
|
||||
// backwards from that time up to the limit), which had more messages than we
|
||||
// could display in that day, jumping backwards from the earliest displayed event
|
||||
// in the displayed range, say `T12:00:05` would still give us the same day
|
||||
// `/2020/01/02` and we want to redirect them to previous chunk from that same
|
||||
// day, like `/2020/01/02T12:00:00`
|
||||
if (dir === DIRECTION.backward) {
|
||||
const fromSameDay =
|
||||
tsForClosestEvent && areTimestampsFromSameUtcDay(currentRangeEndTs, tsForClosestEvent);
|
||||
const fromSameHour =
|
||||
tsForClosestEvent && areTimestampsFromSameUtcHour(currentRangeEndTs, tsForClosestEvent);
|
||||
const fromSameMinute =
|
||||
tsForClosestEvent && areTimestampsFromSameUtcMinute(currentRangeEndTs, tsForClosestEvent);
|
||||
const fromSameSecond =
|
||||
tsForClosestEvent && areTimestampsFromSameUtcSecond(currentRangeEndTs, tsForClosestEvent);
|
||||
|
||||
// The closest event is from the same second we tried to jump from. Since we
|
||||
// can't represent something smaller than a second in the URL yet (we could do
|
||||
// ms but it's a concious choice to make the URL cleaner,
|
||||
// #support-ms-time-slice), we will need to just return the timestamp with a
|
||||
// precision of seconds and hope that there isn't too many messages in this same
|
||||
// second.
|
||||
//
|
||||
// XXX: If there is too many messages all within the same second, people will be
|
||||
// stuck visiting the same page over and over every time they try to jump
|
||||
// backwards from that range.
|
||||
if (fromSameSecond) {
|
||||
newOriginServerTs = tsForClosestEvent;
|
||||
preferredPrecision = TIME_PRECISION_VALUES.seconds;
|
||||
}
|
||||
// The closest event is from the same minute we tried to jump from, we will need
|
||||
// to round up to the nearest second so that the URL encompasses the closest
|
||||
// event looking backwards
|
||||
else if (fromSameMinute) {
|
||||
newOriginServerTs = roundUpTimestampToUtcSecond(tsForClosestEvent);
|
||||
preferredPrecision = TIME_PRECISION_VALUES.seconds;
|
||||
}
|
||||
// The closest event is from the same hour we tried to jump from, we will need
|
||||
// to round up to the nearest minute so that the URL encompasses the closest
|
||||
// event looking backwards
|
||||
else if (fromSameHour) {
|
||||
newOriginServerTs = roundUpTimestampToUtcMinute(tsForClosestEvent);
|
||||
preferredPrecision = TIME_PRECISION_VALUES.minutes;
|
||||
}
|
||||
// The closest event is from the same day we tried to jump from, we will need to
|
||||
// round up to the nearest hour so that the URL encompasses the closest event
|
||||
// looking backwards
|
||||
else if (fromSameDay) {
|
||||
newOriginServerTs = roundUpTimestampToUtcHour(tsForClosestEvent);
|
||||
preferredPrecision = TIME_PRECISION_VALUES.minutes;
|
||||
}
|
||||
// We don't need to do anything. The next closest event is far enough away
|
||||
// (greater than 1 day) where we don't need to worry about the URL at all and
|
||||
// can just render whatever day that the closest event is from because the
|
||||
// archives biggest time-period represented in the URL is a day.
|
||||
//
|
||||
// We can display more than a day of content at a given URL (imagine lots of a
|
||||
// quiet days in a room), but the URL will never represent a time-period
|
||||
// greater than a day, ex. `/2023/01/01`. We don't allow someone to just
|
||||
// specify the month like `/2023/01` ❌
|
||||
else {
|
||||
newOriginServerTs = tsForClosestEvent;
|
||||
}
|
||||
}
|
||||
// When jumping forwards, the goal is to go forward 100 messages, so that when we
|
||||
// view the room at that point going backwards 100 messages (which is how the
|
||||
// archive works for any given date from the archive URL), we end up at the
|
||||
// perfect continuation spot in the room (seamless).
|
||||
//
|
||||
// XXX: This is flawed in the fact that when we go `/messages?dir=b` later, it
|
||||
// could backfill messages which will fill up the response before we perfectly
|
||||
|
@ -187,22 +349,26 @@ router.get(
|
|||
// `/messages?dir=f` backfills, we won't have this problem anymore because any
|
||||
// messages backfilled in the forwards direction would be picked up the same going
|
||||
// backwards.
|
||||
if (dir === 'f') {
|
||||
if (dir === DIRECTION.forward) {
|
||||
// Use `/messages?dir=f` and get the `end` pagination token to paginate from. And
|
||||
// then start the scroll from the top of the page so they can continue.
|
||||
const archiveMessageLimit = config.get('archiveMessageLimit');
|
||||
//
|
||||
// XXX: It would be cool to somehow cache this response and re-use our work here
|
||||
// for the actual room display that we redirect to from this route. No need for
|
||||
// us go out 100 messages, only for us to go backwards 100 messages again in the
|
||||
// next route.
|
||||
const messageResData = await getMessagesResponseFromEventId({
|
||||
accessToken: matrixAccessToken,
|
||||
roomId,
|
||||
eventId: eventIdForTimestamp,
|
||||
dir: 'f',
|
||||
eventId: eventIdForClosestEvent,
|
||||
dir: DIRECTION.forward,
|
||||
limit: archiveMessageLimit,
|
||||
});
|
||||
|
||||
if (!messageResData.chunk?.length) {
|
||||
throw new StatusError(
|
||||
404,
|
||||
`/messages response didn't contain any more messages to jump to`
|
||||
`/jump?dir=${dir}: /messages response didn't contain any more messages to jump to`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -210,32 +376,84 @@ router.get(
|
|||
messageResData.chunk[messageResData.chunk.length - 1].origin_server_ts;
|
||||
const dateOfLastMessage = new Date(timestampOfLastMessage);
|
||||
|
||||
// Back track from the last message timestamp to the date boundary. This will
|
||||
// gurantee some overlap with the previous page we jumped from so we don't lose
|
||||
// any messages in the gap.
|
||||
// Back-track from the last message timestamp to the nearest date boundary.
|
||||
// Because we're back-tracking a couple events here, when we paginate back out
|
||||
// by the `archiveMessageLimit` later in the room route, it will gurantee some
|
||||
// overlap with the previous page we jumped from so we don't lose any messages
|
||||
// in the gap.
|
||||
//
|
||||
// XXX: This date boundary logic may need to change once we introduce hour
|
||||
// chunks or time slices
|
||||
// (https://github.com/matrix-org/matrix-public-archive/issues/7). For example
|
||||
// if we reached into the next day but it has too many messages to show for a
|
||||
// given page, we would want to back track until a suitable time slice boundary.
|
||||
// Maybe we need to add a new URL parameter here `?time-slice=true` to indicate
|
||||
// that it's okay to break it up by time slice based on previously having to
|
||||
// view by time slice. We wouldn't want to give
|
||||
const utcMidnightOfDayBefore = Date.UTC(
|
||||
dateOfLastMessage.getUTCFullYear(),
|
||||
dateOfLastMessage.getUTCMonth(),
|
||||
dateOfLastMessage.getUTCDate()
|
||||
);
|
||||
// We minus 1 from UTC midnight to get to the day before
|
||||
const endOfDayBeforeDate = new Date(utcMidnightOfDayBefore - 1);
|
||||
// We could choose to jump to the exact timestamp of the last message instead of
|
||||
// back-tracking but then we get ugly URL's every time you jump instead of being
|
||||
// able to back-track and round down to the nearest hour in a lot of cases. The
|
||||
// other reason not to return the exact date is maybe there multiple messages at
|
||||
// the same timestamp and we will lose messages in the gap it displays more than
|
||||
// we thought.
|
||||
const msGapFromJumpPointToLastMessage = timestampOfLastMessage - ts;
|
||||
const moreThanDayGap = msGapFromJumpPointToLastMessage > ONE_DAY_IN_MS;
|
||||
const moreThanHourGap = msGapFromJumpPointToLastMessage > ONE_HOUR_IN_MS;
|
||||
const moreThanMinuteGap = msGapFromJumpPointToLastMessage > ONE_MINUTE_IN_MS;
|
||||
const moreThanSecondGap = msGapFromJumpPointToLastMessage > ONE_SECOND_IN_MS;
|
||||
|
||||
originServerTs = endOfDayBeforeDate;
|
||||
// If the `/messages` response returns less than the `archiveMessageLimit`
|
||||
// looking forwards, it means we're looking at the latest events in the room. We
|
||||
// can simply just display the day that the latest event occured on or given
|
||||
// rangeEnd (whichever is later).
|
||||
const haveReachedLatestMessagesInRoom = messageResData.chunk?.length < archiveMessageLimit;
|
||||
if (haveReachedLatestMessagesInRoom) {
|
||||
const latestDesiredTs = Math.max(currentRangeEndTs, timestampOfLastMessage);
|
||||
const latestDesiredDate = new Date(latestDesiredTs);
|
||||
const utcMidnightTs = getUtcStartOfDayTs(latestDesiredDate);
|
||||
newOriginServerTs = utcMidnightTs;
|
||||
preferredPrecision = TIME_PRECISION_VALUES.none;
|
||||
}
|
||||
// More than a day gap here, so we can just back-track to the nearest day
|
||||
else if (moreThanDayGap) {
|
||||
const utcMidnightOfDayBefore = getUtcStartOfDayTs(dateOfLastMessage);
|
||||
// We `- 1` from UTC midnight to get the timestamp that is a millisecond
|
||||
// before the next day but we choose a no time precision so we jump to just
|
||||
// the bare date without a time. A bare date in the `/date/2022/12/16`
|
||||
// endpoint represents the end of that day looking backwards so this is
|
||||
// exactly what we want.
|
||||
const endOfDayBeforeTs = utcMidnightOfDayBefore - 1;
|
||||
newOriginServerTs = endOfDayBeforeTs;
|
||||
preferredPrecision = TIME_PRECISION_VALUES.none;
|
||||
}
|
||||
// More than a hour gap here, we will need to back-track to the nearest hour
|
||||
else if (moreThanHourGap) {
|
||||
const utcTopOfHourBefore = getUtcStartOfHourTs(dateOfLastMessage);
|
||||
newOriginServerTs = utcTopOfHourBefore;
|
||||
preferredPrecision = TIME_PRECISION_VALUES.minutes;
|
||||
}
|
||||
// More than a minute gap here, we will need to back-track to the nearest minute
|
||||
else if (moreThanMinuteGap) {
|
||||
const utcTopOfMinuteBefore = getUtcStartOfMinuteTs(dateOfLastMessage);
|
||||
newOriginServerTs = utcTopOfMinuteBefore;
|
||||
preferredPrecision = TIME_PRECISION_VALUES.minutes;
|
||||
}
|
||||
// More than a second gap here, we will need to back-track to the nearest second
|
||||
else if (moreThanSecondGap) {
|
||||
const utcTopOfSecondBefore = getUtcStartOfSecondTs(dateOfLastMessage);
|
||||
newOriginServerTs = utcTopOfSecondBefore;
|
||||
preferredPrecision = TIME_PRECISION_VALUES.seconds;
|
||||
}
|
||||
// Less than a second gap here, we will give up.
|
||||
//
|
||||
// XXX: Maybe we can support ms here (#support-ms-time-slice)
|
||||
else {
|
||||
// 501 Not Implemented: the server does not support the functionality required
|
||||
// to fulfill the request
|
||||
res.status(501);
|
||||
res.send(
|
||||
`/jump ran into a problem: ${getErrorStringForTooManyMessages(archiveMessageLimit)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const is404Error = err instanceof HTTPResponseError && err.response.status === 404;
|
||||
// Only throw if it's something other than a 404 error. 404 errors are fine, they
|
||||
// just mean there is no more messages to paginate in that room.
|
||||
// just mean there is no more messages to paginate in that room and we were
|
||||
// already viewing the latest in the room.
|
||||
if (!is404Error) {
|
||||
throw err;
|
||||
}
|
||||
|
@ -244,37 +462,50 @@ router.get(
|
|||
// If we can't find any more messages to paginate to, just progress the date by a
|
||||
// day in whatever direction they wanted to go so we can display the empty view for
|
||||
// that day.
|
||||
if (!originServerTs) {
|
||||
const tsDate = new Date(ts);
|
||||
const yyyy = tsDate.getUTCFullYear();
|
||||
const mm = tsDate.getUTCMonth();
|
||||
const dd = tsDate.getUTCDate();
|
||||
if (!newOriginServerTs) {
|
||||
let tsAtRangeBoundaryInDirection;
|
||||
if (dir === DIRECTION.backward) {
|
||||
tsAtRangeBoundaryInDirection = currentRangeStartTs;
|
||||
} else if (dir === DIRECTION.forward) {
|
||||
tsAtRangeBoundaryInDirection = currentRangeEndTs;
|
||||
}
|
||||
|
||||
const newDayDelta = dir === 'f' ? 1 : -1;
|
||||
originServerTs = Date.UTC(yyyy, mm, dd + newDayDelta);
|
||||
const dateAtRangeBoundaryInDirection = new Date(tsAtRangeBoundaryInDirection);
|
||||
const yyyy = dateAtRangeBoundaryInDirection.getUTCFullYear();
|
||||
const mm = dateAtRangeBoundaryInDirection.getUTCMonth();
|
||||
const dd = dateAtRangeBoundaryInDirection.getUTCDate();
|
||||
|
||||
const newDayDelta = dir === DIRECTION.forward ? 1 : -1;
|
||||
newOriginServerTs = Date.UTC(yyyy, mm, dd + newDayDelta);
|
||||
}
|
||||
|
||||
// Redirect to a day with messages
|
||||
res.redirect(
|
||||
// TODO: Add query parameter that causes the client to start the scroll at the top
|
||||
// when jumping forwards so they can continue reading where they left off.
|
||||
matrixPublicArchiveURLCreator.archiveUrlForDate(roomIdOrAlias, new Date(originServerTs), {
|
||||
const archiveUrlToRedirecTo = matrixPublicArchiveURLCreator.archiveUrlForDate(
|
||||
roomIdOrAlias,
|
||||
new Date(newOriginServerTs),
|
||||
{
|
||||
// Start the scroll at the next event from where they jumped from (seamless navigation)
|
||||
scrollStartEventId: eventIdForTimestamp,
|
||||
})
|
||||
scrollStartEventId: eventIdForClosestEvent,
|
||||
preferredPrecision,
|
||||
}
|
||||
);
|
||||
res.redirect(archiveUrlToRedirecTo);
|
||||
})
|
||||
);
|
||||
|
||||
// Based off of the Gitter archive routes,
|
||||
// https://gitlab.com/gitterHQ/webapp/-/blob/14954e05c905e8c7cb675efebb89116c07cfaab5/server/handlers/app/archive.js#L190-297
|
||||
// Shows messages from the given date/time looking backwards up to the limit.
|
||||
router.get(
|
||||
'/date/:yyyy(\\d{4})/:mm(\\d{2})/:dd(\\d{2})/:hourRange(\\d\\d?-\\d\\d?)?',
|
||||
// The extra set of parenthesis around `((:\\d\\d?)?)` is to work around a
|
||||
// `path-to-regex` bug where the `?` wasn't attaching to the capture group, see
|
||||
// https://github.com/pillarjs/path-to-regexp/issues/287
|
||||
'/date/:yyyy(\\d{4})/:mm(\\d{2})/:dd(\\d{2}):time(T\\d\\d?:\\d\\d?((:\\d\\d?)?))?',
|
||||
timeoutMiddleware,
|
||||
// eslint-disable-next-line max-statements
|
||||
// eslint-disable-next-line max-statements, complexity
|
||||
asyncHandler(async function (req, res) {
|
||||
const roomIdOrAlias = getRoomIdOrAliasFromReq(req);
|
||||
|
||||
// We pull this fresh from the config for each request to ensure we have an
|
||||
// updated value between each e2e test
|
||||
const archiveMessageLimit = config.get('archiveMessageLimit');
|
||||
assert(archiveMessageLimit);
|
||||
// Synapse has a max `/messages` limit of 1000
|
||||
|
@ -283,47 +514,26 @@ router.get(
|
|||
'archiveMessageLimit needs to be in range [1, 999]. We can only get 1000 messages at a time from Synapse and we need a buffer of at least one to see if there are too many messages on a given day so you can only configure a max of 999. If you need more messages, we will have to implement pagination'
|
||||
);
|
||||
|
||||
const { fromTimestamp, toTimestamp, hourRange, fromHour, toHour } =
|
||||
parseArchiveRangeFromReq(req);
|
||||
const { toTimestamp, timeDefined, secondsDefined } = parseArchiveRangeFromReq(req);
|
||||
|
||||
let precisionFromUrl = TIME_PRECISION_VALUES.none;
|
||||
if (secondsDefined) {
|
||||
precisionFromUrl = TIME_PRECISION_VALUES.seconds;
|
||||
} else if (timeDefined) {
|
||||
precisionFromUrl = TIME_PRECISION_VALUES.minutes;
|
||||
}
|
||||
|
||||
// Just 404 if anyone is trying to view the future, no need to waste resources on that
|
||||
const nowTs = Date.now();
|
||||
if (fromTimestamp > nowTs) {
|
||||
if (toTimestamp > roundUpTimestampToUtcDay(nowTs)) {
|
||||
throw new StatusError(
|
||||
404,
|
||||
`You can't view the history of a room on a future day (${new Date(
|
||||
fromTimestamp
|
||||
toTimestamp
|
||||
).toISOString()} > ${new Date(nowTs).toISOString()}). Go back`
|
||||
);
|
||||
}
|
||||
|
||||
// If the hourRange is defined, we force the range to always be 1 hour. If
|
||||
// the format isn't correct, redirect to the correct hour range
|
||||
if (hourRange && toHour !== fromHour + 1) {
|
||||
// Pass through the query parameters
|
||||
let queryParamterUrlPiece = '';
|
||||
if (req.query) {
|
||||
queryParamterUrlPiece = `?${new URLSearchParams(req.query).toString()}`;
|
||||
}
|
||||
|
||||
res.redirect(
|
||||
// FIXME: Can we use the matrixPublicArchiveURLCreator here?
|
||||
`${urlJoin(
|
||||
basePath,
|
||||
roomIdOrAlias,
|
||||
'date',
|
||||
req.params.yyyy,
|
||||
req.params.mm,
|
||||
req.params.dd,
|
||||
`${fromHour}-${fromHour + 1}`
|
||||
)}${queryParamterUrlPiece}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Highlight tile that matches ?at=$xxx
|
||||
//const aroundId = req.query.at;
|
||||
|
||||
// We have to wait for the room join to happen first before we can fetch
|
||||
// any of the additional room info or messages.
|
||||
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
|
||||
|
@ -335,16 +545,19 @@ router.get(
|
|||
// We over-fetch messages outside of the range of the given day so that we
|
||||
// can display messages from surrounding days (currently only from days
|
||||
// before) so that the quiet rooms don't feel as desolate and broken.
|
||||
//
|
||||
// When given a bare date like `2022/11/16`, we want to paginate from the end of that
|
||||
// day backwards. This is why we use the `toTimestamp` here and fetch backwards.
|
||||
fetchEventsFromTimestampBackwards({
|
||||
accessToken: matrixAccessToken,
|
||||
roomId,
|
||||
ts: toTimestamp,
|
||||
// We fetch one more than the `archiveMessageLimit` so that we can see
|
||||
// there are too many messages from the given day. If we have over the
|
||||
// `archiveMessageLimit` number of messages fetching from the given day,
|
||||
// it's acceptable to have them be from surrounding days. But if all 500
|
||||
// messages (for example) are from the same day, let's redirect to a
|
||||
// smaller hour range to display.
|
||||
// We fetch one more than the `archiveMessageLimit` so that we can see if there
|
||||
// are too many messages from the given day. If we have over the
|
||||
// `archiveMessageLimit` number of messages fetching from the given day, it's
|
||||
// acceptable to have them be from surrounding days. But if all 500 messages
|
||||
// (for example) are from the same day, let's redirect to a smaller hour range
|
||||
// to display.
|
||||
limit: archiveMessageLimit + 1,
|
||||
}),
|
||||
]);
|
||||
|
@ -370,26 +583,6 @@ router.get(
|
|||
shouldIndex = roomData?.historyVisibility === `world_readable`;
|
||||
}
|
||||
|
||||
// If we have over the `archiveMessageLimit` number of messages fetching
|
||||
// from the given day, it's acceptable to have them be from surrounding
|
||||
// days. But if all 500 messages (for example) are from the same day, let's
|
||||
// redirect to a smaller hour range to display.
|
||||
if (
|
||||
// If there are too many messages, check that the event is from a previous
|
||||
// day in the surroundings.
|
||||
events.length >= archiveMessageLimit &&
|
||||
// Since we're only fetching previous days for the surroundings, we only
|
||||
// need to look at the oldest event in the chronological list.
|
||||
//
|
||||
// XXX: In the future when we also fetch events from days after, we will
|
||||
// need to change this next day check.
|
||||
events[0].origin_server_ts >= fromTimestamp
|
||||
) {
|
||||
res.send('TODO: Redirect user to smaller hour range');
|
||||
res.status(204);
|
||||
return;
|
||||
}
|
||||
|
||||
const hydrogenStylesUrl = urlJoin(basePath, '/hydrogen-styles.css');
|
||||
const stylesUrl = urlJoin(basePath, '/css/styles.css');
|
||||
const jsBundleUrl = urlJoin(basePath, '/js/entry-client-hydrogen.es.js');
|
||||
|
@ -397,8 +590,8 @@ router.get(
|
|||
const pageHtml = await renderHydrogenVmRenderScriptToPageHtml(
|
||||
path.resolve(__dirname, '../../shared/hydrogen-vm-render-script.js'),
|
||||
{
|
||||
fromTimestamp,
|
||||
toTimestamp,
|
||||
precisionFromUrl,
|
||||
roomData: {
|
||||
...roomData,
|
||||
// The `canonicalAlias` will take precedence over the `roomId` when present so we only
|
||||
|
|
|
@ -8,16 +8,17 @@
|
|||
const assert = require('matrix-public-archive-shared/lib/assert');
|
||||
const { Platform, MediaRepository, createNavigation, createRouter } = require('hydrogen-view-sdk');
|
||||
|
||||
const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values');
|
||||
const ArchiveRoomView = require('matrix-public-archive-shared/views/ArchiveRoomView');
|
||||
const ArchiveHistory = require('matrix-public-archive-shared/lib/archive-history');
|
||||
const supressBlankAnchorsReloadingThePage = require('matrix-public-archive-shared/lib/supress-blank-anchors-reloading-the-page');
|
||||
const ArchiveRoomViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveRoomViewModel');
|
||||
const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable');
|
||||
|
||||
const fromTimestamp = window.matrixPublicArchiveContext.fromTimestamp;
|
||||
assert(fromTimestamp);
|
||||
const toTimestamp = window.matrixPublicArchiveContext.toTimestamp;
|
||||
assert(toTimestamp);
|
||||
const precisionFromUrl = window.matrixPublicArchiveContext.precisionFromUrl;
|
||||
assert(Object.values(TIME_PRECISION_VALUES).includes(precisionFromUrl));
|
||||
const roomData = window.matrixPublicArchiveContext.roomData;
|
||||
assert(roomData);
|
||||
const events = window.matrixPublicArchiveContext.events;
|
||||
|
@ -105,8 +106,8 @@ async function mountHydrogen() {
|
|||
homeserverUrl: config.matrixServerUrl,
|
||||
room,
|
||||
// The timestamp from the URL that was originally visited
|
||||
dayTimestampFrom: fromTimestamp,
|
||||
dayTimestampTo: toTimestamp,
|
||||
precisionFromUrl,
|
||||
scrollStartEventId,
|
||||
events,
|
||||
stateEventMap,
|
||||
|
|
|
@ -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 assert = require('matrix-public-archive-shared/lib/assert');
|
||||
const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values');
|
||||
|
||||
function qsToUrlPiece(qs) {
|
||||
if (qs.toString()) {
|
||||
|
@ -65,9 +66,22 @@ class URLCreator {
|
|||
return `${urlJoin(this._basePath, `${urlPath}`)}${qsToUrlPiece(qs)}`;
|
||||
}
|
||||
|
||||
archiveUrlForDate(roomIdOrAlias, date, { viaServers = [], scrollStartEventId } = {}) {
|
||||
archiveUrlForDate(
|
||||
roomIdOrAlias,
|
||||
date,
|
||||
{ preferredPrecision = null, viaServers = [], scrollStartEventId } = {}
|
||||
) {
|
||||
assert(roomIdOrAlias);
|
||||
assert(date);
|
||||
// `preferredPrecision` is optional but if they gave a value, make sure it's something expected
|
||||
if (preferredPrecision) {
|
||||
assert(
|
||||
Object.values(TIME_PRECISION_VALUES).includes(preferredPrecision),
|
||||
`TimeSelectorViewModel: options.preferredPrecision must be one of ${JSON.stringify(
|
||||
Object.values(TIME_PRECISION_VALUES)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
let qs = new URLSearchParams();
|
||||
[].concat(viaServers).forEach((viaServer) => {
|
||||
|
@ -81,19 +95,36 @@ class URLCreator {
|
|||
|
||||
// Gives the date in YYYY/mm/dd format.
|
||||
// date.toISOString() -> 2022-02-16T23:20:04.709Z
|
||||
const urlDate = date.toISOString().split('T')[0].replaceAll('-', '/');
|
||||
const [datePiece, timePiece] = date.toISOString().split('T');
|
||||
// Get the `2022/02/16` part of it
|
||||
const urlDate = datePiece.replaceAll('-', '/');
|
||||
|
||||
return `${urlJoin(this._basePath, `${urlPath}/date/${urlDate}`)}${qsToUrlPiece(qs)}`;
|
||||
// Get the `23:20:04` part of it (TIME_PRECISION_VALUES.seconds)
|
||||
let urlTime = timePiece.split('.')[0];
|
||||
if (preferredPrecision === TIME_PRECISION_VALUES.minutes) {
|
||||
// We only want to replace the seconds part of the URL if its superfluous. `23:59:00`
|
||||
// does not convey more information than `23:59` so we can safely remove it if the
|
||||
// desired precision is in minutes.
|
||||
urlTime = urlTime.replace(/:00$/, '');
|
||||
}
|
||||
const shouldIncludeTimeInUrl = !!preferredPrecision;
|
||||
|
||||
return `${urlJoin(
|
||||
this._basePath,
|
||||
`${urlPath}/date/${urlDate}${shouldIncludeTimeInUrl ? `T${urlTime}` : ''}`
|
||||
)}${qsToUrlPiece(qs)}`;
|
||||
}
|
||||
|
||||
archiveJumpUrlForRoom(roomIdOrAlias, { ts, dir }) {
|
||||
archiveJumpUrlForRoom(roomIdOrAlias, { dir, currentRangeStartTs, currentRangeEndTs }) {
|
||||
assert(roomIdOrAlias);
|
||||
assert(ts);
|
||||
assert(dir);
|
||||
assert(currentRangeStartTs);
|
||||
assert(currentRangeEndTs);
|
||||
|
||||
let qs = new URLSearchParams();
|
||||
qs.append('ts', ts);
|
||||
qs.append('dir', dir);
|
||||
qs.append('currentRangeStartTs', currentRangeStartTs);
|
||||
qs.append('currentRangeEndTs', currentRangeEndTs);
|
||||
|
||||
const urlPath = this._getArchiveUrlPathForRoomIdOrAlias(roomIdOrAlias);
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ const assert = require('matrix-public-archive-shared/lib/assert');
|
|||
const ModalViewModel = require('matrix-public-archive-shared/viewmodels/ModalViewModel');
|
||||
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
||||
const CalendarViewModel = require('matrix-public-archive-shared/viewmodels/CalendarViewModel');
|
||||
const TimeSelectorViewModel = require('matrix-public-archive-shared/viewmodels/TimeSelectorViewModel');
|
||||
const DeveloperOptionsContentViewModel = require('matrix-public-archive-shared/viewmodels/DeveloperOptionsContentViewModel');
|
||||
const RightPanelContentView = require('matrix-public-archive-shared/views/RightPanelContentView');
|
||||
const AvatarViewModel = require('matrix-public-archive-shared/viewmodels/AvatarViewModel');
|
||||
|
@ -23,6 +24,10 @@ const {
|
|||
customTileClassForEntry,
|
||||
} = require('matrix-public-archive-shared/lib/custom-tile-utilities');
|
||||
const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable');
|
||||
const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values');
|
||||
const {
|
||||
areTimestampsFromSameUtcDay,
|
||||
} = require('matrix-public-archive-shared/lib/timestamp-utilities');
|
||||
|
||||
let txnCount = 0;
|
||||
function getFakeEventId() {
|
||||
|
@ -57,14 +62,14 @@ function makeEventEntryFromEventJson(eventJson, memberEvent) {
|
|||
}
|
||||
|
||||
class ArchiveRoomViewModel extends ViewModel {
|
||||
// eslint-disable-next-line max-statements
|
||||
// eslint-disable-next-line max-statements, complexity
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {
|
||||
homeserverUrl,
|
||||
room,
|
||||
dayTimestampFrom,
|
||||
dayTimestampTo,
|
||||
precisionFromUrl,
|
||||
scrollStartEventId,
|
||||
events,
|
||||
stateEventMap,
|
||||
|
@ -73,15 +78,14 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
} = options;
|
||||
assert(homeserverUrl);
|
||||
assert(room);
|
||||
assert(dayTimestampFrom);
|
||||
assert(dayTimestampTo);
|
||||
assert(Object.values(TIME_PRECISION_VALUES).includes(precisionFromUrl));
|
||||
assert(events);
|
||||
assert(stateEventMap);
|
||||
assert(shouldIndex !== undefined);
|
||||
assert(events);
|
||||
|
||||
this._room = room;
|
||||
this._dayTimestampFrom = dayTimestampFrom;
|
||||
this._dayTimestampTo = dayTimestampTo;
|
||||
this._currentTopPositionEventEntry = null;
|
||||
this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
|
||||
|
@ -98,6 +102,11 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
stateEventMap,
|
||||
});
|
||||
this._eventEntriesByEventId = eventEntriesByEventId;
|
||||
// Since we anchor our scroll to the bottom when we page-load, it makes sense to set
|
||||
// this as the bottom-most event entry by default. This variable says "TopPosition"
|
||||
// but it means the top of the viewport which in the extreme case of the viewport
|
||||
// being very short, should be the bottom-most event.
|
||||
this._currentTopPositionEventEntry = events && eventEntriesByEventId[events[events.length - 1]];
|
||||
|
||||
this._roomAvatarViewModel = new AvatarViewModel({
|
||||
homeserverUrlToPullMediaFrom: homeserverUrl,
|
||||
|
@ -112,14 +121,50 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
entityId: this._room.id,
|
||||
});
|
||||
|
||||
const initialDate = new Date(dayTimestampFrom);
|
||||
const timelineRangeStartTimestamp = events[0]?.origin_server_ts;
|
||||
const timelineRangeEndTimestamp = events[events.length - 1]?.origin_server_ts;
|
||||
|
||||
const bottomMostEventDate = timelineRangeEndTimestamp && new Date(timelineRangeEndTimestamp);
|
||||
const initialDate = new Date(dayTimestampTo);
|
||||
// The activeDate gets updated based on what the `currentTopPositionEventEntry` is
|
||||
// sob ecause we initialize with the bottom-most event as the
|
||||
// `currentTopPositionEventEntry`, the starting activeDate should also be the
|
||||
// timestamp from the bottom-most event. Otherwise, just fallback to the initialDate
|
||||
const initialActiveDate = bottomMostEventDate || initialDate;
|
||||
|
||||
this._calendarViewModel = new CalendarViewModel({
|
||||
// The day being shown in the archive
|
||||
activeDate: initialDate,
|
||||
activeDate: initialActiveDate,
|
||||
// The month displayed in the calendar
|
||||
calendarDate: initialDate,
|
||||
calendarDate: initialActiveDate,
|
||||
room,
|
||||
basePath,
|
||||
matrixPublicArchiveURLCreator: this._matrixPublicArchiveURLCreator,
|
||||
});
|
||||
|
||||
const shouldShowTimeSelector =
|
||||
// If there are no events, then it's possible the user navigated too far back
|
||||
// before the room was created and we will let them pick a new time that might make
|
||||
// more sense. But only if they are worried about time precision in the URL already.
|
||||
(precisionFromUrl !== TIME_PRECISION_VALUES.none && !events.length) ||
|
||||
// Only show the time selector when we're showing events all from the same day.
|
||||
(events.length &&
|
||||
areTimestampsFromSameUtcDay(timelineRangeStartTimestamp, timelineRangeEndTimestamp));
|
||||
|
||||
this._timeSelectorViewModel = new TimeSelectorViewModel({
|
||||
room,
|
||||
// The time (within the given date) being displayed in the time scrubber.
|
||||
activeDate: initialActiveDate,
|
||||
// Prevent extra precision if it's not needed. We only need to show seconds if
|
||||
// the page-loaded archive URL is worried about seconds.
|
||||
preferredPrecision:
|
||||
// Default to minutes for the time selector otherwise use whatever more fine
|
||||
// grained precision that the URL is using
|
||||
precisionFromUrl === TIME_PRECISION_VALUES.none
|
||||
? TIME_PRECISION_VALUES.minutes
|
||||
: precisionFromUrl,
|
||||
timelineRangeStartTimestamp,
|
||||
timelineRangeEndTimestamp,
|
||||
matrixPublicArchiveURLCreator: this._matrixPublicArchiveURLCreator,
|
||||
});
|
||||
|
||||
this._developerOptionsContentViewModel = new DeveloperOptionsContentViewModel(
|
||||
|
@ -162,6 +207,8 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
type: 'custom',
|
||||
customView: RightPanelContentView,
|
||||
calendarViewModel: this._calendarViewModel,
|
||||
shouldShowTimeSelector,
|
||||
timeSelectorViewModel: this._timeSelectorViewModel,
|
||||
shouldIndex,
|
||||
get developerOptionsUrl() {
|
||||
return urlRouter.urlForSegments([
|
||||
|
@ -254,6 +301,7 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
return this._eventEntriesByEventId;
|
||||
}
|
||||
|
||||
// This is the event that appears at the very top of our visible timeline as you scroll around
|
||||
get currentTopPositionEventEntry() {
|
||||
return this._currentTopPositionEventEntry;
|
||||
}
|
||||
|
@ -262,16 +310,19 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
return this._shouldShowRightPanel;
|
||||
}
|
||||
|
||||
// This is the event that appears at the very top of our visible timeline as you
|
||||
// scroll around (see the IntersectionObserver)
|
||||
setCurrentTopPositionEventEntry(currentTopPositionEventEntry) {
|
||||
this._currentTopPositionEventEntry = currentTopPositionEventEntry;
|
||||
this.emitChange('currentTopPositionEventEntry');
|
||||
|
||||
// Update the calendar
|
||||
// Update the calendar and time scrubber
|
||||
this._calendarViewModel.setActiveDate(currentTopPositionEventEntry.timestamp);
|
||||
this._timeSelectorViewModel.setActiveDate(currentTopPositionEventEntry.timestamp);
|
||||
}
|
||||
|
||||
get dayTimestampFrom() {
|
||||
return this._dayTimestampFrom;
|
||||
get dayTimestampTo() {
|
||||
return this._dayTimestampTo;
|
||||
}
|
||||
|
||||
get roomDirectoryUrl() {
|
||||
|
@ -302,8 +353,22 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
_addJumpSummaryEvents(inputEventList) {
|
||||
const events = [...inputEventList];
|
||||
|
||||
// The start of the range to use as a jumping off point to the previous activity.
|
||||
// This should be the first event in the timeline (oldest) or if there are no events
|
||||
// in the timeline, we can jump from day.
|
||||
const jumpRangeStartTimestamp = events[0]?.origin_server_ts || this._dayTimestampTo;
|
||||
// The end of the range to use as a jumping off point to the next activity. You
|
||||
// might expect this to be the last event in the timeline but since we paginate from
|
||||
// `_dayTimestampTo` backwards, `_dayTimestampTo` is actually the newest timestamp
|
||||
// to paginate from
|
||||
const jumpRangeEndTimestamp = this._dayTimestampTo;
|
||||
|
||||
// Check whether the given day represented in the URL has any events on the page
|
||||
// from that day. We only need to check the last event which would be closest to
|
||||
// `_dayTimestampTo` anyway.
|
||||
const lastEventTs = events[events.length - 1]?.origin_server_ts;
|
||||
const hasEventsFromGivenDay =
|
||||
events[events.length - 1]?.origin_server_ts >= this._dayTimestampFrom;
|
||||
lastEventTs && areTimestampsFromSameUtcDay(lastEventTs, this._dayTimestampTo);
|
||||
let daySummaryKind;
|
||||
if (events.length === 0) {
|
||||
daySummaryKind = 'no-events-at-all';
|
||||
|
@ -323,12 +388,12 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
type: 'org.matrix.archive.jump_to_previous_activity_summary',
|
||||
room_id: this._room.id,
|
||||
// Even though this isn't used for sort, just using the time where the event
|
||||
// would logically be (at the start of the day)
|
||||
// would logically be (before any of the other events in the timeline)
|
||||
origin_server_ts: events[0].origin_server_ts - 1,
|
||||
content: {
|
||||
canonicalAlias: this._room.canonicalAlias,
|
||||
// The start of the range to use as a jumping off point to the previous activity
|
||||
rangeStartTimestamp: events[0].origin_server_ts - 1,
|
||||
jumpRangeStartTimestamp,
|
||||
jumpRangeEndTimestamp,
|
||||
// This is a bit cheating but I don't know how else to pass this kind of
|
||||
// info to the Tile viewmodel
|
||||
basePath: this._basePath,
|
||||
|
@ -343,17 +408,15 @@ class ArchiveRoomViewModel extends ViewModel {
|
|||
type: 'org.matrix.archive.jump_to_next_activity_summary',
|
||||
room_id: this._room.id,
|
||||
// Even though this isn't used for sort, just using the time where the event
|
||||
// would logically be.
|
||||
//
|
||||
// -1 so we're not at 00:00:00 of the next day
|
||||
origin_server_ts: this._dayTimestampTo - 1,
|
||||
// would logically be (at the end of the day)
|
||||
origin_server_ts: this._dayTimestampTo,
|
||||
content: {
|
||||
canonicalAlias: this._room.canonicalAlias,
|
||||
daySummaryKind,
|
||||
// The timestamp from the URL that was originally visited
|
||||
dayTimestamp: this._dayTimestampFrom,
|
||||
// The end of the range to use as a jumping off point to the next activity
|
||||
rangeEndTimestamp: this._dayTimestampTo,
|
||||
dayTimestamp: this._dayTimestampTo,
|
||||
jumpRangeStartTimestamp,
|
||||
jumpRangeEndTimestamp,
|
||||
// This is a bit cheating but I don't know how else to pass this kind of
|
||||
// info to the Tile viewmodel
|
||||
basePath: this._basePath,
|
||||
|
|
|
@ -3,23 +3,22 @@
|
|||
const { ViewModel } = require('hydrogen-view-sdk');
|
||||
|
||||
const assert = require('matrix-public-archive-shared/lib/assert');
|
||||
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
||||
|
||||
class CalendarViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const { activeDate, calendarDate, room, basePath } = options;
|
||||
const { activeDate, calendarDate, room, matrixPublicArchiveURLCreator } = options;
|
||||
assert(activeDate);
|
||||
assert(calendarDate);
|
||||
assert(room);
|
||||
assert(basePath);
|
||||
assert(matrixPublicArchiveURLCreator);
|
||||
|
||||
// The day being shown in the archive
|
||||
this._activeDate = activeDate;
|
||||
// The month displayed in the calendar
|
||||
this._calendarDate = calendarDate;
|
||||
this._room = room;
|
||||
this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
|
||||
this._matrixPublicArchiveURLCreator = matrixPublicArchiveURLCreator;
|
||||
}
|
||||
|
||||
get activeDate() {
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
const { SimpleTile } = require('hydrogen-view-sdk');
|
||||
|
||||
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
|
||||
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
||||
const assert = require('../lib/assert');
|
||||
|
||||
|
@ -27,17 +28,23 @@ class JumpToNextActivitySummaryTileViewModel extends SimpleTile {
|
|||
return this._entry?.content?.['dayTimestamp'];
|
||||
}
|
||||
|
||||
// The start of the range to use as a jumping off point to the previous activity
|
||||
get jumpRangeStartTimestamp() {
|
||||
return this._entry?.content?.['jumpRangeStartTimestamp'];
|
||||
}
|
||||
|
||||
// The end of the range to use as a jumping off point to the next activity
|
||||
get rangeEndTimestamp() {
|
||||
return this._entry?.content?.['rangeEndTimestamp'];
|
||||
get jumpRangeEndTimestamp() {
|
||||
return this._entry?.content?.['jumpRangeEndTimestamp'];
|
||||
}
|
||||
|
||||
get jumpToNextActivityUrl() {
|
||||
return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(
|
||||
this._entry?.content?.['canonicalAlias'] || this._entry.roomId,
|
||||
{
|
||||
ts: this.rangeEndTimestamp,
|
||||
dir: 'f',
|
||||
dir: DIRECTION.forward,
|
||||
currentRangeStartTs: this.jumpRangeStartTimestamp,
|
||||
currentRangeEndTs: this.jumpRangeEndTimestamp,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
const { SimpleTile } = require('hydrogen-view-sdk');
|
||||
|
||||
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
|
||||
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
|
||||
const assert = require('../lib/assert');
|
||||
|
||||
|
@ -20,16 +21,22 @@ class JumpToPreviousActivitySummaryTileViewModel extends SimpleTile {
|
|||
}
|
||||
|
||||
// The start of the range to use as a jumping off point to the previous activity
|
||||
get rangeStartTimestamp() {
|
||||
return this._entry?.content?.['rangeStartTimestamp'];
|
||||
get jumpRangeStartTimestamp() {
|
||||
return this._entry?.content?.['jumpRangeStartTimestamp'];
|
||||
}
|
||||
|
||||
// The end of the range to use as a jumping off point to the next activity
|
||||
get jumpRangeEndTimestamp() {
|
||||
return this._entry?.content?.['jumpRangeEndTimestamp'];
|
||||
}
|
||||
|
||||
get jumpToPreviousActivityUrl() {
|
||||
return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(
|
||||
this._entry?.content?.['canonicalAlias'] || this._entry.roomId,
|
||||
{
|
||||
ts: this.rangeStartTimestamp,
|
||||
dir: 'b',
|
||||
dir: DIRECTION.backward,
|
||||
currentRangeStartTs: this.jumpRangeStartTimestamp,
|
||||
currentRangeEndTs: this.jumpRangeEndTimestamp,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
// If the date from our `archiveRoomViewModel` is available, use that
|
||||
vm?.currentTopPositionEventEntry?.timestamp ||
|
||||
// Otherwise, use our initial `dayTimestampFrom`
|
||||
vm.dayTimestampFrom
|
||||
// Otherwise, use our initial `dayTimestampTo`
|
||||
vm.dayTimestampTo
|
||||
);
|
||||
const dateString = activeDate.toISOString().split('T')[0];
|
||||
return t.span(`You're viewing an archive of events from ${dateString}. Use a `);
|
||||
|
@ -114,6 +114,19 @@ class DisabledComposerView extends TemplateView {
|
|||
}
|
||||
|
||||
class ArchiveRoomView extends TemplateView {
|
||||
constructor(vm) {
|
||||
super(vm);
|
||||
|
||||
// Keep track of the `IntersectionObserver` so we can disconnect it when necessary
|
||||
this._interSectionObserverForUpdatedTopPositionEventEntry = null;
|
||||
}
|
||||
|
||||
unmount() {
|
||||
if (this._interSectionObserverForUpdatedTopPositionEventEntry) {
|
||||
this._interSectionObserverForUpdatedTopPositionEventEntry.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
render(t, vm) {
|
||||
const rootElement = t.div(
|
||||
{
|
||||
|
@ -126,7 +139,7 @@ class ArchiveRoomView extends TemplateView {
|
|||
// The red border and yellow background trail around the event that is
|
||||
// driving the active date as you scroll around.
|
||||
t.if(
|
||||
(vm) => vm._developerOptionsViewModel?.debugActiveDateIntersectionObserver,
|
||||
(vm) => vm._developerOptionsContentViewModel?.debugActiveDateIntersectionObserver,
|
||||
(t /*, vm*/) => {
|
||||
return t.style({}, (vm) => {
|
||||
return `
|
||||
|
@ -150,6 +163,9 @@ class ArchiveRoomView extends TemplateView {
|
|||
t.view(new DisabledComposerView(vm)),
|
||||
]),
|
||||
]),
|
||||
// We can't just conditionally render the right-panel with `t.ifView(...)` based
|
||||
// on `shouldShowRightPanel` because the right-panel being "hidden" only applies
|
||||
// to the mobile break points and is always shown on desktop.
|
||||
t.view(new RightPanelView(vm.rightPanelModel)),
|
||||
t.mapView(
|
||||
(vm) => vm.lightboxViewModel,
|
||||
|
@ -159,9 +175,11 @@ class ArchiveRoomView extends TemplateView {
|
|||
]
|
||||
);
|
||||
|
||||
// Avoid an error when server-side rendering (SSR) because it doesn't have all the
|
||||
// DOM API's available (and doesn't need it for this case)
|
||||
if (typeof IntersectionObserver === 'function') {
|
||||
const scrollRoot = rootElement.querySelector('.Timeline_scroller');
|
||||
const observer = new IntersectionObserver(
|
||||
this._interSectionObserverForUpdatedTopPositionEventEntry = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
|
@ -188,7 +206,7 @@ class ArchiveRoomView extends TemplateView {
|
|||
}
|
||||
);
|
||||
[...scrollRoot.querySelectorAll(`:scope > ul > [data-event-id]`)].forEach((el) => {
|
||||
observer.observe(el);
|
||||
this._interSectionObserverForUpdatedTopPositionEventEntry.observe(el);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -3,14 +3,9 @@
|
|||
// Be mindful to do all date operations in UTC (the archive is all in UTC date/times)
|
||||
|
||||
const { TemplateView } = require('hydrogen-view-sdk');
|
||||
|
||||
function sameDay(date1, date2) {
|
||||
return (
|
||||
date1.getUTCFullYear() === date2.getUTCFullYear() &&
|
||||
date1.getUTCMonth() === date2.getUTCMonth() &&
|
||||
date1.getUTCDate() === date2.getUTCDate()
|
||||
);
|
||||
}
|
||||
const {
|
||||
areTimestampsFromSameUtcDay,
|
||||
} = require('matrix-public-archive-shared/lib/timestamp-utilities');
|
||||
|
||||
// Get the number of days in the given month where the `inputDate` lies.
|
||||
//
|
||||
|
@ -162,7 +157,10 @@ class CalendarView extends TemplateView {
|
|||
const isDayInFuture = dayNumberDate.getTime() - todayTs > 0;
|
||||
|
||||
// The current day displayed in the archive
|
||||
const isActive = sameDay(dayNumberDate, vm.activeDate);
|
||||
const isActive = areTimestampsFromSameUtcDay(
|
||||
dayNumberDate.getTime(),
|
||||
vm.activeDate.getTime()
|
||||
);
|
||||
|
||||
// day number from 0 (monday) to 6 (sunday)
|
||||
const dayNumber = dayNumberDate.getUTCDay();
|
||||
|
|
|
@ -29,7 +29,6 @@ class JumpToNextActivitySummaryTileView extends TemplateView {
|
|||
// while we wait for the rest of the JavaScript to load.
|
||||
'js-bottom-scroll-anchor': true,
|
||||
},
|
||||
'data-event-id': vm.eventId,
|
||||
},
|
||||
[
|
||||
t.if(
|
||||
|
|
|
@ -7,7 +7,6 @@ class JumpToPreviousActivitySummaryTileView extends TemplateView {
|
|||
return t.div(
|
||||
{
|
||||
className: 'JumpToPreviousActivitySummaryTileView',
|
||||
'data-event-id': vm.eventId,
|
||||
},
|
||||
[
|
||||
t.a(
|
||||
|
|
|
@ -78,8 +78,10 @@ class ModalView extends TemplateView {
|
|||
t.mapSideEffect(
|
||||
(vm) => vm.open,
|
||||
(open) => {
|
||||
// The dialog has to be in the DOM before we can call `showModal`, etc.
|
||||
// Assume this view will be mounted in the parent DOM straight away.
|
||||
// The dialog has to be in the DOM before we can call `showModal`, etc. Assume
|
||||
// this view will be mounted in the parent DOM straight away.
|
||||
// #hydrogen-assume-view-mounted-right-away -
|
||||
// https://github.com/vector-im/hydrogen-web/issues/1069
|
||||
requestAnimationFrame(() => {
|
||||
// Prevent doing extra work if the modal is already closed or open and already
|
||||
// matches our intention
|
||||
|
|
|
@ -3,11 +3,14 @@
|
|||
const { TemplateView } = require('hydrogen-view-sdk');
|
||||
|
||||
const CalendarView = require('matrix-public-archive-shared/views/CalendarView');
|
||||
const TimeSelectorView = require('matrix-public-archive-shared/views/TimeSelectorView');
|
||||
const assert = require('matrix-public-archive-shared/lib/assert');
|
||||
|
||||
class RightPanelContentView extends TemplateView {
|
||||
render(t, vm) {
|
||||
assert(vm.shouldIndex !== undefined);
|
||||
assert(vm.shouldShowTimeSelector !== undefined);
|
||||
|
||||
let maybeIndexedMessage = 'This room is not being indexed by search engines.';
|
||||
if (vm.shouldIndex) {
|
||||
maybeIndexedMessage = 'This room is being indexed by search engines.';
|
||||
|
@ -15,25 +18,25 @@ class RightPanelContentView extends TemplateView {
|
|||
|
||||
return t.div(
|
||||
{
|
||||
className: {
|
||||
RightPanelContentView: true,
|
||||
},
|
||||
className: 'RightPanelContentView',
|
||||
},
|
||||
[
|
||||
t.view(new CalendarView(vm.calendarViewModel)),
|
||||
t.div(
|
||||
t.div({ className: 'RightPanelContentView_mainContent' }, [
|
||||
t.view(new CalendarView(vm.calendarViewModel)),
|
||||
t.ifView(
|
||||
(vm) => vm.shouldShowTimeSelector,
|
||||
(vm) => new TimeSelectorView(vm.timeSelectorViewModel)
|
||||
),
|
||||
]),
|
||||
t.footer(
|
||||
{
|
||||
className: {
|
||||
RightPanelContentView_footer: true,
|
||||
},
|
||||
className: 'RightPanelContentView_footer',
|
||||
},
|
||||
[
|
||||
t.p(maybeIndexedMessage),
|
||||
t.div(
|
||||
{
|
||||
className: {
|
||||
RightPanelContentView_footerLinkList: true,
|
||||
},
|
||||
className: 'RightPanelContentView_footerLinkList',
|
||||
},
|
||||
[
|
||||
t.a(
|
||||
|
|
|
@ -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
|
||||
async function createMessagesInRoom({ client, roomId, numMessages, prefix, timestamp }) {
|
||||
async function createMessagesInRoom({
|
||||
client,
|
||||
roomId,
|
||||
numMessages,
|
||||
prefix,
|
||||
timestamp,
|
||||
// The amount of time between each message
|
||||
increment = 1,
|
||||
}) {
|
||||
let eventIds = [];
|
||||
let eventMap = new Map();
|
||||
for (let i = 0; i < numMessages; i++) {
|
||||
const originServerTs = timestamp + i * increment;
|
||||
const content = {
|
||||
msgtype: 'm.text',
|
||||
body: `${prefix} - message${i}`,
|
||||
};
|
||||
const eventId = await sendMessage({
|
||||
client,
|
||||
roomId,
|
||||
content: {
|
||||
msgtype: 'm.text',
|
||||
body: `${prefix} - message${i}`,
|
||||
},
|
||||
// The timestamp doesn't matter if it's the same anymore (since
|
||||
// https://github.com/matrix-org/synapse/pull/13658) but it still seems
|
||||
// like a good idea to make the tests more clear.
|
||||
timestamp: timestamp + i,
|
||||
content,
|
||||
// Technically, we don't have to set the timestamp to be unique or sequential but
|
||||
// it still seems like a good idea to make the tests more clear.
|
||||
timestamp: originServerTs,
|
||||
});
|
||||
eventIds.push(eventId);
|
||||
eventMap.set(eventId, {
|
||||
roomId,
|
||||
originServerTs,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
// Sanity check that we actually sent some messages
|
||||
assert.strictEqual(eventIds.length, numMessages);
|
||||
|
||||
return eventIds;
|
||||
return { eventIds, eventMap };
|
||||
}
|
||||
|
||||
async function updateProfile({ client, displayName, avatarUrl }) {
|
Loading…
Reference in New Issue