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:
Eric Eastwood 2023-04-05 04:25:31 -05:00 committed by GitHub
parent b0cee80253
commit 954b22995a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 3491 additions and 524 deletions

View File

@ -11,8 +11,8 @@
"build": "node ./build/do-client-build.js", "build": "node ./build/do-client-build.js",
"start": "node server/server.js", "start": "node server/server.js",
"start-dev": "node server/start-dev.js", "start-dev": "node server/start-dev.js",
"test": "npm run mocha -- test/e2e-tests.js --timeout 15000", "test": "npm run mocha -- test/**/*-tests.js --timeout 15000",
"test-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --interactive", "test-e2e-interactive": "npm run mocha -- test/e2e-tests.js --timeout 15000 --interactive",
"nodemon": "nodemon", "nodemon": "nodemon",
"vite": "vite", "vite": "vite",
"mocha": "mocha", "mocha": "mocha",

View File

@ -1,3 +1,10 @@
:root {
--primary-access-color: #0dbd8b;
--primary-access-color-hover: #0a8f69;
--text-color-rgb: 46, 47, 50;
}
/* apply a natural box layout model to all elements, but allowing components to change */ /* apply a natural box layout model to all elements, but allowing components to change */
html { html {
box-sizing: border-box; box-sizing: border-box;
@ -131,12 +138,17 @@ summary {
} }
.RightPanelContentView { .RightPanelContentView {
overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
height: 100%; height: 100%;
} }
.RightPanelContentView_mainContent > * + * {
margin-top: 1em;
}
.RightPanelContentView_footer { .RightPanelContentView_footer {
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
@ -270,18 +282,284 @@ summary {
} }
.CalendarView_dayLink_active { .CalendarView_dayLink_active {
background-color: #0dbd8b; background-color: var(--primary-access-color);
color: #ffffff; color: #ffffff;
} }
.CalendarView_dayLink_active:hover { .CalendarView_dayLink_active:hover {
background-color: #0a8f69; background-color: var(--primary-access-color-hover);
} }
.CalendarView_dayLink_disabled { .CalendarView_dayLink_disabled {
opacity: 0.5; opacity: 0.5;
} }
.TimeSelectorView {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.TimeSelectorView_header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 8px;
}
.TimeSelectorView_primaryTimezoneLabel {
display: inline-block;
padding: 2px 12px;
background-color: var(--primary-access-color);
/* Always make a pill shape */
border-radius: 9999px;
color: #ffffff;
}
.TimeSelectorView_timeInput {
margin-top: 6px;
padding: 2px 18px;
background: transparent;
border: 0;
/* Always make a pill shape */
border-radius: 9999px;
color: var(--primary-access-color);
font-family: inherit;
font-size: inherit;
font-weight: bold;
}
/**
* The Chrome <input type="time"> has a little clock icon that makes the whole input
* appear off-center since we don't have any border/background on it. These styles just
* scoot the time text over to appear centered regardless of the icon.
*/
@supports selector(::-webkit-calendar-picker-indicator) {
.TimeSelectorView_timeInput {
margin-right: calc(-20px - 6px - 8px);
}
.TimeSelectorView_timeInput::-webkit-calendar-picker-indicator {
width: 20px;
padding: 3px;
margin-left: 8px;
}
}
.TimeSelectorView_goAction {
position: absolute;
right: 0;
display: inline-block;
padding: 4px 16px;
margin-right: 16px;
background-color: var(--primary-access-color);
/* Always make a pill shape */
border-radius: 9999px;
border: 0;
color: #ffffff;
line-height: 24px;
text-decoration: none;
cursor: pointer;
}
.TimeSelectorView_goAction:hover {
background-color: var(--primary-access-color-hover);
}
.TimeSelectorView_footer {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 16px;
}
.TimeSelectorView_secondaryTime {
color: rgba(var(--text-color-rgb), 0.75);
font-weight: bold;
}
.TimeSelectorView_secondaryTimezoneLabel {
display: inline-block;
margin-top: 6px;
color: rgba(var(--text-color-rgb), 0.75);
}
.TimeSelectorView_scrubber {
position: relative;
width: 100%;
max-width: 600px;
}
/* Current position triangle indicator */
.TimeSelectorView_scrubber::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 32px;
transform: translateX(-50%) translateY(-50%);
/* Draw some pointer triangles at our current scroll position */
/* prettier-ignore */
background-image:
/* Arrow pointing down at the top */
linear-gradient(to top right, transparent 50%, var(--primary-access-color) 0),
linear-gradient(to bottom right, var(--primary-access-color) 50%, transparent 0),
/* Arrow pointing up at the bottom */
linear-gradient(to bottom right, transparent 50%, rgba(var(--text-color-rgb), 0.2) 0),
linear-gradient(to top right, rgba(var(--text-color-rgb), 0.2) 50%, transparent 0);
background-size: 50% 4px;
background-repeat: no-repeat;
/* prettier-ignore */
background-position:
/* Arrow pointing down at the top */
left top, right top,
/* Arrow pointing up at the bottom */
left bottom, right bottom;
/* This is just a visual thing, so don't mess with clicks or selection here */
pointer-events: none;
}
/* The magnifier highlights the time range of messages in the timeline on this page */
.TimeSelectorView_magnifierBubble {
position: absolute;
top: 50%;
height: 24px;
transform: translateY(-50%);
/* Add slight highlight color to magnifier glass - Based on message highlight color */
background-color: rgb(255, 255, 138, 0.1);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(var(--text-color-rgb), 0.3);
/* The magnifier is just a visual thing, so don't mess with clicks or selection here */
pointer-events: none;
}
.TimeSelectorView_scrubberScrollWrapper {
/**
* By having plain normal scrolling for this control, we get all the nice momentum
* scrolling that is native to peoples touch devices (Android, iOS, etc). We also
* emulate momentum scrolling for mouse click and dragging.
*/
overflow-x: auto;
/* Hide scrollbar in Firefox. We want scroll but no scrollbar so mobile and touchpad
people can still scroll */
scrollbar-width: none;
display: flex;
width: 100%;
cursor: grab;
}
.TimeSelectorView_scrubberScrollWrapper::-webkit-scrollbar {
/* Hide scrollbar in Safari and Chrome. We want scroll but no scrollbar so mobile and touchpad
people can still scroll */
display: none;
}
.TimeSelectorView_scrubberScrollWrapper.is-dragging {
cursor: grabbing;
user-select: none;
}
.TimeSelectorView_dial {
--tick-width: 1px;
--tick-major-height: 20px;
--tick-minor-height: 10px;
--tick-detail-height: 3px;
position: relative;
display: flex;
height: 68px;
margin: 0;
padding: 0;
/* Allow the dial to scroll to the ends with the start/end tick being in the middle */
margin: 0 50%;
list-style: none;
/* Draw some tick marks along the dial */
/* prettier-ignore */
background-image:
/* Ticks every hour */
linear-gradient(90deg, currentcolor var(--tick-width), transparent 0),
/* Last hour tick */
linear-gradient(90deg, currentcolor var(--tick-width), transparent 0),
/* Ticks every 10 mins */
linear-gradient(90deg, currentcolor var(--tick-width), transparent 0),
/* Ticks every 5 mins */
linear-gradient(90deg, rgba(var(--text-color-rgb), 0.4) var(--tick-width), transparent 0);
/* prettier-ignore */
background-repeat:
/* Ticks every hour */
repeat-x,
/* Last hour tick */
no-repeat,
/* Ticks every 10 mins */
repeat-x,
/* Ticks every 5 mins */
repeat-x;
/* prettier-ignore */
background-size:
/* Ticks every hour */
60px var(--tick-major-height),
/* Last hour tick */
var(--tick-width) var(--tick-major-height),
/* Ticks every 10 mins */
10px var(--tick-minor-height),
/* Ticks every 5 mins */
5px var(--tick-detail-height);
/* prettier-ignore */
background-position:
/* Ticks every hour */
center left,
/* Last hour tick */
center right,
/* Ticks every 10 mins */
center left,
/* Ticks every 5 mins */
center left;
}
.TimeSelectorView_incrementLabel {
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
width: 60px;
margin: 0;
white-space: nowrap;
font-size: 12px;
font-weight: bold;
}
.TimeSelectorView_incrementLabelText,
.TimeSelectorView_incrementLabelTextSecondary {
transform: translateX(-50%);
}
.TimeSelectorView_incrementLabelTextSecondary {
color: rgba(var(--text-color-rgb), 0.75);
}
/* Some custom timeline, tiles stuff */ /* Some custom timeline, tiles stuff */
.JumpToPreviousActivitySummaryTileView, .JumpToPreviousActivitySummaryTileView,

View File

@ -45,7 +45,6 @@ async function serializeError(err) {
// If we don't listen for these events, the child will exit with status code 1 // If we don't listen for these events, the child will exit with status code 1
// (error) when they occur. // (error) when they occur.
process.on('uncaughtException', async (err /*, origin*/) => { process.on('uncaughtException', async (err /*, origin*/) => {
console.log('2 uncaughtException', err);
await serializeError(new RethrownError('uncaughtException in child process', err)); await serializeError(new RethrownError('uncaughtException in child process', err));
}); });

View File

@ -3,6 +3,7 @@
const assert = require('assert'); const assert = require('assert');
const { traceFunction } = require('../../tracing/trace-utilities'); const { traceFunction } = require('../../tracing/trace-utilities');
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
const timestampToEvent = require('./timestamp-to-event'); const timestampToEvent = require('./timestamp-to-event');
const getMessagesResponseFromEventId = require('./get-messages-response-from-event-id'); const getMessagesResponseFromEventId = require('./get-messages-response-from-event-id');
@ -40,7 +41,7 @@ async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limi
accessToken, accessToken,
roomId, roomId,
ts, ts,
direction: 'b', direction: DIRECTION.backward,
}); });
eventIdForTimestamp = eventId; eventIdForTimestamp = eventId;
} catch (err) { } catch (err) {
@ -67,7 +68,7 @@ async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limi
eventId: eventIdForTimestamp, eventId: eventIdForTimestamp,
// We go backwards because that's the direction that backfills events (Synapse // We go backwards because that's the direction that backfills events (Synapse
// doesn't backfill in the forward direction) // doesn't backfill in the forward direction)
dir: 'b', dir: DIRECTION.backward,
limit, limit,
}); });

View File

@ -3,6 +3,7 @@
const assert = require('assert'); const assert = require('assert');
const urlJoin = require('url-join'); const urlJoin = require('url-join');
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
const { fetchEndpointAsJson } = require('../fetch-endpoint'); const { fetchEndpointAsJson } = require('../fetch-endpoint');
const config = require('../config'); const config = require('../config');
@ -32,6 +33,17 @@ async function getMessagesResponseFromEventId({ accessToken, roomId, eventId, di
accessToken, accessToken,
}); });
// We want to re-paginte over the same event so it's included in the response.
//
// When going backwards, that means starting using the paginatin token after the event
// so we can see it looking backwards again.
let paginationToken = contextResData.end;
// When going forwards, that means starting using the paginatin token before the event
// so we can see it looking forwards again.
if (dir === DIRECTION.forward) {
paginationToken = contextResData.start;
}
// Add `filter={"lazy_load_members":true}` to only get member state events for // Add `filter={"lazy_load_members":true}` to only get member state events for
// the messages included in the response // the messages included in the response
const messagesEndpoint = urlJoin( const messagesEndpoint = urlJoin(
@ -39,7 +51,7 @@ async function getMessagesResponseFromEventId({ accessToken, roomId, eventId, di
`_matrix/client/r0/rooms/${encodeURIComponent( `_matrix/client/r0/rooms/${encodeURIComponent(
roomId roomId
)}/messages?dir=${dir}&from=${encodeURIComponent( )}/messages?dir=${dir}&from=${encodeURIComponent(
contextResData.end paginationToken
)}&limit=${limit}&filter={"lazy_load_members":true}` )}&limit=${limit}&filter={"lazy_load_members":true}`
); );
const { data: messageResData } = await fetchEndpointAsJson(messagesEndpoint, { const { data: messageResData } = await fetchEndpointAsJson(messagesEndpoint, {

View File

@ -18,6 +18,26 @@ const timestampToEvent = require('../lib/matrix-utils/timestamp-to-event');
const getMessagesResponseFromEventId = require('../lib/matrix-utils/get-messages-response-from-event-id'); const getMessagesResponseFromEventId = require('../lib/matrix-utils/get-messages-response-from-event-id');
const renderHydrogenVmRenderScriptToPageHtml = require('../hydrogen-render/render-hydrogen-vm-render-script-to-page-html'); const renderHydrogenVmRenderScriptToPageHtml = require('../hydrogen-render/render-hydrogen-vm-render-script-to-page-html');
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
const {
MS_LOOKUP,
TIME_PRECISION_VALUES,
DIRECTION,
} = require('matrix-public-archive-shared/lib/reference-values');
const { ONE_DAY_IN_MS, ONE_HOUR_IN_MS, ONE_MINUTE_IN_MS, ONE_SECOND_IN_MS } = MS_LOOKUP;
const {
roundUpTimestampToUtcDay,
roundUpTimestampToUtcHour,
roundUpTimestampToUtcMinute,
roundUpTimestampToUtcSecond,
getUtcStartOfDayTs,
getUtcStartOfHourTs,
getUtcStartOfMinuteTs,
getUtcStartOfSecondTs,
areTimestampsFromSameUtcDay,
areTimestampsFromSameUtcHour,
areTimestampsFromSameUtcMinute,
areTimestampsFromSameUtcSecond,
} = require('matrix-public-archive-shared/lib/timestamp-utilities');
const config = require('../lib/config'); const config = require('../lib/config');
const basePath = config.get('basePath'); const basePath = config.get('basePath');
@ -43,6 +63,15 @@ const VALID_ENTITY_DESCRIPTOR_TO_SIGIL_MAP = {
const validSigilList = Object.values(VALID_ENTITY_DESCRIPTOR_TO_SIGIL_MAP); const validSigilList = Object.values(VALID_ENTITY_DESCRIPTOR_TO_SIGIL_MAP);
const sigilRe = new RegExp(`^(${validSigilList.join('|')})`); const sigilRe = new RegExp(`^(${validSigilList.join('|')})`);
function getErrorStringForTooManyMessages(archiveMessageLimit) {
const message =
`Too many messages were sent all within a second for us to display ` +
`(more than ${archiveMessageLimit} in one second). We're unable to redirect you to ` +
`a smaller time range to view them without losing a few between each page. ` +
`Since this is probably pretty rare, we've decided not to support it for now.`;
return message;
}
function getRoomIdOrAliasFromReq(req) { function getRoomIdOrAliasFromReq(req) {
const entityDescriptor = req.params.entityDescriptor; const entityDescriptor = req.params.entityDescriptor;
// This could be with or with our without the sigil. Although the correct thing here // This could be with or with our without the sigil. Although the correct thing here
@ -60,46 +89,73 @@ function getRoomIdOrAliasFromReq(req) {
return `${sigil}${roomIdOrAliasWithoutSigil}`; return `${sigil}${roomIdOrAliasWithoutSigil}`;
} }
// eslint-disable-next-line max-statements, complexity
function parseArchiveRangeFromReq(req) { function parseArchiveRangeFromReq(req) {
const yyyy = parseInt(req.params.yyyy, 10); const yyyy = parseInt(req.params.yyyy, 10);
// Month is the only zero-based index in this group // Month is the only zero-based index in this group
const mm = parseInt(req.params.mm, 10) - 1; const mm = parseInt(req.params.mm, 10) - 1;
const dd = parseInt(req.params.dd, 10); const dd = parseInt(req.params.dd, 10);
const hourRange = req.params.hourRange; const timeString = req.params.time;
let timeInMs = 0;
let timeDefined = false;
let secondsDefined = false;
if (timeString) {
const timeMatches = timeString.match(/^T(\d\d?):(\d\d?)(?::(\d\d?))?$/);
let fromHour = 0; if (!timeMatches) {
let toHour = 0; throw new StatusError(
if (hourRange) { 404,
const hourMatches = hourRange.match(/^(\d\d?)-(\d\d?)$/); 'Time was unable to be parsed from URL. It should be in 24-hour format 23:59:59'
);
if (!hourMatches) {
throw new StatusError(404, 'Hour was unable to be parsed');
} }
fromHour = parseInt(hourMatches[1], 10); const hour = timeMatches[1] && parseInt(timeMatches[1], 10);
toHour = parseInt(hourMatches[2], 10); const minute = timeMatches[2] && parseInt(timeMatches[2], 10);
const second = timeMatches[3] ? parseInt(timeMatches[3], 10) : 0;
if (Number.isNaN(fromHour) || fromHour < 0 || fromHour > 23) { timeDefined = !!timeMatches;
throw new StatusError(404, 'From hour can only be in range 0-23'); // Whether the timestamp included seconds
secondsDefined = !!timeMatches[3];
if (Number.isNaN(hour) || hour < 0 || hour > 23) {
throw new StatusError(404, `Hour can only be in range 0-23 -> ${hour}`);
} }
if (Number.isNaN(minute) || minute < 0 || minute > 59) {
throw new StatusError(404, `Minute can only be in range 0-59 -> ${minute}`);
}
if (Number.isNaN(second) || second < 0 || second > 59) {
throw new StatusError(404, `Second can only be in range 0-59 -> ${second}`);
}
const hourInMs = hour * ONE_HOUR_IN_MS;
const minuteInMs = minute * ONE_MINUTE_IN_MS;
const secondInMs = second * ONE_SECOND_IN_MS;
timeInMs = hourInMs + minuteInMs + secondInMs;
} }
const fromTimestamp = Date.UTC(yyyy, mm, dd, fromHour); let toTimestamp;
let toTimestamp = Date.UTC(yyyy, mm, dd + 1, fromHour); if (timeInMs) {
if (hourRange) { const startOfDayTimestamp = Date.UTC(yyyy, mm, dd);
toTimestamp = Date.UTC(yyyy, mm, dd, toHour); toTimestamp = startOfDayTimestamp + timeInMs;
}
// If no time specified, then we assume end-of-day
else {
// We `- 1` from UTC midnight to get the timestamp that is a millisecond before the
// next day T23:59:59.999
toTimestamp = Date.UTC(yyyy, mm, dd + 1) - 1;
} }
return { return {
fromTimestamp,
toTimestamp, toTimestamp,
yyyy, yyyy,
mm, mm,
dd, dd,
hourRange, // Whether the req included time `T23:59`
fromHour, timeDefined,
toHour, // Whether the req included seconds in the time `T23:59:59`
secondsDefined,
}; };
} }
@ -119,12 +175,12 @@ router.get(
// any of the additional room info or messages. // any of the additional room info or messages.
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via); const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
// Find the closest day to today with messages // Find the closest day to the current time with messages
const { originServerTs } = await timestampToEvent({ const { originServerTs } = await timestampToEvent({
accessToken: matrixAccessToken, accessToken: matrixAccessToken,
roomId, roomId,
ts: dateBeforeJoin, ts: dateBeforeJoin,
direction: 'b', direction: DIRECTION.backward,
}); });
if (!originServerTs) { if (!originServerTs) {
throw new StatusError(404, 'Unable to find day with history'); throw new StatusError(404, 'Unable to find day with history');
@ -153,33 +209,139 @@ router.get(
router.get( router.get(
'/jump', '/jump',
// eslint-disable-next-line max-statements // eslint-disable-next-line max-statements, complexity
asyncHandler(async function (req, res) { asyncHandler(async function (req, res) {
const roomIdOrAlias = getRoomIdOrAliasFromReq(req); const roomIdOrAlias = getRoomIdOrAliasFromReq(req);
const ts = parseInt(req.query.ts, 10); const currentRangeStartTs = parseInt(req.query.currentRangeStartTs, 10);
assert(!Number.isNaN(ts), '?ts query parameter must be a number'); assert(
!Number.isNaN(currentRangeStartTs),
'?currentRangeStartTs query parameter must be a number'
);
const currentRangeEndTs = parseInt(req.query.currentRangeEndTs, 10);
assert(!Number.isNaN(currentRangeEndTs), '?currentRangeEndTs query parameter must be a number');
const dir = req.query.dir; const dir = req.query.dir;
assert(['f', 'b'].includes(dir), '?dir query parameter must be [f|b]'); assert(
[DIRECTION.forward, DIRECTION.backward].includes(dir),
'?dir query parameter must be [f|b]'
);
let ts;
if (dir === DIRECTION.backward) {
// We `- 1` so we don't jump to the same event because the endpoint is inclusive.
//
// XXX: This is probably an edge-case flaw when there could be multiple events at
// the same timestamp
ts = currentRangeStartTs - 1;
} else if (dir === DIRECTION.forward) {
// We `+ 1` so we don't jump to the same event because the endpoint is inclusive
//
// XXX: This is probably an edge-case flaw when there could be multiple events at
// the same timestamp
ts = currentRangeEndTs + 1;
} else {
throw new Error(`Unable to handle unknown dir=${dir} in /jump`);
}
// We have to wait for the room join to happen first before we can use the jump to // We have to wait for the room join to happen first before we can use the jump to
// date endpoint // date endpoint
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via); const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
let eventIdForTimestamp; let eventIdForClosestEvent;
let originServerTs; let tsForClosestEvent;
let newOriginServerTs;
let preferredPrecision = null;
try { try {
// Find the closest day to today with messages // We pull this fresh from the config for each request to ensure we have an
({ eventId: eventIdForTimestamp, originServerTs } = await timestampToEvent({ // updated value between each e2e test
accessToken: matrixAccessToken, const archiveMessageLimit = config.get('archiveMessageLimit');
roomId,
ts: ts,
direction: dir,
}));
// The goal is to go forward 100 messages, so that when we view the room at that // Find the closest event to the given timestamp
// point going backwards 100 messages, we end up at the perfect sam continuation ({ eventId: eventIdForClosestEvent, originServerTs: tsForClosestEvent } =
// spot in the room. await timestampToEvent({
accessToken: matrixAccessToken,
roomId,
ts: ts,
direction: dir,
}));
// Based on what we found was the closest, figure out the URL that will represent
// the next chunk in the desired direction.
// ==============================
//
// When jumping backwards, since a given room archive URL represents the end of
// the day/time-period looking backward (scroll is also anchored to the bottom),
// we just need to get the user to the previous time-period.
//
// We are trying to avoid sending the user to the same time period they were just
// viewing. i.e, if they were visiting `/2020/01/02T16:00:00` (displays messages
// backwards from that time up to the limit), which had more messages than we
// could display in that day, jumping backwards from the earliest displayed event
// in the displayed range, say `T12:00:05` would still give us the same day
// `/2020/01/02` and we want to redirect them to previous chunk from that same
// day, like `/2020/01/02T12:00:00`
if (dir === DIRECTION.backward) {
const fromSameDay =
tsForClosestEvent && areTimestampsFromSameUtcDay(currentRangeEndTs, tsForClosestEvent);
const fromSameHour =
tsForClosestEvent && areTimestampsFromSameUtcHour(currentRangeEndTs, tsForClosestEvent);
const fromSameMinute =
tsForClosestEvent && areTimestampsFromSameUtcMinute(currentRangeEndTs, tsForClosestEvent);
const fromSameSecond =
tsForClosestEvent && areTimestampsFromSameUtcSecond(currentRangeEndTs, tsForClosestEvent);
// The closest event is from the same second we tried to jump from. Since we
// can't represent something smaller than a second in the URL yet (we could do
// ms but it's a concious choice to make the URL cleaner,
// #support-ms-time-slice), we will need to just return the timestamp with a
// precision of seconds and hope that there isn't too many messages in this same
// second.
//
// XXX: If there is too many messages all within the same second, people will be
// stuck visiting the same page over and over every time they try to jump
// backwards from that range.
if (fromSameSecond) {
newOriginServerTs = tsForClosestEvent;
preferredPrecision = TIME_PRECISION_VALUES.seconds;
}
// The closest event is from the same minute we tried to jump from, we will need
// to round up to the nearest second so that the URL encompasses the closest
// event looking backwards
else if (fromSameMinute) {
newOriginServerTs = roundUpTimestampToUtcSecond(tsForClosestEvent);
preferredPrecision = TIME_PRECISION_VALUES.seconds;
}
// The closest event is from the same hour we tried to jump from, we will need
// to round up to the nearest minute so that the URL encompasses the closest
// event looking backwards
else if (fromSameHour) {
newOriginServerTs = roundUpTimestampToUtcMinute(tsForClosestEvent);
preferredPrecision = TIME_PRECISION_VALUES.minutes;
}
// The closest event is from the same day we tried to jump from, we will need to
// round up to the nearest hour so that the URL encompasses the closest event
// looking backwards
else if (fromSameDay) {
newOriginServerTs = roundUpTimestampToUtcHour(tsForClosestEvent);
preferredPrecision = TIME_PRECISION_VALUES.minutes;
}
// We don't need to do anything. The next closest event is far enough away
// (greater than 1 day) where we don't need to worry about the URL at all and
// can just render whatever day that the closest event is from because the
// archives biggest time-period represented in the URL is a day.
//
// We can display more than a day of content at a given URL (imagine lots of a
// quiet days in a room), but the URL will never represent a time-period
// greater than a day, ex. `/2023/01/01`. We don't allow someone to just
// specify the month like `/2023/01` ❌
else {
newOriginServerTs = tsForClosestEvent;
}
}
// When jumping forwards, the goal is to go forward 100 messages, so that when we
// view the room at that point going backwards 100 messages (which is how the
// archive works for any given date from the archive URL), we end up at the
// perfect continuation spot in the room (seamless).
// //
// XXX: This is flawed in the fact that when we go `/messages?dir=b` later, it // XXX: This is flawed in the fact that when we go `/messages?dir=b` later, it
// could backfill messages which will fill up the response before we perfectly // could backfill messages which will fill up the response before we perfectly
@ -187,22 +349,26 @@ router.get(
// `/messages?dir=f` backfills, we won't have this problem anymore because any // `/messages?dir=f` backfills, we won't have this problem anymore because any
// messages backfilled in the forwards direction would be picked up the same going // messages backfilled in the forwards direction would be picked up the same going
// backwards. // backwards.
if (dir === 'f') { if (dir === DIRECTION.forward) {
// Use `/messages?dir=f` and get the `end` pagination token to paginate from. And // Use `/messages?dir=f` and get the `end` pagination token to paginate from. And
// then start the scroll from the top of the page so they can continue. // then start the scroll from the top of the page so they can continue.
const archiveMessageLimit = config.get('archiveMessageLimit'); //
// XXX: It would be cool to somehow cache this response and re-use our work here
// for the actual room display that we redirect to from this route. No need for
// us go out 100 messages, only for us to go backwards 100 messages again in the
// next route.
const messageResData = await getMessagesResponseFromEventId({ const messageResData = await getMessagesResponseFromEventId({
accessToken: matrixAccessToken, accessToken: matrixAccessToken,
roomId, roomId,
eventId: eventIdForTimestamp, eventId: eventIdForClosestEvent,
dir: 'f', dir: DIRECTION.forward,
limit: archiveMessageLimit, limit: archiveMessageLimit,
}); });
if (!messageResData.chunk?.length) { if (!messageResData.chunk?.length) {
throw new StatusError( throw new StatusError(
404, 404,
`/messages response didn't contain any more messages to jump to` `/jump?dir=${dir}: /messages response didn't contain any more messages to jump to`
); );
} }
@ -210,32 +376,84 @@ router.get(
messageResData.chunk[messageResData.chunk.length - 1].origin_server_ts; messageResData.chunk[messageResData.chunk.length - 1].origin_server_ts;
const dateOfLastMessage = new Date(timestampOfLastMessage); const dateOfLastMessage = new Date(timestampOfLastMessage);
// Back track from the last message timestamp to the date boundary. This will // Back-track from the last message timestamp to the nearest date boundary.
// gurantee some overlap with the previous page we jumped from so we don't lose // Because we're back-tracking a couple events here, when we paginate back out
// any messages in the gap. // by the `archiveMessageLimit` later in the room route, it will gurantee some
// overlap with the previous page we jumped from so we don't lose any messages
// in the gap.
// //
// XXX: This date boundary logic may need to change once we introduce hour // We could choose to jump to the exact timestamp of the last message instead of
// chunks or time slices // back-tracking but then we get ugly URL's every time you jump instead of being
// (https://github.com/matrix-org/matrix-public-archive/issues/7). For example // able to back-track and round down to the nearest hour in a lot of cases. The
// if we reached into the next day but it has too many messages to show for a // other reason not to return the exact date is maybe there multiple messages at
// given page, we would want to back track until a suitable time slice boundary. // the same timestamp and we will lose messages in the gap it displays more than
// Maybe we need to add a new URL parameter here `?time-slice=true` to indicate // we thought.
// that it's okay to break it up by time slice based on previously having to const msGapFromJumpPointToLastMessage = timestampOfLastMessage - ts;
// view by time slice. We wouldn't want to give const moreThanDayGap = msGapFromJumpPointToLastMessage > ONE_DAY_IN_MS;
const utcMidnightOfDayBefore = Date.UTC( const moreThanHourGap = msGapFromJumpPointToLastMessage > ONE_HOUR_IN_MS;
dateOfLastMessage.getUTCFullYear(), const moreThanMinuteGap = msGapFromJumpPointToLastMessage > ONE_MINUTE_IN_MS;
dateOfLastMessage.getUTCMonth(), const moreThanSecondGap = msGapFromJumpPointToLastMessage > ONE_SECOND_IN_MS;
dateOfLastMessage.getUTCDate()
);
// We minus 1 from UTC midnight to get to the day before
const endOfDayBeforeDate = new Date(utcMidnightOfDayBefore - 1);
originServerTs = endOfDayBeforeDate; // If the `/messages` response returns less than the `archiveMessageLimit`
// looking forwards, it means we're looking at the latest events in the room. We
// can simply just display the day that the latest event occured on or given
// rangeEnd (whichever is later).
const haveReachedLatestMessagesInRoom = messageResData.chunk?.length < archiveMessageLimit;
if (haveReachedLatestMessagesInRoom) {
const latestDesiredTs = Math.max(currentRangeEndTs, timestampOfLastMessage);
const latestDesiredDate = new Date(latestDesiredTs);
const utcMidnightTs = getUtcStartOfDayTs(latestDesiredDate);
newOriginServerTs = utcMidnightTs;
preferredPrecision = TIME_PRECISION_VALUES.none;
}
// More than a day gap here, so we can just back-track to the nearest day
else if (moreThanDayGap) {
const utcMidnightOfDayBefore = getUtcStartOfDayTs(dateOfLastMessage);
// We `- 1` from UTC midnight to get the timestamp that is a millisecond
// before the next day but we choose a no time precision so we jump to just
// the bare date without a time. A bare date in the `/date/2022/12/16`
// endpoint represents the end of that day looking backwards so this is
// exactly what we want.
const endOfDayBeforeTs = utcMidnightOfDayBefore - 1;
newOriginServerTs = endOfDayBeforeTs;
preferredPrecision = TIME_PRECISION_VALUES.none;
}
// More than a hour gap here, we will need to back-track to the nearest hour
else if (moreThanHourGap) {
const utcTopOfHourBefore = getUtcStartOfHourTs(dateOfLastMessage);
newOriginServerTs = utcTopOfHourBefore;
preferredPrecision = TIME_PRECISION_VALUES.minutes;
}
// More than a minute gap here, we will need to back-track to the nearest minute
else if (moreThanMinuteGap) {
const utcTopOfMinuteBefore = getUtcStartOfMinuteTs(dateOfLastMessage);
newOriginServerTs = utcTopOfMinuteBefore;
preferredPrecision = TIME_PRECISION_VALUES.minutes;
}
// More than a second gap here, we will need to back-track to the nearest second
else if (moreThanSecondGap) {
const utcTopOfSecondBefore = getUtcStartOfSecondTs(dateOfLastMessage);
newOriginServerTs = utcTopOfSecondBefore;
preferredPrecision = TIME_PRECISION_VALUES.seconds;
}
// Less than a second gap here, we will give up.
//
// XXX: Maybe we can support ms here (#support-ms-time-slice)
else {
// 501 Not Implemented: the server does not support the functionality required
// to fulfill the request
res.status(501);
res.send(
`/jump ran into a problem: ${getErrorStringForTooManyMessages(archiveMessageLimit)}`
);
return;
}
} }
} catch (err) { } catch (err) {
const is404Error = err instanceof HTTPResponseError && err.response.status === 404; const is404Error = err instanceof HTTPResponseError && err.response.status === 404;
// Only throw if it's something other than a 404 error. 404 errors are fine, they // Only throw if it's something other than a 404 error. 404 errors are fine, they
// just mean there is no more messages to paginate in that room. // just mean there is no more messages to paginate in that room and we were
// already viewing the latest in the room.
if (!is404Error) { if (!is404Error) {
throw err; throw err;
} }
@ -244,37 +462,50 @@ router.get(
// If we can't find any more messages to paginate to, just progress the date by a // If we can't find any more messages to paginate to, just progress the date by a
// day in whatever direction they wanted to go so we can display the empty view for // day in whatever direction they wanted to go so we can display the empty view for
// that day. // that day.
if (!originServerTs) { if (!newOriginServerTs) {
const tsDate = new Date(ts); let tsAtRangeBoundaryInDirection;
const yyyy = tsDate.getUTCFullYear(); if (dir === DIRECTION.backward) {
const mm = tsDate.getUTCMonth(); tsAtRangeBoundaryInDirection = currentRangeStartTs;
const dd = tsDate.getUTCDate(); } else if (dir === DIRECTION.forward) {
tsAtRangeBoundaryInDirection = currentRangeEndTs;
}
const newDayDelta = dir === 'f' ? 1 : -1; const dateAtRangeBoundaryInDirection = new Date(tsAtRangeBoundaryInDirection);
originServerTs = Date.UTC(yyyy, mm, dd + newDayDelta); const yyyy = dateAtRangeBoundaryInDirection.getUTCFullYear();
const mm = dateAtRangeBoundaryInDirection.getUTCMonth();
const dd = dateAtRangeBoundaryInDirection.getUTCDate();
const newDayDelta = dir === DIRECTION.forward ? 1 : -1;
newOriginServerTs = Date.UTC(yyyy, mm, dd + newDayDelta);
} }
// Redirect to a day with messages // Redirect to a day with messages
res.redirect( const archiveUrlToRedirecTo = matrixPublicArchiveURLCreator.archiveUrlForDate(
// TODO: Add query parameter that causes the client to start the scroll at the top roomIdOrAlias,
// when jumping forwards so they can continue reading where they left off. new Date(newOriginServerTs),
matrixPublicArchiveURLCreator.archiveUrlForDate(roomIdOrAlias, new Date(originServerTs), { {
// Start the scroll at the next event from where they jumped from (seamless navigation) // Start the scroll at the next event from where they jumped from (seamless navigation)
scrollStartEventId: eventIdForTimestamp, scrollStartEventId: eventIdForClosestEvent,
}) preferredPrecision,
}
); );
res.redirect(archiveUrlToRedirecTo);
}) })
); );
// Based off of the Gitter archive routes, // Shows messages from the given date/time looking backwards up to the limit.
// https://gitlab.com/gitterHQ/webapp/-/blob/14954e05c905e8c7cb675efebb89116c07cfaab5/server/handlers/app/archive.js#L190-297
router.get( router.get(
'/date/:yyyy(\\d{4})/:mm(\\d{2})/:dd(\\d{2})/:hourRange(\\d\\d?-\\d\\d?)?', // The extra set of parenthesis around `((:\\d\\d?)?)` is to work around a
// `path-to-regex` bug where the `?` wasn't attaching to the capture group, see
// https://github.com/pillarjs/path-to-regexp/issues/287
'/date/:yyyy(\\d{4})/:mm(\\d{2})/:dd(\\d{2}):time(T\\d\\d?:\\d\\d?((:\\d\\d?)?))?',
timeoutMiddleware, timeoutMiddleware,
// eslint-disable-next-line max-statements // eslint-disable-next-line max-statements, complexity
asyncHandler(async function (req, res) { asyncHandler(async function (req, res) {
const roomIdOrAlias = getRoomIdOrAliasFromReq(req); const roomIdOrAlias = getRoomIdOrAliasFromReq(req);
// We pull this fresh from the config for each request to ensure we have an
// updated value between each e2e test
const archiveMessageLimit = config.get('archiveMessageLimit'); const archiveMessageLimit = config.get('archiveMessageLimit');
assert(archiveMessageLimit); assert(archiveMessageLimit);
// Synapse has a max `/messages` limit of 1000 // Synapse has a max `/messages` limit of 1000
@ -283,47 +514,26 @@ router.get(
'archiveMessageLimit needs to be in range [1, 999]. We can only get 1000 messages at a time from Synapse and we need a buffer of at least one to see if there are too many messages on a given day so you can only configure a max of 999. If you need more messages, we will have to implement pagination' 'archiveMessageLimit needs to be in range [1, 999]. We can only get 1000 messages at a time from Synapse and we need a buffer of at least one to see if there are too many messages on a given day so you can only configure a max of 999. If you need more messages, we will have to implement pagination'
); );
const { fromTimestamp, toTimestamp, hourRange, fromHour, toHour } = const { toTimestamp, timeDefined, secondsDefined } = parseArchiveRangeFromReq(req);
parseArchiveRangeFromReq(req);
let precisionFromUrl = TIME_PRECISION_VALUES.none;
if (secondsDefined) {
precisionFromUrl = TIME_PRECISION_VALUES.seconds;
} else if (timeDefined) {
precisionFromUrl = TIME_PRECISION_VALUES.minutes;
}
// Just 404 if anyone is trying to view the future, no need to waste resources on that // Just 404 if anyone is trying to view the future, no need to waste resources on that
const nowTs = Date.now(); const nowTs = Date.now();
if (fromTimestamp > nowTs) { if (toTimestamp > roundUpTimestampToUtcDay(nowTs)) {
throw new StatusError( throw new StatusError(
404, 404,
`You can't view the history of a room on a future day (${new Date( `You can't view the history of a room on a future day (${new Date(
fromTimestamp toTimestamp
).toISOString()} > ${new Date(nowTs).toISOString()}). Go back` ).toISOString()} > ${new Date(nowTs).toISOString()}). Go back`
); );
} }
// If the hourRange is defined, we force the range to always be 1 hour. If
// the format isn't correct, redirect to the correct hour range
if (hourRange && toHour !== fromHour + 1) {
// Pass through the query parameters
let queryParamterUrlPiece = '';
if (req.query) {
queryParamterUrlPiece = `?${new URLSearchParams(req.query).toString()}`;
}
res.redirect(
// FIXME: Can we use the matrixPublicArchiveURLCreator here?
`${urlJoin(
basePath,
roomIdOrAlias,
'date',
req.params.yyyy,
req.params.mm,
req.params.dd,
`${fromHour}-${fromHour + 1}`
)}${queryParamterUrlPiece}`
);
return;
}
// TODO: Highlight tile that matches ?at=$xxx
//const aroundId = req.query.at;
// We have to wait for the room join to happen first before we can fetch // We have to wait for the room join to happen first before we can fetch
// any of the additional room info or messages. // any of the additional room info or messages.
const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via); const roomId = await ensureRoomJoined(matrixAccessToken, roomIdOrAlias, req.query.via);
@ -335,16 +545,19 @@ router.get(
// We over-fetch messages outside of the range of the given day so that we // We over-fetch messages outside of the range of the given day so that we
// can display messages from surrounding days (currently only from days // can display messages from surrounding days (currently only from days
// before) so that the quiet rooms don't feel as desolate and broken. // before) so that the quiet rooms don't feel as desolate and broken.
//
// When given a bare date like `2022/11/16`, we want to paginate from the end of that
// day backwards. This is why we use the `toTimestamp` here and fetch backwards.
fetchEventsFromTimestampBackwards({ fetchEventsFromTimestampBackwards({
accessToken: matrixAccessToken, accessToken: matrixAccessToken,
roomId, roomId,
ts: toTimestamp, ts: toTimestamp,
// We fetch one more than the `archiveMessageLimit` so that we can see // We fetch one more than the `archiveMessageLimit` so that we can see if there
// there are too many messages from the given day. If we have over the // are too many messages from the given day. If we have over the
// `archiveMessageLimit` number of messages fetching from the given day, // `archiveMessageLimit` number of messages fetching from the given day, it's
// it's acceptable to have them be from surrounding days. But if all 500 // acceptable to have them be from surrounding days. But if all 500 messages
// messages (for example) are from the same day, let's redirect to a // (for example) are from the same day, let's redirect to a smaller hour range
// smaller hour range to display. // to display.
limit: archiveMessageLimit + 1, limit: archiveMessageLimit + 1,
}), }),
]); ]);
@ -370,26 +583,6 @@ router.get(
shouldIndex = roomData?.historyVisibility === `world_readable`; shouldIndex = roomData?.historyVisibility === `world_readable`;
} }
// If we have over the `archiveMessageLimit` number of messages fetching
// from the given day, it's acceptable to have them be from surrounding
// days. But if all 500 messages (for example) are from the same day, let's
// redirect to a smaller hour range to display.
if (
// If there are too many messages, check that the event is from a previous
// day in the surroundings.
events.length >= archiveMessageLimit &&
// Since we're only fetching previous days for the surroundings, we only
// need to look at the oldest event in the chronological list.
//
// XXX: In the future when we also fetch events from days after, we will
// need to change this next day check.
events[0].origin_server_ts >= fromTimestamp
) {
res.send('TODO: Redirect user to smaller hour range');
res.status(204);
return;
}
const hydrogenStylesUrl = urlJoin(basePath, '/hydrogen-styles.css'); const hydrogenStylesUrl = urlJoin(basePath, '/hydrogen-styles.css');
const stylesUrl = urlJoin(basePath, '/css/styles.css'); const stylesUrl = urlJoin(basePath, '/css/styles.css');
const jsBundleUrl = urlJoin(basePath, '/js/entry-client-hydrogen.es.js'); const jsBundleUrl = urlJoin(basePath, '/js/entry-client-hydrogen.es.js');
@ -397,8 +590,8 @@ router.get(
const pageHtml = await renderHydrogenVmRenderScriptToPageHtml( const pageHtml = await renderHydrogenVmRenderScriptToPageHtml(
path.resolve(__dirname, '../../shared/hydrogen-vm-render-script.js'), path.resolve(__dirname, '../../shared/hydrogen-vm-render-script.js'),
{ {
fromTimestamp,
toTimestamp, toTimestamp,
precisionFromUrl,
roomData: { roomData: {
...roomData, ...roomData,
// The `canonicalAlias` will take precedence over the `roomId` when present so we only // The `canonicalAlias` will take precedence over the `roomId` when present so we only

View File

@ -8,16 +8,17 @@
const assert = require('matrix-public-archive-shared/lib/assert'); const assert = require('matrix-public-archive-shared/lib/assert');
const { Platform, MediaRepository, createNavigation, createRouter } = require('hydrogen-view-sdk'); const { Platform, MediaRepository, createNavigation, createRouter } = require('hydrogen-view-sdk');
const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values');
const ArchiveRoomView = require('matrix-public-archive-shared/views/ArchiveRoomView'); const ArchiveRoomView = require('matrix-public-archive-shared/views/ArchiveRoomView');
const ArchiveHistory = require('matrix-public-archive-shared/lib/archive-history'); const ArchiveHistory = require('matrix-public-archive-shared/lib/archive-history');
const supressBlankAnchorsReloadingThePage = require('matrix-public-archive-shared/lib/supress-blank-anchors-reloading-the-page'); const supressBlankAnchorsReloadingThePage = require('matrix-public-archive-shared/lib/supress-blank-anchors-reloading-the-page');
const ArchiveRoomViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveRoomViewModel'); const ArchiveRoomViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveRoomViewModel');
const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable'); const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable');
const fromTimestamp = window.matrixPublicArchiveContext.fromTimestamp;
assert(fromTimestamp);
const toTimestamp = window.matrixPublicArchiveContext.toTimestamp; const toTimestamp = window.matrixPublicArchiveContext.toTimestamp;
assert(toTimestamp); assert(toTimestamp);
const precisionFromUrl = window.matrixPublicArchiveContext.precisionFromUrl;
assert(Object.values(TIME_PRECISION_VALUES).includes(precisionFromUrl));
const roomData = window.matrixPublicArchiveContext.roomData; const roomData = window.matrixPublicArchiveContext.roomData;
assert(roomData); assert(roomData);
const events = window.matrixPublicArchiveContext.events; const events = window.matrixPublicArchiveContext.events;
@ -105,8 +106,8 @@ async function mountHydrogen() {
homeserverUrl: config.matrixServerUrl, homeserverUrl: config.matrixServerUrl,
room, room,
// The timestamp from the URL that was originally visited // The timestamp from the URL that was originally visited
dayTimestampFrom: fromTimestamp,
dayTimestampTo: toTimestamp, dayTimestampTo: toTimestamp,
precisionFromUrl,
scrollStartEventId, scrollStartEventId,
events, events,
stateEventMap, stateEventMap,

View File

@ -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,
};

View File

@ -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,
};

View File

@ -3,6 +3,7 @@
const urlJoin = require('url-join'); const urlJoin = require('url-join');
const assert = require('matrix-public-archive-shared/lib/assert'); const assert = require('matrix-public-archive-shared/lib/assert');
const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values');
function qsToUrlPiece(qs) { function qsToUrlPiece(qs) {
if (qs.toString()) { if (qs.toString()) {
@ -65,9 +66,22 @@ class URLCreator {
return `${urlJoin(this._basePath, `${urlPath}`)}${qsToUrlPiece(qs)}`; return `${urlJoin(this._basePath, `${urlPath}`)}${qsToUrlPiece(qs)}`;
} }
archiveUrlForDate(roomIdOrAlias, date, { viaServers = [], scrollStartEventId } = {}) { archiveUrlForDate(
roomIdOrAlias,
date,
{ preferredPrecision = null, viaServers = [], scrollStartEventId } = {}
) {
assert(roomIdOrAlias); assert(roomIdOrAlias);
assert(date); assert(date);
// `preferredPrecision` is optional but if they gave a value, make sure it's something expected
if (preferredPrecision) {
assert(
Object.values(TIME_PRECISION_VALUES).includes(preferredPrecision),
`TimeSelectorViewModel: options.preferredPrecision must be one of ${JSON.stringify(
Object.values(TIME_PRECISION_VALUES)
)}`
);
}
let qs = new URLSearchParams(); let qs = new URLSearchParams();
[].concat(viaServers).forEach((viaServer) => { [].concat(viaServers).forEach((viaServer) => {
@ -81,19 +95,36 @@ class URLCreator {
// Gives the date in YYYY/mm/dd format. // Gives the date in YYYY/mm/dd format.
// date.toISOString() -> 2022-02-16T23:20:04.709Z // date.toISOString() -> 2022-02-16T23:20:04.709Z
const urlDate = date.toISOString().split('T')[0].replaceAll('-', '/'); const [datePiece, timePiece] = date.toISOString().split('T');
// Get the `2022/02/16` part of it
const urlDate = datePiece.replaceAll('-', '/');
return `${urlJoin(this._basePath, `${urlPath}/date/${urlDate}`)}${qsToUrlPiece(qs)}`; // Get the `23:20:04` part of it (TIME_PRECISION_VALUES.seconds)
let urlTime = timePiece.split('.')[0];
if (preferredPrecision === TIME_PRECISION_VALUES.minutes) {
// We only want to replace the seconds part of the URL if its superfluous. `23:59:00`
// does not convey more information than `23:59` so we can safely remove it if the
// desired precision is in minutes.
urlTime = urlTime.replace(/:00$/, '');
}
const shouldIncludeTimeInUrl = !!preferredPrecision;
return `${urlJoin(
this._basePath,
`${urlPath}/date/${urlDate}${shouldIncludeTimeInUrl ? `T${urlTime}` : ''}`
)}${qsToUrlPiece(qs)}`;
} }
archiveJumpUrlForRoom(roomIdOrAlias, { ts, dir }) { archiveJumpUrlForRoom(roomIdOrAlias, { dir, currentRangeStartTs, currentRangeEndTs }) {
assert(roomIdOrAlias); assert(roomIdOrAlias);
assert(ts);
assert(dir); assert(dir);
assert(currentRangeStartTs);
assert(currentRangeEndTs);
let qs = new URLSearchParams(); let qs = new URLSearchParams();
qs.append('ts', ts);
qs.append('dir', dir); qs.append('dir', dir);
qs.append('currentRangeStartTs', currentRangeStartTs);
qs.append('currentRangeEndTs', currentRangeEndTs);
const urlPath = this._getArchiveUrlPathForRoomIdOrAlias(roomIdOrAlias); const urlPath = this._getArchiveUrlPathForRoomIdOrAlias(roomIdOrAlias);

View File

@ -16,6 +16,7 @@ const assert = require('matrix-public-archive-shared/lib/assert');
const ModalViewModel = require('matrix-public-archive-shared/viewmodels/ModalViewModel'); const ModalViewModel = require('matrix-public-archive-shared/viewmodels/ModalViewModel');
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
const CalendarViewModel = require('matrix-public-archive-shared/viewmodels/CalendarViewModel'); const CalendarViewModel = require('matrix-public-archive-shared/viewmodels/CalendarViewModel');
const TimeSelectorViewModel = require('matrix-public-archive-shared/viewmodels/TimeSelectorViewModel');
const DeveloperOptionsContentViewModel = require('matrix-public-archive-shared/viewmodels/DeveloperOptionsContentViewModel'); const DeveloperOptionsContentViewModel = require('matrix-public-archive-shared/viewmodels/DeveloperOptionsContentViewModel');
const RightPanelContentView = require('matrix-public-archive-shared/views/RightPanelContentView'); const RightPanelContentView = require('matrix-public-archive-shared/views/RightPanelContentView');
const AvatarViewModel = require('matrix-public-archive-shared/viewmodels/AvatarViewModel'); const AvatarViewModel = require('matrix-public-archive-shared/viewmodels/AvatarViewModel');
@ -23,6 +24,10 @@ const {
customTileClassForEntry, customTileClassForEntry,
} = require('matrix-public-archive-shared/lib/custom-tile-utilities'); } = require('matrix-public-archive-shared/lib/custom-tile-utilities');
const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable'); const stubPowerLevelsObservable = require('matrix-public-archive-shared/lib/stub-powerlevels-observable');
const { TIME_PRECISION_VALUES } = require('matrix-public-archive-shared/lib/reference-values');
const {
areTimestampsFromSameUtcDay,
} = require('matrix-public-archive-shared/lib/timestamp-utilities');
let txnCount = 0; let txnCount = 0;
function getFakeEventId() { function getFakeEventId() {
@ -57,14 +62,14 @@ function makeEventEntryFromEventJson(eventJson, memberEvent) {
} }
class ArchiveRoomViewModel extends ViewModel { class ArchiveRoomViewModel extends ViewModel {
// eslint-disable-next-line max-statements // eslint-disable-next-line max-statements, complexity
constructor(options) { constructor(options) {
super(options); super(options);
const { const {
homeserverUrl, homeserverUrl,
room, room,
dayTimestampFrom,
dayTimestampTo, dayTimestampTo,
precisionFromUrl,
scrollStartEventId, scrollStartEventId,
events, events,
stateEventMap, stateEventMap,
@ -73,15 +78,14 @@ class ArchiveRoomViewModel extends ViewModel {
} = options; } = options;
assert(homeserverUrl); assert(homeserverUrl);
assert(room); assert(room);
assert(dayTimestampFrom);
assert(dayTimestampTo); assert(dayTimestampTo);
assert(Object.values(TIME_PRECISION_VALUES).includes(precisionFromUrl));
assert(events); assert(events);
assert(stateEventMap); assert(stateEventMap);
assert(shouldIndex !== undefined); assert(shouldIndex !== undefined);
assert(events); assert(events);
this._room = room; this._room = room;
this._dayTimestampFrom = dayTimestampFrom;
this._dayTimestampTo = dayTimestampTo; this._dayTimestampTo = dayTimestampTo;
this._currentTopPositionEventEntry = null; this._currentTopPositionEventEntry = null;
this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath); this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
@ -98,6 +102,11 @@ class ArchiveRoomViewModel extends ViewModel {
stateEventMap, stateEventMap,
}); });
this._eventEntriesByEventId = eventEntriesByEventId; this._eventEntriesByEventId = eventEntriesByEventId;
// Since we anchor our scroll to the bottom when we page-load, it makes sense to set
// this as the bottom-most event entry by default. This variable says "TopPosition"
// but it means the top of the viewport which in the extreme case of the viewport
// being very short, should be the bottom-most event.
this._currentTopPositionEventEntry = events && eventEntriesByEventId[events[events.length - 1]];
this._roomAvatarViewModel = new AvatarViewModel({ this._roomAvatarViewModel = new AvatarViewModel({
homeserverUrlToPullMediaFrom: homeserverUrl, homeserverUrlToPullMediaFrom: homeserverUrl,
@ -112,14 +121,50 @@ class ArchiveRoomViewModel extends ViewModel {
entityId: this._room.id, entityId: this._room.id,
}); });
const initialDate = new Date(dayTimestampFrom); const timelineRangeStartTimestamp = events[0]?.origin_server_ts;
const timelineRangeEndTimestamp = events[events.length - 1]?.origin_server_ts;
const bottomMostEventDate = timelineRangeEndTimestamp && new Date(timelineRangeEndTimestamp);
const initialDate = new Date(dayTimestampTo);
// The activeDate gets updated based on what the `currentTopPositionEventEntry` is
// sob ecause we initialize with the bottom-most event as the
// `currentTopPositionEventEntry`, the starting activeDate should also be the
// timestamp from the bottom-most event. Otherwise, just fallback to the initialDate
const initialActiveDate = bottomMostEventDate || initialDate;
this._calendarViewModel = new CalendarViewModel({ this._calendarViewModel = new CalendarViewModel({
// The day being shown in the archive // The day being shown in the archive
activeDate: initialDate, activeDate: initialActiveDate,
// The month displayed in the calendar // The month displayed in the calendar
calendarDate: initialDate, calendarDate: initialActiveDate,
room, room,
basePath, matrixPublicArchiveURLCreator: this._matrixPublicArchiveURLCreator,
});
const shouldShowTimeSelector =
// If there are no events, then it's possible the user navigated too far back
// before the room was created and we will let them pick a new time that might make
// more sense. But only if they are worried about time precision in the URL already.
(precisionFromUrl !== TIME_PRECISION_VALUES.none && !events.length) ||
// Only show the time selector when we're showing events all from the same day.
(events.length &&
areTimestampsFromSameUtcDay(timelineRangeStartTimestamp, timelineRangeEndTimestamp));
this._timeSelectorViewModel = new TimeSelectorViewModel({
room,
// The time (within the given date) being displayed in the time scrubber.
activeDate: initialActiveDate,
// Prevent extra precision if it's not needed. We only need to show seconds if
// the page-loaded archive URL is worried about seconds.
preferredPrecision:
// Default to minutes for the time selector otherwise use whatever more fine
// grained precision that the URL is using
precisionFromUrl === TIME_PRECISION_VALUES.none
? TIME_PRECISION_VALUES.minutes
: precisionFromUrl,
timelineRangeStartTimestamp,
timelineRangeEndTimestamp,
matrixPublicArchiveURLCreator: this._matrixPublicArchiveURLCreator,
}); });
this._developerOptionsContentViewModel = new DeveloperOptionsContentViewModel( this._developerOptionsContentViewModel = new DeveloperOptionsContentViewModel(
@ -162,6 +207,8 @@ class ArchiveRoomViewModel extends ViewModel {
type: 'custom', type: 'custom',
customView: RightPanelContentView, customView: RightPanelContentView,
calendarViewModel: this._calendarViewModel, calendarViewModel: this._calendarViewModel,
shouldShowTimeSelector,
timeSelectorViewModel: this._timeSelectorViewModel,
shouldIndex, shouldIndex,
get developerOptionsUrl() { get developerOptionsUrl() {
return urlRouter.urlForSegments([ return urlRouter.urlForSegments([
@ -254,6 +301,7 @@ class ArchiveRoomViewModel extends ViewModel {
return this._eventEntriesByEventId; return this._eventEntriesByEventId;
} }
// This is the event that appears at the very top of our visible timeline as you scroll around
get currentTopPositionEventEntry() { get currentTopPositionEventEntry() {
return this._currentTopPositionEventEntry; return this._currentTopPositionEventEntry;
} }
@ -262,16 +310,19 @@ class ArchiveRoomViewModel extends ViewModel {
return this._shouldShowRightPanel; return this._shouldShowRightPanel;
} }
// This is the event that appears at the very top of our visible timeline as you
// scroll around (see the IntersectionObserver)
setCurrentTopPositionEventEntry(currentTopPositionEventEntry) { setCurrentTopPositionEventEntry(currentTopPositionEventEntry) {
this._currentTopPositionEventEntry = currentTopPositionEventEntry; this._currentTopPositionEventEntry = currentTopPositionEventEntry;
this.emitChange('currentTopPositionEventEntry'); this.emitChange('currentTopPositionEventEntry');
// Update the calendar // Update the calendar and time scrubber
this._calendarViewModel.setActiveDate(currentTopPositionEventEntry.timestamp); this._calendarViewModel.setActiveDate(currentTopPositionEventEntry.timestamp);
this._timeSelectorViewModel.setActiveDate(currentTopPositionEventEntry.timestamp);
} }
get dayTimestampFrom() { get dayTimestampTo() {
return this._dayTimestampFrom; return this._dayTimestampTo;
} }
get roomDirectoryUrl() { get roomDirectoryUrl() {
@ -302,8 +353,22 @@ class ArchiveRoomViewModel extends ViewModel {
_addJumpSummaryEvents(inputEventList) { _addJumpSummaryEvents(inputEventList) {
const events = [...inputEventList]; const events = [...inputEventList];
// The start of the range to use as a jumping off point to the previous activity.
// This should be the first event in the timeline (oldest) or if there are no events
// in the timeline, we can jump from day.
const jumpRangeStartTimestamp = events[0]?.origin_server_ts || this._dayTimestampTo;
// The end of the range to use as a jumping off point to the next activity. You
// might expect this to be the last event in the timeline but since we paginate from
// `_dayTimestampTo` backwards, `_dayTimestampTo` is actually the newest timestamp
// to paginate from
const jumpRangeEndTimestamp = this._dayTimestampTo;
// Check whether the given day represented in the URL has any events on the page
// from that day. We only need to check the last event which would be closest to
// `_dayTimestampTo` anyway.
const lastEventTs = events[events.length - 1]?.origin_server_ts;
const hasEventsFromGivenDay = const hasEventsFromGivenDay =
events[events.length - 1]?.origin_server_ts >= this._dayTimestampFrom; lastEventTs && areTimestampsFromSameUtcDay(lastEventTs, this._dayTimestampTo);
let daySummaryKind; let daySummaryKind;
if (events.length === 0) { if (events.length === 0) {
daySummaryKind = 'no-events-at-all'; daySummaryKind = 'no-events-at-all';
@ -323,12 +388,12 @@ class ArchiveRoomViewModel extends ViewModel {
type: 'org.matrix.archive.jump_to_previous_activity_summary', type: 'org.matrix.archive.jump_to_previous_activity_summary',
room_id: this._room.id, room_id: this._room.id,
// Even though this isn't used for sort, just using the time where the event // Even though this isn't used for sort, just using the time where the event
// would logically be (at the start of the day) // would logically be (before any of the other events in the timeline)
origin_server_ts: events[0].origin_server_ts - 1, origin_server_ts: events[0].origin_server_ts - 1,
content: { content: {
canonicalAlias: this._room.canonicalAlias, canonicalAlias: this._room.canonicalAlias,
// The start of the range to use as a jumping off point to the previous activity jumpRangeStartTimestamp,
rangeStartTimestamp: events[0].origin_server_ts - 1, jumpRangeEndTimestamp,
// This is a bit cheating but I don't know how else to pass this kind of // This is a bit cheating but I don't know how else to pass this kind of
// info to the Tile viewmodel // info to the Tile viewmodel
basePath: this._basePath, basePath: this._basePath,
@ -343,17 +408,15 @@ class ArchiveRoomViewModel extends ViewModel {
type: 'org.matrix.archive.jump_to_next_activity_summary', type: 'org.matrix.archive.jump_to_next_activity_summary',
room_id: this._room.id, room_id: this._room.id,
// Even though this isn't used for sort, just using the time where the event // Even though this isn't used for sort, just using the time where the event
// would logically be. // would logically be (at the end of the day)
// origin_server_ts: this._dayTimestampTo,
// -1 so we're not at 00:00:00 of the next day
origin_server_ts: this._dayTimestampTo - 1,
content: { content: {
canonicalAlias: this._room.canonicalAlias, canonicalAlias: this._room.canonicalAlias,
daySummaryKind, daySummaryKind,
// The timestamp from the URL that was originally visited // The timestamp from the URL that was originally visited
dayTimestamp: this._dayTimestampFrom, dayTimestamp: this._dayTimestampTo,
// The end of the range to use as a jumping off point to the next activity jumpRangeStartTimestamp,
rangeEndTimestamp: this._dayTimestampTo, jumpRangeEndTimestamp,
// This is a bit cheating but I don't know how else to pass this kind of // This is a bit cheating but I don't know how else to pass this kind of
// info to the Tile viewmodel // info to the Tile viewmodel
basePath: this._basePath, basePath: this._basePath,

View File

@ -3,23 +3,22 @@
const { ViewModel } = require('hydrogen-view-sdk'); const { ViewModel } = require('hydrogen-view-sdk');
const assert = require('matrix-public-archive-shared/lib/assert'); const assert = require('matrix-public-archive-shared/lib/assert');
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
class CalendarViewModel extends ViewModel { class CalendarViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const { activeDate, calendarDate, room, basePath } = options; const { activeDate, calendarDate, room, matrixPublicArchiveURLCreator } = options;
assert(activeDate); assert(activeDate);
assert(calendarDate); assert(calendarDate);
assert(room); assert(room);
assert(basePath); assert(matrixPublicArchiveURLCreator);
// The day being shown in the archive // The day being shown in the archive
this._activeDate = activeDate; this._activeDate = activeDate;
// The month displayed in the calendar // The month displayed in the calendar
this._calendarDate = calendarDate; this._calendarDate = calendarDate;
this._room = room; this._room = room;
this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath); this._matrixPublicArchiveURLCreator = matrixPublicArchiveURLCreator;
} }
get activeDate() { get activeDate() {

View File

@ -2,6 +2,7 @@
const { SimpleTile } = require('hydrogen-view-sdk'); const { SimpleTile } = require('hydrogen-view-sdk');
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
const assert = require('../lib/assert'); const assert = require('../lib/assert');
@ -27,17 +28,23 @@ class JumpToNextActivitySummaryTileViewModel extends SimpleTile {
return this._entry?.content?.['dayTimestamp']; return this._entry?.content?.['dayTimestamp'];
} }
// The start of the range to use as a jumping off point to the previous activity
get jumpRangeStartTimestamp() {
return this._entry?.content?.['jumpRangeStartTimestamp'];
}
// The end of the range to use as a jumping off point to the next activity // The end of the range to use as a jumping off point to the next activity
get rangeEndTimestamp() { get jumpRangeEndTimestamp() {
return this._entry?.content?.['rangeEndTimestamp']; return this._entry?.content?.['jumpRangeEndTimestamp'];
} }
get jumpToNextActivityUrl() { get jumpToNextActivityUrl() {
return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom( return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(
this._entry?.content?.['canonicalAlias'] || this._entry.roomId, this._entry?.content?.['canonicalAlias'] || this._entry.roomId,
{ {
ts: this.rangeEndTimestamp, dir: DIRECTION.forward,
dir: 'f', currentRangeStartTs: this.jumpRangeStartTimestamp,
currentRangeEndTs: this.jumpRangeEndTimestamp,
} }
); );
} }

View File

@ -2,6 +2,7 @@
const { SimpleTile } = require('hydrogen-view-sdk'); const { SimpleTile } = require('hydrogen-view-sdk');
const { DIRECTION } = require('matrix-public-archive-shared/lib/reference-values');
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
const assert = require('../lib/assert'); const assert = require('../lib/assert');
@ -20,16 +21,22 @@ class JumpToPreviousActivitySummaryTileViewModel extends SimpleTile {
} }
// The start of the range to use as a jumping off point to the previous activity // The start of the range to use as a jumping off point to the previous activity
get rangeStartTimestamp() { get jumpRangeStartTimestamp() {
return this._entry?.content?.['rangeStartTimestamp']; return this._entry?.content?.['jumpRangeStartTimestamp'];
}
// The end of the range to use as a jumping off point to the next activity
get jumpRangeEndTimestamp() {
return this._entry?.content?.['jumpRangeEndTimestamp'];
} }
get jumpToPreviousActivityUrl() { get jumpToPreviousActivityUrl() {
return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom( return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(
this._entry?.content?.['canonicalAlias'] || this._entry.roomId, this._entry?.content?.['canonicalAlias'] || this._entry.roomId,
{ {
ts: this.rangeStartTimestamp, dir: DIRECTION.backward,
dir: 'b', currentRangeStartTs: this.jumpRangeStartTimestamp,
currentRangeEndTs: this.jumpRangeEndTimestamp,
} }
); );
} }

View File

@ -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;

View File

@ -92,8 +92,8 @@ class DisabledComposerView extends TemplateView {
const activeDate = new Date( const activeDate = new Date(
// If the date from our `archiveRoomViewModel` is available, use that // If the date from our `archiveRoomViewModel` is available, use that
vm?.currentTopPositionEventEntry?.timestamp || vm?.currentTopPositionEventEntry?.timestamp ||
// Otherwise, use our initial `dayTimestampFrom` // Otherwise, use our initial `dayTimestampTo`
vm.dayTimestampFrom vm.dayTimestampTo
); );
const dateString = activeDate.toISOString().split('T')[0]; const dateString = activeDate.toISOString().split('T')[0];
return t.span(`You're viewing an archive of events from ${dateString}. Use a `); return t.span(`You're viewing an archive of events from ${dateString}. Use a `);
@ -114,6 +114,19 @@ class DisabledComposerView extends TemplateView {
} }
class ArchiveRoomView extends TemplateView { class ArchiveRoomView extends TemplateView {
constructor(vm) {
super(vm);
// Keep track of the `IntersectionObserver` so we can disconnect it when necessary
this._interSectionObserverForUpdatedTopPositionEventEntry = null;
}
unmount() {
if (this._interSectionObserverForUpdatedTopPositionEventEntry) {
this._interSectionObserverForUpdatedTopPositionEventEntry.disconnect();
}
}
render(t, vm) { render(t, vm) {
const rootElement = t.div( const rootElement = t.div(
{ {
@ -126,7 +139,7 @@ class ArchiveRoomView extends TemplateView {
// The red border and yellow background trail around the event that is // The red border and yellow background trail around the event that is
// driving the active date as you scroll around. // driving the active date as you scroll around.
t.if( t.if(
(vm) => vm._developerOptionsViewModel?.debugActiveDateIntersectionObserver, (vm) => vm._developerOptionsContentViewModel?.debugActiveDateIntersectionObserver,
(t /*, vm*/) => { (t /*, vm*/) => {
return t.style({}, (vm) => { return t.style({}, (vm) => {
return ` return `
@ -150,6 +163,9 @@ class ArchiveRoomView extends TemplateView {
t.view(new DisabledComposerView(vm)), t.view(new DisabledComposerView(vm)),
]), ]),
]), ]),
// We can't just conditionally render the right-panel with `t.ifView(...)` based
// on `shouldShowRightPanel` because the right-panel being "hidden" only applies
// to the mobile break points and is always shown on desktop.
t.view(new RightPanelView(vm.rightPanelModel)), t.view(new RightPanelView(vm.rightPanelModel)),
t.mapView( t.mapView(
(vm) => vm.lightboxViewModel, (vm) => vm.lightboxViewModel,
@ -159,9 +175,11 @@ class ArchiveRoomView extends TemplateView {
] ]
); );
// Avoid an error when server-side rendering (SSR) because it doesn't have all the
// DOM API's available (and doesn't need it for this case)
if (typeof IntersectionObserver === 'function') { if (typeof IntersectionObserver === 'function') {
const scrollRoot = rootElement.querySelector('.Timeline_scroller'); const scrollRoot = rootElement.querySelector('.Timeline_scroller');
const observer = new IntersectionObserver( this._interSectionObserverForUpdatedTopPositionEventEntry = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
@ -188,7 +206,7 @@ class ArchiveRoomView extends TemplateView {
} }
); );
[...scrollRoot.querySelectorAll(`:scope > ul > [data-event-id]`)].forEach((el) => { [...scrollRoot.querySelectorAll(`:scope > ul > [data-event-id]`)].forEach((el) => {
observer.observe(el); this._interSectionObserverForUpdatedTopPositionEventEntry.observe(el);
}); });
} }

View File

@ -3,14 +3,9 @@
// Be mindful to do all date operations in UTC (the archive is all in UTC date/times) // Be mindful to do all date operations in UTC (the archive is all in UTC date/times)
const { TemplateView } = require('hydrogen-view-sdk'); const { TemplateView } = require('hydrogen-view-sdk');
const {
function sameDay(date1, date2) { areTimestampsFromSameUtcDay,
return ( } = require('matrix-public-archive-shared/lib/timestamp-utilities');
date1.getUTCFullYear() === date2.getUTCFullYear() &&
date1.getUTCMonth() === date2.getUTCMonth() &&
date1.getUTCDate() === date2.getUTCDate()
);
}
// Get the number of days in the given month where the `inputDate` lies. // Get the number of days in the given month where the `inputDate` lies.
// //
@ -162,7 +157,10 @@ class CalendarView extends TemplateView {
const isDayInFuture = dayNumberDate.getTime() - todayTs > 0; const isDayInFuture = dayNumberDate.getTime() - todayTs > 0;
// The current day displayed in the archive // The current day displayed in the archive
const isActive = sameDay(dayNumberDate, vm.activeDate); const isActive = areTimestampsFromSameUtcDay(
dayNumberDate.getTime(),
vm.activeDate.getTime()
);
// day number from 0 (monday) to 6 (sunday) // day number from 0 (monday) to 6 (sunday)
const dayNumber = dayNumberDate.getUTCDay(); const dayNumber = dayNumberDate.getUTCDay();

View File

@ -29,7 +29,6 @@ class JumpToNextActivitySummaryTileView extends TemplateView {
// while we wait for the rest of the JavaScript to load. // while we wait for the rest of the JavaScript to load.
'js-bottom-scroll-anchor': true, 'js-bottom-scroll-anchor': true,
}, },
'data-event-id': vm.eventId,
}, },
[ [
t.if( t.if(

View File

@ -7,7 +7,6 @@ class JumpToPreviousActivitySummaryTileView extends TemplateView {
return t.div( return t.div(
{ {
className: 'JumpToPreviousActivitySummaryTileView', className: 'JumpToPreviousActivitySummaryTileView',
'data-event-id': vm.eventId,
}, },
[ [
t.a( t.a(

View File

@ -78,8 +78,10 @@ class ModalView extends TemplateView {
t.mapSideEffect( t.mapSideEffect(
(vm) => vm.open, (vm) => vm.open,
(open) => { (open) => {
// The dialog has to be in the DOM before we can call `showModal`, etc. // The dialog has to be in the DOM before we can call `showModal`, etc. Assume
// Assume this view will be mounted in the parent DOM straight away. // this view will be mounted in the parent DOM straight away.
// #hydrogen-assume-view-mounted-right-away -
// https://github.com/vector-im/hydrogen-web/issues/1069
requestAnimationFrame(() => { requestAnimationFrame(() => {
// Prevent doing extra work if the modal is already closed or open and already // Prevent doing extra work if the modal is already closed or open and already
// matches our intention // matches our intention

View File

@ -3,11 +3,14 @@
const { TemplateView } = require('hydrogen-view-sdk'); const { TemplateView } = require('hydrogen-view-sdk');
const CalendarView = require('matrix-public-archive-shared/views/CalendarView'); const CalendarView = require('matrix-public-archive-shared/views/CalendarView');
const TimeSelectorView = require('matrix-public-archive-shared/views/TimeSelectorView');
const assert = require('matrix-public-archive-shared/lib/assert'); const assert = require('matrix-public-archive-shared/lib/assert');
class RightPanelContentView extends TemplateView { class RightPanelContentView extends TemplateView {
render(t, vm) { render(t, vm) {
assert(vm.shouldIndex !== undefined); assert(vm.shouldIndex !== undefined);
assert(vm.shouldShowTimeSelector !== undefined);
let maybeIndexedMessage = 'This room is not being indexed by search engines.'; let maybeIndexedMessage = 'This room is not being indexed by search engines.';
if (vm.shouldIndex) { if (vm.shouldIndex) {
maybeIndexedMessage = 'This room is being indexed by search engines.'; maybeIndexedMessage = 'This room is being indexed by search engines.';
@ -15,25 +18,25 @@ class RightPanelContentView extends TemplateView {
return t.div( return t.div(
{ {
className: { className: 'RightPanelContentView',
RightPanelContentView: true,
},
}, },
[ [
t.view(new CalendarView(vm.calendarViewModel)), t.div({ className: 'RightPanelContentView_mainContent' }, [
t.div( t.view(new CalendarView(vm.calendarViewModel)),
t.ifView(
(vm) => vm.shouldShowTimeSelector,
(vm) => new TimeSelectorView(vm.timeSelectorViewModel)
),
]),
t.footer(
{ {
className: { className: 'RightPanelContentView_footer',
RightPanelContentView_footer: true,
},
}, },
[ [
t.p(maybeIndexedMessage), t.p(maybeIndexedMessage),
t.div( t.div(
{ {
className: { className: 'RightPanelContentView_footerLinkList',
RightPanelContentView_footerLinkList: true,
},
}, },
[ [
t.a( t.a(

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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);
});
});
});
});

View File

@ -220,28 +220,43 @@ async function sendMessage({ client, roomId, content, timestamp }) {
} }
// Create a number of messages in the given room // Create a number of messages in the given room
async function createMessagesInRoom({ client, roomId, numMessages, prefix, timestamp }) { async function createMessagesInRoom({
client,
roomId,
numMessages,
prefix,
timestamp,
// The amount of time between each message
increment = 1,
}) {
let eventIds = []; let eventIds = [];
let eventMap = new Map();
for (let i = 0; i < numMessages; i++) { for (let i = 0; i < numMessages; i++) {
const originServerTs = timestamp + i * increment;
const content = {
msgtype: 'm.text',
body: `${prefix} - message${i}`,
};
const eventId = await sendMessage({ const eventId = await sendMessage({
client, client,
roomId, roomId,
content: { content,
msgtype: 'm.text', // Technically, we don't have to set the timestamp to be unique or sequential but
body: `${prefix} - message${i}`, // it still seems like a good idea to make the tests more clear.
}, timestamp: originServerTs,
// The timestamp doesn't matter if it's the same anymore (since
// https://github.com/matrix-org/synapse/pull/13658) but it still seems
// like a good idea to make the tests more clear.
timestamp: timestamp + i,
}); });
eventIds.push(eventId); eventIds.push(eventId);
eventMap.set(eventId, {
roomId,
originServerTs,
content,
});
} }
// Sanity check that we actually sent some messages // Sanity check that we actually sent some messages
assert.strictEqual(eventIds.length, numMessages); assert.strictEqual(eventIds.length, numMessages);
return eventIds; return { eventIds, eventMap };
} }
async function updateProfile({ client, displayName, avatarUrl }) { async function updateProfile({ client, displayName, avatarUrl }) {