515 lines
20 KiB
JavaScript
515 lines
20 KiB
JavaScript
|
'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;
|