diff --git a/package-lock.json b/package-lock.json
index 8dfd360b..e8fe3614 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,7 +21,7 @@
"@opentelemetry/semantic-conventions": "^1.3.1",
"dompurify": "^2.3.9",
"express": "^4.17.2",
- "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.16.0-scratch",
+ "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.18.0-scratch",
"json5": "^2.2.1",
"linkedom": "^0.14.1",
"matrix-public-archive-shared": "file:./shared/",
@@ -3637,9 +3637,9 @@
},
"node_modules/hydrogen-view-sdk": {
"name": "@mlm/hydrogen-view-sdk",
- "version": "0.16.0-scratch",
- "resolved": "https://registry.npmjs.org/@mlm/hydrogen-view-sdk/-/hydrogen-view-sdk-0.16.0-scratch.tgz",
- "integrity": "sha512-jyarsK0D3rjJ8V/zdmWCZ+TVlsqfWQiYYJZtj5apyMdAAkE/XD/wYT84hJsLwwuoCHw1gjbUs9LVBvOzRgsYGQ==",
+ "version": "0.18.0-scratch",
+ "resolved": "https://registry.npmjs.org/@mlm/hydrogen-view-sdk/-/hydrogen-view-sdk-0.18.0-scratch.tgz",
+ "integrity": "sha512-xX6mAfr120O5wHL4Scf3A2RI7GGlgo88jUiMjR98j+YN/ha+X7xEoEHLE5dPbX+oRxcPiwuzw8VX1ssucHCsfw==",
"dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"another-json": "^0.2.0",
@@ -8071,9 +8071,9 @@
}
},
"hydrogen-view-sdk": {
- "version": "npm:@mlm/hydrogen-view-sdk@0.16.0-scratch",
- "resolved": "https://registry.npmjs.org/@mlm/hydrogen-view-sdk/-/hydrogen-view-sdk-0.16.0-scratch.tgz",
- "integrity": "sha512-jyarsK0D3rjJ8V/zdmWCZ+TVlsqfWQiYYJZtj5apyMdAAkE/XD/wYT84hJsLwwuoCHw1gjbUs9LVBvOzRgsYGQ==",
+ "version": "npm:@mlm/hydrogen-view-sdk@0.18.0-scratch",
+ "resolved": "https://registry.npmjs.org/@mlm/hydrogen-view-sdk/-/hydrogen-view-sdk-0.18.0-scratch.tgz",
+ "integrity": "sha512-xX6mAfr120O5wHL4Scf3A2RI7GGlgo88jUiMjR98j+YN/ha+X7xEoEHLE5dPbX+oRxcPiwuzw8VX1ssucHCsfw==",
"requires": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"another-json": "^0.2.0",
diff --git a/package.json b/package.json
index 52d4cb1a..4fd24969 100644
--- a/package.json
+++ b/package.json
@@ -47,7 +47,7 @@
"@opentelemetry/semantic-conventions": "^1.3.1",
"dompurify": "^2.3.9",
"express": "^4.17.2",
- "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.16.0-scratch",
+ "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.18.0-scratch",
"json5": "^2.2.1",
"linkedom": "^0.14.1",
"matrix-public-archive-shared": "file:./shared/",
diff --git a/public/css/styles.css b/public/css/styles.css
index 9bed638f..9e0d366f 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -13,7 +13,7 @@ body {
}
/* Based on .SessionView from Hydrogen */
-.ArchiveView {
+.ArchiveRoomView {
/* this takes into account whether or not the url bar is hidden on mobile
(have tested Firefox Android and Safari on iOS),
see https://developers.google.com/web/updates/2016/12/url-bar-resizing */
@@ -29,10 +29,20 @@ body {
min-width: 0;
}
+.RoomHeader_actionButton {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ color: var(--icon-color--darker-20);
+}
+.RoomHeader_actionButton > * {
+ vertical-align: middle;
+ color: var(--icon-color--darker-20);
+}
+
/* No need to open the right-panel when it's always visible at desktop widths */
-.room-header-change-dates-button {
+.RoomHeader_changeDatesButton {
display: none;
- color: var(--icon-color--darker-20);
}
/* No need to close the right-panel when it's always visible at desktop widths */
.RightPanelView_buttons .close {
@@ -41,29 +51,29 @@ body {
@media screen and (max-width: 800px) {
/* Only the middle needs to be visible mobile by default */
- .ArchiveView {
+ .ArchiveRoomView {
grid-template:
'status' auto
'middle' 1fr /
1fr;
}
/* Which also means hiding the right-panel by default on mobile */
- .ArchiveView:not(.right-shown) .RightPanelView {
+ .ArchiveRoomView:not(.right-shown) .RightPanelView {
display: none;
}
/* When the user opens the right-panel, show it */
- .ArchiveView.right-shown {
+ .ArchiveRoomView.right-shown {
grid-template:
'status' auto
'right' 1fr /
1fr;
}
- .ArchiveView.right-shown .middle {
+ .ArchiveRoomView.right-shown .middle {
display: none;
}
/* And show the button to open the right-panel on mobile */
- .room-header-change-dates-button {
+ .RoomHeader_changeDatesButton {
display: block;
}
/* And show the button to close the right-panel on mobile */
@@ -72,6 +82,34 @@ body {
}
}
+.RightPanelContentView {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ height: 100%;
+}
+
+.RightPanelContentView_footer {
+ padding-left: 16px;
+ padding-right: 16px;
+ padding-bottom: 16px;
+ font-size: 12px;
+}
+
+.RightPanelContentView_footerLinkList > * + * {
+ margin-left: 1ch;
+}
+
+.RightPanelContentView_footerLink {
+ text-decoration: none;
+}
+
+.RightPanelContentView_footerLink:hover,
+.RightPanelContentView_footerLink:focus {
+ color: #0098d4;
+ text-decoration: underline;
+}
+
.CalendarView {
}
@@ -196,6 +234,111 @@ body {
opacity: 0.5;
}
+/* Some custom timeline, tiles stuff */
+
+.NotEnoughEventsFromDaySummaryTileView {
+ margin-top: 40px;
+ padding: 20px 12px;
+
+ background: rgba(46, 48, 51, 0.1);
+ border-top: 1px solid rgba(46, 48, 51, 0.38);
+}
+
+.NotEnoughEventsFromDaySummaryTileView_summaryMessage {
+ margin-top: 0;
+ font-size: 1.17em;
+}
+
+.NotEnoughEventsFromDaySummaryTileView_nextActivityLink {
+ text-decoration: none;
+ font-weight: bold;
+}
+
+.NotEnoughEventsFromDaySummaryTileView_nextActivityLink:hover,
+.NotEnoughEventsFromDaySummaryTileView_nextActivityLink:focus {
+ color: #0098d4;
+ text-decoration: underline;
+}
+
+.NotEnoughEventsFromDaySummaryTileView_nextActivityIcon {
+ margin-left: 1ch;
+ vertical-align: bottom;
+}
+
+/* Developer options modal */
+
+.DeveloperOptionsView {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.DeveloperOptionsView_backdrop {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+
+ background-color: rgba(46, 48, 51, 0.38);
+}
+
+.DeveloperOptionsView_modal {
+ z-index: 1;
+ overflow-y: auto;
+ width: 100%;
+ max-width: 500px;
+ max-height: 80%;
+ margin-left: 10px;
+ margin-right: 10px;
+ padding: 24px;
+ padding-bottom: 100px;
+
+ background-color: #ffffff;
+ border-radius: 8px;
+ box-shadow: 2px 15px 30px 0 rgb(0 0 0 / 48%);
+}
+
+.DeveloperOptionsView_modalHeader {
+ display: flex;
+ justify-content: space-between;
+}
+
+.DeveloperOptionsView_modalDismissButton {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding-left: 16px;
+ padding-right: 16px;
+
+ background: none;
+ border: none;
+
+ cursor: pointer;
+ color: var(--icon-color);
+}
+
+.DeveloperOptionsView_settingsFlag {
+ display: flex;
+ align-items: flex-start;
+}
+
+.DeveloperOptionsView_labelText {
+ line-height: 1.5em;
+}
+
+.DeveloperOptionsView_microcopy {
+ font-size: 0.85em;
+ line-height: 1.5em;
+ color: #737d8c;
+}
+
/* Error pages */
.heading-sub-detail {
diff --git a/server/hydrogen-render/render-hydrogen-vm-render-script-to-page-html.js b/server/hydrogen-render/render-hydrogen-vm-render-script-to-page-html.js
index 91a6b63d..80123075 100644
--- a/server/hydrogen-render/render-hydrogen-vm-render-script-to-page-html.js
+++ b/server/hydrogen-render/render-hydrogen-vm-render-script-to-page-html.js
@@ -29,7 +29,7 @@ async function renderHydrogenVmRenderScriptToPageHtml(
// We shouldn't let some pages be indexed by search engines
let maybeNoIndexHtml = '';
- if (pageOptions.noIndex) {
+ if (!pageOptions.shouldIndex) {
maybeNoIndexHtml = ``;
}
diff --git a/server/lib/matrix-utils/fetch-events-in-range.js b/server/lib/matrix-utils/fetch-events-from-timestamp-backwards.js
similarity index 56%
rename from server/lib/matrix-utils/fetch-events-in-range.js
rename to server/lib/matrix-utils/fetch-events-from-timestamp-backwards.js
index c474cb8c..99a8c980 100644
--- a/server/lib/matrix-utils/fetch-events-in-range.js
+++ b/server/lib/matrix-utils/fetch-events-from-timestamp-backwards.js
@@ -12,30 +12,55 @@ const matrixServerUrl = config.get('matrixServerUrl');
assert(matrixServerUrl);
// Find an event right ahead of where we are trying to look. Then paginate
-// /messages backwards. This makes sure that we can get events for the day
-// when the room started.
+// /messages backwards. This makes sure that we can get events for the day when
+// the room started. And it ensures that the `/messages` backfill kicks in
+// properly since it only works to fill in the gaps going backwards.
//
-// Consider this scenario: dayStart(fromTs) <---- msg1 <- msg2 <-- msg3 <---- dayEnd(toTs)
+// Consider this scenario: dayStart(fromTs) <- msg1 <- msg2 <- msg3 <- dayEnd(toTs)
// - ❌ If we start from dayStart and look backwards, we will find nothing.
-// - ❌ If we start from dayStart and look forwards, we will find msg1, but federated backfill won't be able to paginate forwards
-// - ✅ If we start from dayEnd and look backwards, we will find msg3
+// - ❌ If we start from dayStart and look forwards, we will find msg1, but
+// federated backfill won't be able to paginate forwards
+// - ✅ If we start from dayEnd and look backwards, we will find msg3 and
+// federation backfill can paginate backwards
// - ❌ If we start from dayEnd and look forwards, we will find nothing
//
// Returns events in reverse-chronological order.
-async function fetchEventsFromTimestampBackwards(accessToken, roomId, ts, limit) {
+async function fetchEventsFromTimestampBackwards({ accessToken, roomId, ts, limit }) {
assert(accessToken);
assert(roomId);
assert(ts);
- assert(limit);
+ // Synapse has a max `/messages` limit of 1000
+ assert(
+ limit <= 1000,
+ 'We can only get 1000 messages at a time from Synapse. If you need more messages, we will have to implement pagination'
+ );
- const { eventId: eventIdForTimestamp } = await timestampToEvent({
- accessToken,
- roomId,
- ts,
- direction: 'b',
- });
- assert(eventIdForTimestamp);
- //console.log('eventIdForTimestamp', eventIdForTimestamp);
+ let eventIdForTimestamp;
+ try {
+ const { eventId } = await timestampToEvent({
+ accessToken,
+ roomId,
+ ts,
+ direction: 'b',
+ });
+ eventIdForTimestamp = eventId;
+ } catch (err) {
+ const allowedErrorCodes = [
+ // Allow `404: Unable to find event xxx in direction x`
+ // so we can just display an empty placeholder with no events.
+ 404,
+ ];
+ if (!allowedErrorCodes.includes(err?.response?.status)) {
+ throw err;
+ }
+ }
+
+ if (!eventIdForTimestamp) {
+ return {
+ stateEventMap: {},
+ events: [],
+ };
+ }
// We only use this endpoint to get a pagination token we can use with
// `/messages`.
@@ -56,7 +81,6 @@ async function fetchEventsFromTimestampBackwards(accessToken, roomId, ts, limit)
const contextResData = await fetchEndpointAsJson(contextEndpoint, {
accessToken,
});
- //console.log('contextResData', contextResData);
// Add `filter={"lazy_load_members":true}` to only get member state events for
// the messages included in the response
@@ -68,7 +92,6 @@ async function fetchEventsFromTimestampBackwards(accessToken, roomId, ts, limit)
accessToken,
});
- //console.log('messageResData.state', messageResData.state);
const stateEventMap = {};
for (const stateEvent of messageResData.state || []) {
if (stateEvent.type === 'm.room.member') {
@@ -76,58 +99,12 @@ async function fetchEventsFromTimestampBackwards(accessToken, roomId, ts, limit)
}
}
- return {
- stateEventMap,
- events: messageResData.chunk,
- };
-}
-
-async function fetchEventsInRange(accessToken, roomId, startTs, endTs, limit) {
- assert(accessToken);
- assert(roomId);
- assert(startTs);
- assert(endTs);
- assert(limit);
-
- //console.log('fetchEventsInRange', startTs, endTs);
-
- // Fetch events from endTs and before
- const { events, stateEventMap } = await fetchEventsFromTimestampBackwards(
- accessToken,
- roomId,
- endTs,
- limit
- );
-
- //console.log('events', events.length);
-
- let eventsInRange = events;
- // `events` are in reverse-chronological order.
- // We only need to filter if the oldest message is before startTs
- if (events[events.length - 1].origin_server_ts < startTs) {
- eventsInRange = [];
-
- // Let's iterate until we see events before startTs
- for (let i = 0; i < events.length; i++) {
- const event = events[i];
-
- // Once we found an event before startTs, the rest are outside of our range
- if (event.origin_server_ts < startTs) {
- break;
- }
-
- eventsInRange.push(event);
- }
- }
-
- //console.log('eventsInRange', eventsInRange.length);
-
- const chronologicalEventsInRange = eventsInRange.reverse();
+ const chronologicalEvents = messageResData?.chunk?.reverse() || [];
return {
stateEventMap,
- events: chronologicalEventsInRange,
+ events: chronologicalEvents,
};
}
-module.exports = traceFunction(fetchEventsInRange);
+module.exports = traceFunction(fetchEventsFromTimestampBackwards);
diff --git a/server/routes/room-routes.js b/server/routes/room-routes.js
index 7bd1041e..53a88683 100644
--- a/server/routes/room-routes.js
+++ b/server/routes/room-routes.js
@@ -10,7 +10,7 @@ const StatusError = require('../lib/status-error');
const timeoutMiddleware = require('./timeout-middleware');
const fetchRoomData = require('../lib/matrix-utils/fetch-room-data');
-const fetchEventsInRange = require('../lib/matrix-utils/fetch-events-in-range');
+const fetchEventsFromTimestampBackwards = require('../lib/matrix-utils/fetch-events-from-timestamp-backwards');
const ensureRoomJoined = require('../lib/matrix-utils/ensure-room-joined');
const timestampToEvent = require('../lib/matrix-utils/timestamp-to-event');
const renderHydrogenVmRenderScriptToPageHtml = require('../hydrogen-render/render-hydrogen-vm-render-script-to-page-html');
@@ -23,8 +23,6 @@ const matrixServerUrl = config.get('matrixServerUrl');
assert(matrixServerUrl);
const matrixAccessToken = config.get('matrixAccessToken');
assert(matrixAccessToken);
-const archiveMessageLimit = config.get('archiveMessageLimit');
-assert(archiveMessageLimit);
const matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
@@ -124,6 +122,38 @@ router.get(
})
);
+router.get(
+ '/jump',
+ asyncHandler(async function (req, res) {
+ const roomIdOrAlias = req.params.roomIdOrAlias;
+ const isValidAlias = roomIdOrAlias.startsWith('!') || roomIdOrAlias.startsWith('#');
+ if (!isValidAlias) {
+ throw new StatusError(404, `Invalid alias given: ${roomIdOrAlias}`);
+ }
+
+ const ts = parseInt(req.query.ts, 10);
+ assert(!Number.isNaN(ts), '?ts query parameter must be a number');
+ const dir = req.query.dir;
+ assert(['f', 'b'].includes(dir), '?dir query parameter must be [f|b]');
+
+ // Find the closest day to today with messages
+ const { originServerTs } = await timestampToEvent({
+ accessToken: matrixAccessToken,
+ roomId: roomIdOrAlias,
+ ts: ts,
+ direction: dir,
+ });
+ if (!originServerTs) {
+ throw new StatusError(404, 'Unable to find day with history');
+ }
+
+ // Redirect to a day with messages
+ res.redirect(
+ matrixPublicArchiveURLCreator.archiveUrlForDate(roomIdOrAlias, new Date(originServerTs))
+ );
+ })
+);
+
// Based off of the Gitter archive routes,
// https://gitlab.com/gitterHQ/webapp/-/blob/14954e05c905e8c7cb675efebb89116c07cfaab5/server/handlers/app/archive.js#L190-297
router.get(
@@ -136,6 +166,14 @@ router.get(
throw new StatusError(404, `Invalid alias given: ${roomIdOrAlias}`);
}
+ const archiveMessageLimit = config.get('archiveMessageLimit');
+ assert(archiveMessageLimit);
+ // Synapse has a max `/messages` limit of 1000
+ assert(
+ archiveMessageLimit <= 999,
+ 'archiveMessageLimit needs to be in range [1, 999]. We can only get 1000 messages at a time from Synapse and we need a buffer of at least one to see if there are too many messages on a given day so you can only configure a max of 999. If you need more messages, we will have to implement pagination'
+ );
+
const { fromTimestamp, toTimestamp, hourRange, fromHour, toHour } =
parseArchiveRangeFromReq(req);
@@ -174,13 +212,21 @@ router.get(
// (we want to display the archive page faster)
const [roomData, { events, stateEventMap }] = await Promise.all([
fetchRoomData(matrixAccessToken, roomIdOrAlias),
- fetchEventsInRange(
- matrixAccessToken,
- roomIdOrAlias,
- fromTimestamp,
- toTimestamp,
- archiveMessageLimit
- ),
+ // We over-fetch messages outside of the range of the given day so that we
+ // can display messages from surrounding days (currently only from days
+ // before) so that the quiet rooms don't feel as desolate and broken.
+ fetchEventsFromTimestampBackwards({
+ accessToken: matrixAccessToken,
+ roomId: roomIdOrAlias,
+ ts: toTimestamp,
+ // We fetch one more than the `archiveMessageLimit` so that we can see
+ // there are too many messages from the given day. If we have over the
+ // `archiveMessageLimit` number of messages fetching from the given day,
+ // it's acceptable to have them be from surrounding days. But if all 500
+ // messages (for example) are from the same day, let's redirect to a
+ // smaller hour range to display.
+ limit: archiveMessageLimit + 1,
+ }),
]);
// Only `world_readable` or `shared` rooms that are `public` are viewable in the archive
@@ -195,8 +241,27 @@ router.get(
);
}
- if (events.length >= archiveMessageLimit) {
- throw new Error('TODO: Redirect user to smaller hour range');
+ // We only allow search engines to index `world_readable` rooms
+ const 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 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');
@@ -207,9 +272,11 @@ router.get(
path.resolve(__dirname, '../../shared/hydrogen-vm-render-script.js'),
{
fromTimestamp,
+ toTimestamp,
roomData,
events,
stateEventMap,
+ shouldIndex,
config: {
basePath: basePath,
matrixServerUrl: matrixServerUrl,
@@ -219,8 +286,7 @@ router.get(
title: `${roomData.name} - Matrix Public Archive`,
styles: [hydrogenStylesUrl, stylesUrl],
scripts: [jsBundleUrl],
- // We only allow search engines to index `world_readable` rooms
- noIndex: roomData?.historyVisibility !== `world_readable`,
+ shouldIndex,
}
);
diff --git a/server/start-dev.js b/server/start-dev.js
index 1bb12474..f743aaef 100644
--- a/server/start-dev.js
+++ b/server/start-dev.js
@@ -11,6 +11,7 @@ const buildClient = require('../build/build-client');
buildClient({
build: {
// Rebuild when we see changes
+ // https://rollupjs.org/guide/en/#watch-options
watch: true,
},
});
diff --git a/shared/hydrogen-vm-render-script.js b/shared/hydrogen-vm-render-script.js
index 656da758..fc92a2d3 100644
--- a/shared/hydrogen-vm-render-script.js
+++ b/shared/hydrogen-vm-render-script.js
@@ -18,29 +18,34 @@ const {
TilesCollection,
FragmentIdComparer,
- tileClassForEntry,
EventEntry,
encodeKey,
encodeEventIdKey,
Timeline,
+ ViewModel,
RoomViewModel,
} = require('hydrogen-view-sdk');
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
-
-const ArchiveView = require('matrix-public-archive-shared/views/ArchiveView');
+const ArchiveRoomView = require('matrix-public-archive-shared/views/ArchiveRoomView');
const ArchiveHistory = require('matrix-public-archive-shared/lib/archive-history');
-
-const ArchiveViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveViewModel');
+const ArchiveRoomViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveRoomViewModel');
+const {
+ customTileClassForEntry,
+} = require('matrix-public-archive-shared/lib/custom-tile-utilities');
const fromTimestamp = window.matrixPublicArchiveContext.fromTimestamp;
assert(fromTimestamp);
+const toTimestamp = window.matrixPublicArchiveContext.toTimestamp;
+assert(toTimestamp);
const roomData = window.matrixPublicArchiveContext.roomData;
assert(roomData);
const events = window.matrixPublicArchiveContext.events;
assert(events);
const stateEventMap = window.matrixPublicArchiveContext.stateEventMap;
assert(stateEventMap);
+const shouldIndex = window.matrixPublicArchiveContext.shouldIndex;
+assert(shouldIndex !== undefined);
const config = window.matrixPublicArchiveContext.config;
assert(config);
assert(config.matrixServerUrl);
@@ -48,6 +53,12 @@ assert(config.basePath);
const matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(config.basePath);
+let txnCount = 0;
+function getFakeEventId() {
+ txnCount++;
+ return `fake-event-id-${new Date().getTime()}--${txnCount}`;
+}
+
function addSupportClasses() {
const input = document.createElement('input');
input.type = 'month';
@@ -101,14 +112,22 @@ function supressBlankAnchorsReloadingThePage() {
handleEvent(e) {
// For any `` (anchor with a blank href), instead of reloading
// the page just remove the hash.
- if (
- e.type === 'click' &&
- e.target.tagName?.toLowerCase() === 'a' &&
- e.target?.getAttribute('href') === ''
- ) {
- this.clearHash();
- // Prevent the page navigation (reload)
- e.preventDefault();
+ if (e.type === 'click') {
+ // Traverse up the DOM and see whether the click is a child of an anchor element
+ let target = e.target;
+ while (
+ target &&
+ // We use `nodeName` here because it's compatible with any Element (HTML or SVG)
+ target.nodeName !== 'A'
+ ) {
+ target = target.parentNode;
+ }
+
+ if (target?.tagName?.toLowerCase() === 'a' && target?.getAttribute('href') === '') {
+ this.clearHash();
+ // Prevent the page navigation (reload)
+ e.preventDefault();
+ }
}
// Also cleanup whenever the hash is emptied out (like when pressing escape in the lightbox)
else if (e.type === 'hashchange' && document.location.hash === '') {
@@ -193,6 +212,39 @@ async function mountHydrogen() {
const workingStateEventMap = {
...stateEventMap,
};
+
+ // Add a summary item to the bottom of the timeline that explains if we found
+ // events on the day requested.
+ const hasEventsFromGivenDay = events[events.length - 1]?.origin_server_ts >= fromTimestamp;
+ let daySummaryKind;
+ if (events.length === 0) {
+ daySummaryKind = 'no-events-at-all';
+ } else if (hasEventsFromGivenDay) {
+ daySummaryKind = 'some-events-in-day';
+ } else if (!hasEventsFromGivenDay) {
+ daySummaryKind = 'no-events-in-day';
+ }
+ events.push({
+ event_id: getFakeEventId(),
+ type: 'org.matrix.archive.not_enough_events_from_day_summary',
+ room_id: roomData.id,
+ // Even though this isn't used for sort, just using the time where the event
+ // would logically be.
+ //
+ // -1 so we're not at 00:00:00 of the next day
+ origin_server_ts: toTimestamp - 1,
+ content: {
+ daySummaryKind: daySummaryKind,
+ // The timestamp from the URL that was originally visited
+ dayTimestamp: fromTimestamp,
+ // The end of the range to use as a jumping off point to the next activity
+ rangeEndTimestamp: toTimestamp,
+ // This is a bit cheating but I don't know how else to pass this kind of
+ // info to the Tile viewmodel
+ basePath: config.basePath,
+ },
+ });
+
const eventEntries = events.map((event) => {
if (event.type === 'm.room.member') {
workingStateEventMap[event.state_key] = event;
@@ -221,7 +273,7 @@ async function mountHydrogen() {
//console.log('timeline.entries', timeline.entries.length, timeline.entries);
const tiles = new TilesCollection(timeline.entries, {
- tileClassForEntry,
+ tileClassForEntry: customTileClassForEntry,
platform,
navigation,
urlCreator: urlRouter,
@@ -264,34 +316,15 @@ async function mountHydrogen() {
this.navigation.applyPath(path);
};
+ roomViewModel.roomDirectoryUrl = matrixPublicArchiveURLCreator.roomDirectoryUrl();
+
Object.defineProperty(roomViewModel, 'timelineViewModel', {
get() {
return timelineViewModel;
},
});
- const fromDate = new Date(fromTimestamp);
- const dateString = fromDate.toISOString().split('T')[0];
- Object.defineProperty(roomViewModel, 'composerViewModel', {
- get() {
- return {
- kind: 'disabled',
- description: [
- `You're viewing an archive of events from ${dateString}. Use a `,
- tag.a(
- {
- href: matrixPublicArchiveURLCreator.permalinkForRoomId(roomData.id),
- rel: 'noopener',
- target: '_blank',
- },
- ['Matrix client']
- ),
- ` to start chatting in this room.`,
- ],
- };
- },
- });
- const archiveViewModel = new ArchiveViewModel({
+ const archiveRoomViewModel = new ArchiveRoomViewModel({
// Hydrogen options
navigation: navigation,
urlCreator: urlRouter,
@@ -299,17 +332,69 @@ async function mountHydrogen() {
// Our options
roomViewModel,
room,
- fromDate,
+ fromDate: new Date(fromTimestamp),
eventEntriesByEventId,
+ shouldIndex,
basePath: config.basePath,
});
- const view = new ArchiveView(archiveViewModel);
+ // Create a custom disabled composer view that shows our archive message.
+ class DisabledArchiveComposerViewModel extends ViewModel {
+ constructor(options) {
+ super(options);
+
+ // Whenever the `archiveRoomViewModel.currentTopPositionEventEntry`
+ // changes, re-render the composer view with the updated date.
+ archiveRoomViewModel.on('change', (changedProps) => {
+ if (changedProps === 'currentTopPositionEventEntry') {
+ this.emitChange();
+ }
+ });
+ }
+
+ get kind() {
+ return 'disabled';
+ }
+
+ get description() {
+ return [
+ (/*vm*/) => {
+ const activeDate = new Date(
+ // If the date from our `archiveRoomViewModel` is available, use that
+ archiveRoomViewModel?.currentTopPositionEventEntry?.timestamp ||
+ // Otherwise, use our initial `fromTimestamp`
+ fromTimestamp
+ );
+ const dateString = activeDate.toISOString().split('T')[0];
+ return `You're viewing an archive of events from ${dateString}. Use a `;
+ },
+ tag.a(
+ {
+ href: matrixPublicArchiveURLCreator.permalinkForRoomId(roomData.id),
+ rel: 'noopener',
+ target: '_blank',
+ },
+ ['Matrix client']
+ ),
+ ` to start chatting in this room.`,
+ ];
+ }
+ }
+ const disabledArchiveComposerViewModel = new DisabledArchiveComposerViewModel({});
+ Object.defineProperty(roomViewModel, 'composerViewModel', {
+ get() {
+ return disabledArchiveComposerViewModel;
+ },
+ });
+
+ // ---------------------------------------------------------------------
+ // ---------------------------------------------------------------------
+ // Render what we actually care about
+ const view = new ArchiveRoomView(archiveRoomViewModel);
appElement.replaceChildren(view.mount());
addSupportClasses();
-
supressBlankAnchorsReloadingThePage();
console.timeEnd('Completed mounting Hydrogen');
diff --git a/shared/lib/assert.js b/shared/lib/assert.js
index d7cebf7d..b40bda5d 100644
--- a/shared/lib/assert.js
+++ b/shared/lib/assert.js
@@ -1,9 +1,24 @@
'use strict';
+class AssertionError extends Error {
+ constructor(...params) {
+ // Pass remaining arguments (including vendor specific ones) to parent constructor
+ super(...params);
+
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, AssertionError);
+ }
+
+ this.name = 'AssertionError';
+ }
+}
+
function assert(value, message) {
- console.assert(value, message);
if (!value) {
- throw new Error(`AssertionError: expected ${value} to be truthy`);
+ const error = new AssertionError(message || `expected ${value} to be truthy`);
+ //console.error(error);
+ throw error;
}
}
diff --git a/shared/lib/custom-tile-utilities.js b/shared/lib/custom-tile-utilities.js
new file mode 100644
index 00000000..06cadf1b
--- /dev/null
+++ b/shared/lib/custom-tile-utilities.js
@@ -0,0 +1,31 @@
+'use strict';
+
+// Extending the Hydrogen utilities to add our custom tiles
+
+const { tileClassForEntry, viewClassForTile } = require('hydrogen-view-sdk');
+
+const NotEnoughEventsFromDaySummaryTileViewModel = require('matrix-public-archive-shared/viewmodels/NotEnoughEventsFromDaySummaryTileViewModel');
+const NotEnoughEventsFromDaySummaryTileView = require('matrix-public-archive-shared/views/NotEnoughEventsFromDaySummaryTileView');
+
+function customTileClassForEntry(entry) {
+ switch (entry.eventType) {
+ case 'org.matrix.archive.not_enough_events_from_day_summary':
+ return NotEnoughEventsFromDaySummaryTileViewModel;
+ default:
+ return tileClassForEntry(entry);
+ }
+}
+
+function customViewClassForTile(vm) {
+ switch (vm.shape) {
+ case 'org.matrix.archive.not_enough_events_from_day_summary:shape':
+ return NotEnoughEventsFromDaySummaryTileView;
+ default:
+ return viewClassForTile(vm);
+ }
+}
+
+module.exports = {
+ customTileClassForEntry,
+ customViewClassForTile,
+};
diff --git a/shared/lib/url-creator.js b/shared/lib/url-creator.js
index e03550a7..9f73b6c4 100644
--- a/shared/lib/url-creator.js
+++ b/shared/lib/url-creator.js
@@ -17,6 +17,10 @@ class URLCreator {
this._basePath = basePath;
}
+ permalinkForRoomId(roomId) {
+ return `https://matrix.to/#/${roomId}`;
+ }
+
roomDirectoryUrl({ searchTerm, paginationToken } = {}) {
let qs = new URLSearchParams();
if (searchTerm) {
@@ -29,10 +33,6 @@ class URLCreator {
return `${this._basePath}${qsToUrlPiece(qs)}`;
}
- permalinkForRoomId(roomId) {
- return `https://matrix.to/#/${roomId}`;
- }
-
archiveUrlForRoom(roomId, { viaServers = [] } = {}) {
assert(roomId);
let qs = new URLSearchParams();
@@ -58,6 +58,18 @@ class URLCreator {
return `${urlJoin(this._basePath, `${roomId}/date/${urlDate}`)}${qsToUrlPiece(qs)}`;
}
+
+ archiveJumpUrlForRoom(roomId, { ts, dir }) {
+ assert(roomId);
+ assert(ts);
+ assert(dir);
+
+ let qs = new URLSearchParams();
+ qs.append('ts', ts);
+ qs.append('dir', dir);
+
+ return `${urlJoin(this._basePath, `${roomId}/jump`)}${qsToUrlPiece(qs)}`;
+ }
}
module.exports = URLCreator;
diff --git a/shared/viewmodels/ArchiveRoomViewModel.js b/shared/viewmodels/ArchiveRoomViewModel.js
new file mode 100644
index 00000000..fbe14062
--- /dev/null
+++ b/shared/viewmodels/ArchiveRoomViewModel.js
@@ -0,0 +1,170 @@
+'use strict';
+
+const { ViewModel, setupLightboxNavigation } = require('hydrogen-view-sdk');
+
+const assert = require('matrix-public-archive-shared/lib/assert');
+
+const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
+const CalendarViewModel = require('matrix-public-archive-shared/viewmodels/CalendarViewModel');
+const DeveloperOptionsViewModel = require('matrix-public-archive-shared/viewmodels/DeveloperOptionsViewModel');
+const RightPanelContentView = require('matrix-public-archive-shared/views/RightPanelContentView');
+
+class ArchiveRoomViewModel extends ViewModel {
+ constructor(options) {
+ super(options);
+ const { roomViewModel, room, fromDate, eventEntriesByEventId, shouldIndex, basePath } = options;
+ assert(roomViewModel);
+ assert(room);
+ assert(fromDate);
+ assert(shouldIndex !== undefined);
+ assert(eventEntriesByEventId);
+
+ this._room = room;
+ this._eventEntriesByEventId = eventEntriesByEventId;
+ this._currentTopPositionEventEntry = null;
+ this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
+
+ this._calendarViewModel = new CalendarViewModel({
+ // The day being shown in the archive
+ activeDate: fromDate,
+ // The month displayed in the calendar
+ calendarDate: fromDate,
+ room,
+ basePath,
+ });
+
+ this._shouldShowDeveloperOptions = false;
+ this._developerOptionsViewModel = new DeveloperOptionsViewModel(
+ this.childOptions({
+ /* any explicit options */
+ })
+ );
+ this._developerOptionsViewModel.loadValuesFromPersistence();
+
+ const navigation = this.navigation;
+ const urlCreator = this.urlCreator;
+
+ this.roomViewModel = roomViewModel;
+ // FIXME: Do we have to fake this?
+ this.rightPanelModel = {
+ navigation,
+ activeViewModel: {
+ // Our own custom options
+ type: 'custom',
+ customView: RightPanelContentView,
+ calendarViewModel: this._calendarViewModel,
+ shouldIndex,
+ get developerOptionsUrl() {
+ return urlCreator.urlForSegments([
+ navigation.segment('room', room.id),
+ navigation.segment('developer-options'),
+ ]);
+ },
+ },
+ closePanel() {
+ const path = this.navigation.path.until('room');
+ this.navigation.applyPath(path);
+ },
+ };
+
+ this.#setupNavigation();
+ }
+
+ #setupNavigation() {
+ // Make sure the right panel opens when the URL changes (only really matters
+ // on mobile)
+ const handleRightPanelNavigationChange = (rightpanelHashExists) => {
+ this._shouldShowRightPanel = rightpanelHashExists;
+ this.emitChange('shouldShowRightPanel');
+ };
+ const rightpanel = this.navigation.observe('right-panel');
+ this.track(rightpanel.subscribe(handleRightPanelNavigationChange));
+ // Also handle the case where the URL already includes right-panel stuff
+ // from page-load
+ const initialRightPanel = rightpanel.get();
+ handleRightPanelNavigationChange(initialRightPanel);
+
+ // Make sure the developer options open when the URL changes
+ const handleDeveloperOptionsNavigationChange = () => {
+ const shouldShowDeveloperOptions = !!this.navigation.path.get('developer-options')?.value;
+ this.setShouldShowDeveloperOptions(shouldShowDeveloperOptions);
+ };
+ const developerOptions = this.navigation.observe('developer-options');
+ this.track(developerOptions.subscribe(handleDeveloperOptionsNavigationChange));
+ // Also handle the case where the URL already includes `#/developer-options`
+ // stuff from page-load
+ const initialDeveloperOptions = developerOptions.get();
+ handleDeveloperOptionsNavigationChange(initialDeveloperOptions);
+
+ // Make sure the lightbox opens when the URL changes
+ setupLightboxNavigation(this, 'lightboxViewModel', (eventId) => {
+ return {
+ room: this._room,
+ eventEntry: this._eventEntriesByEventId[eventId],
+ };
+ });
+
+ // Also make sure when someone opens the lightbox, the day in the URL
+ // changes to when the timestamp of the associated event so the link opens
+ // with the event in the timeline and the lightbox opens again. We don't
+ // want to have a date mismatch because your scroll is on another day while
+ // viewing the lightbox.
+ const handleLightBoxNavigationChange = (eventId) => {
+ if (eventId) {
+ const eventEntry = this._eventEntriesByEventId[eventId];
+ if (eventEntry) {
+ this.setCurrentTopPositionEventEntry(eventEntry);
+ }
+ }
+ };
+ const lightbox = this.navigation.observe('lightbox');
+ this.track(lightbox.subscribe(handleLightBoxNavigationChange));
+ // Also handle the case where the URL already includes `/lightbox/$eventId` (like
+ // from page-load)
+ const initialLightBoxEventId = lightbox.get();
+ handleLightBoxNavigationChange(initialLightBoxEventId);
+ }
+
+ get shouldShowDeveloperOptions() {
+ return this._shouldShowDeveloperOptions;
+ }
+
+ setShouldShowDeveloperOptions(shouldShowDeveloperOptions) {
+ this._shouldShowDeveloperOptions = shouldShowDeveloperOptions;
+ this.emitChange('shouldShowDeveloperOptions');
+ }
+
+ get developerOptionsViewModel() {
+ return this._developerOptionsViewModel;
+ }
+
+ get eventEntriesByEventId() {
+ return this._eventEntriesByEventId;
+ }
+
+ get currentTopPositionEventEntry() {
+ return this._currentTopPositionEventEntry;
+ }
+
+ get shouldShowRightPanel() {
+ return this._shouldShowRightPanel;
+ }
+
+ setCurrentTopPositionEventEntry(currentTopPositionEventEntry) {
+ this._currentTopPositionEventEntry = currentTopPositionEventEntry;
+ this.emitChange('currentTopPositionEventEntry');
+
+ // Update the calendar
+ this._calendarViewModel.setActiveDate(currentTopPositionEventEntry.timestamp);
+
+ // Update the URL
+ this.history.replaceUrlSilently(
+ this._matrixPublicArchiveURLCreator.archiveUrlForDate(
+ this._room.id,
+ new Date(currentTopPositionEventEntry.timestamp)
+ ) + window.location.hash
+ );
+ }
+}
+
+module.exports = ArchiveRoomViewModel;
diff --git a/shared/viewmodels/ArchiveViewModel.js b/shared/viewmodels/ArchiveViewModel.js
deleted file mode 100644
index de157e81..00000000
--- a/shared/viewmodels/ArchiveViewModel.js
+++ /dev/null
@@ -1,71 +0,0 @@
-'use strict';
-
-const { ViewModel, setupLightboxNavigation } = require('hydrogen-view-sdk');
-
-const assert = require('matrix-public-archive-shared/lib/assert');
-
-const CalendarViewModel = require('matrix-public-archive-shared/viewmodels/CalendarViewModel');
-
-const RightPanelContentView = require('matrix-public-archive-shared/views/RightPanelContentView');
-
-class ArchiveViewModel extends ViewModel {
- constructor(options) {
- super(options);
- const { roomViewModel, room, fromDate, eventEntriesByEventId, basePath } = options;
- assert(roomViewModel);
- assert(room);
- assert(fromDate);
- assert(eventEntriesByEventId);
-
- this._room = room;
- this._eventEntriesByEventId = eventEntriesByEventId;
-
- this.roomViewModel = roomViewModel;
- // FIXME: Do we have to fake this?
- this.rightPanelModel = {
- navigation: this.navigation,
- activeViewModel: {
- type: 'custom',
- customView: RightPanelContentView,
- calendarViewModel: new CalendarViewModel({
- // The day being shown in the archive
- activeDate: fromDate,
- // The month displayed in the calendar
- calendarDate: fromDate,
- room,
- basePath,
- }),
- },
- closePanel() {
- const path = this.navigation.path.until('room');
- this.navigation.applyPath(path);
- },
- };
-
- this.#setupNavigation();
- this._updateRightPanel();
- }
-
- #setupNavigation() {
- const rightpanel = this.navigation.observe('right-panel');
- this.track(rightpanel.subscribe(() => this._updateRightPanel()));
-
- setupLightboxNavigation(this, 'lightboxViewModel', (eventId) => {
- return {
- room: this._room,
- eventEntry: this._eventEntriesByEventId[eventId],
- };
- });
- }
-
- get shouldShowRightPanel() {
- return this._shouldShowRightPanel;
- }
-
- _updateRightPanel() {
- this._shouldShowRightPanel = !!this.navigation.path.get('right-panel')?.value;
- this.emitChange('shouldShowRightPanel');
- }
-}
-
-module.exports = ArchiveViewModel;
diff --git a/shared/viewmodels/CalendarViewModel.js b/shared/viewmodels/CalendarViewModel.js
index 6cdb9509..320a9df5 100644
--- a/shared/viewmodels/CalendarViewModel.js
+++ b/shared/viewmodels/CalendarViewModel.js
@@ -14,7 +14,9 @@ class CalendarViewModel extends ViewModel {
assert(room);
assert(basePath);
+ // The day being shown in the archive
this._activeDate = activeDate;
+ // The month displayed in the calendar
this._calendarDate = calendarDate;
this._room = room;
this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
@@ -28,6 +30,14 @@ class CalendarViewModel extends ViewModel {
return this._calendarDate;
}
+ setActiveDate(newActiveDateInput) {
+ const newActiveDate = new Date(newActiveDateInput);
+ this._activeDate = newActiveDate;
+ this._calendarDate = newActiveDate;
+ this.emitChange('activeDate');
+ this.emitChange('calendarDate');
+ }
+
archiveUrlForDate(date) {
return this._matrixPublicArchiveURLCreator.archiveUrlForDate(this._room.id, date);
}
diff --git a/shared/viewmodels/DeveloperOptionsViewModel.js b/shared/viewmodels/DeveloperOptionsViewModel.js
new file mode 100644
index 00000000..f9e8bd3e
--- /dev/null
+++ b/shared/viewmodels/DeveloperOptionsViewModel.js
@@ -0,0 +1,42 @@
+'use strict';
+
+const { ViewModel } = require('hydrogen-view-sdk');
+
+class DeveloperOptionsViewModel extends ViewModel {
+ constructor(options) {
+ super(options);
+ const { debugActiveDateIntersectionObserver = false } = options;
+
+ this._debugActiveDateIntersectionObserver = debugActiveDateIntersectionObserver;
+ }
+
+ loadValuesFromPersistence() {
+ if (window.localStorage) {
+ this._debugActiveDateIntersectionObserver = JSON.parse(
+ window.localStorage.getItem('debugActiveDateIntersectionObserver')
+ );
+ this.emitChange('debugActiveDateIntersectionObserver');
+ } else {
+ console.warn(`Skipping read from localStorage since it's not available`);
+ }
+ }
+
+ get debugActiveDateIntersectionObserver() {
+ return this._debugActiveDateIntersectionObserver;
+ }
+
+ toggleDebugActiveDateIntersectionObserver(checkedValue) {
+ this._debugActiveDateIntersectionObserver = checkedValue;
+ window.localStorage.setItem(
+ 'debugActiveDateIntersectionObserver',
+ this._debugActiveDateIntersectionObserver
+ );
+ this.emitChange('debugActiveDateIntersectionObserver');
+ }
+
+ get closeUrl() {
+ return this.urlCreator.urlUntilSegment('room');
+ }
+}
+
+module.exports = DeveloperOptionsViewModel;
diff --git a/shared/viewmodels/NotEnoughEventsFromDaySummaryTileViewModel.js b/shared/viewmodels/NotEnoughEventsFromDaySummaryTileViewModel.js
new file mode 100644
index 00000000..e32ff80f
--- /dev/null
+++ b/shared/viewmodels/NotEnoughEventsFromDaySummaryTileViewModel.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const { SimpleTile } = require('hydrogen-view-sdk');
+
+const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator');
+const assert = require('../lib/assert');
+
+class NotEnoughEventsFromDaySummaryTileViewModel extends SimpleTile {
+ constructor(entry, options) {
+ super(entry, options);
+ this._entry = entry;
+
+ const basePath = this._entry?.content?.['basePath'];
+ assert(basePath);
+ this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath);
+ }
+
+ get shape() {
+ return 'org.matrix.archive.not_enough_events_from_day_summary:shape';
+ }
+
+ get daySummaryKind() {
+ return this._entry?.content?.['daySummaryKind'];
+ }
+
+ get dayTimestamp() {
+ return this._entry?.content?.['dayTimestamp'];
+ }
+
+ // The end of the range to use as a jumping off point to the next activity
+ get rangeEndTimestamp() {
+ return this._entry?.content?.['rangeEndTimestamp'];
+ }
+
+ get jumpToNextActivityUrl() {
+ return this._matrixPublicArchiveURLCreator.archiveJumpUrlForRoom(this._entry.roomId, {
+ ts: this.rangeEndTimestamp,
+ dir: 'f',
+ });
+ }
+}
+
+module.exports = NotEnoughEventsFromDaySummaryTileViewModel;
diff --git a/shared/views/ArchiveRoomView.js b/shared/views/ArchiveRoomView.js
new file mode 100644
index 00000000..e98d95ff
--- /dev/null
+++ b/shared/views/ArchiveRoomView.js
@@ -0,0 +1,168 @@
+'use strict';
+
+const {
+ TemplateView,
+ AvatarView,
+ RoomView,
+ RightPanelView,
+ LightboxView,
+} = require('hydrogen-view-sdk');
+
+const {
+ customViewClassForTile,
+} = require('matrix-public-archive-shared/lib/custom-tile-utilities');
+
+const DeveloperOptionsView = require('matrix-public-archive-shared/views/DeveloperOptionsView');
+
+class RoomHeaderView extends TemplateView {
+ render(t, vm) {
+ return t.div({ className: 'RoomHeader middle-header' }, [
+ t.a(
+ {
+ className: 'button-utility RoomHeader_actionButton',
+ href: vm.roomDirectoryUrl,
+ title: vm.i18n`Go back to the room directory`,
+ },
+ [
+ // Home icon from Element
+ t.svg(
+ {
+ xmlns: 'http://www.w3.org/2000/svg',
+ width: '16',
+ height: '16',
+ viewBox: '0 0 24 24',
+ fill: 'currentColor',
+ 'aria-hidden': 'true',
+ },
+ [
+ t.path({
+ d: 'M20.2804 7.90031L13.2804 2.06697C12.5387 1.4489 11.4613 1.4489 10.7196 2.06698L3.71963 7.90031C3.26365 8.28029 3 8.84319 3 9.43675V20.5C3 21.6046 3.89543 22.5 5 22.5H7C8.10457 22.5 9 21.6046 9 20.5V16C9 14.8954 9.89543 14 11 14H13C14.1046 14 15 14.8954 15 16V20.5C15 21.6046 15.8954 22.5 17 22.5H19C20.1046 22.5 21 21.6046 21 20.5V9.43675C21 8.84319 20.7364 8.28029 20.2804 7.90031Z',
+ }),
+ ]
+ ),
+ ]
+ ),
+ t.view(new AvatarView(vm, 32)),
+ t.div({ className: 'room-description' }, [t.h2((vm) => vm.name)]),
+ t.button(
+ {
+ className: 'button-utility RoomHeader_actionButton RoomHeader_changeDatesButton',
+ title: vm.i18n`Change dates`,
+ onClick: (/*event*/) => {
+ vm.openRightPanel();
+ },
+ },
+ [
+ // Calendar icon (via `calendar2-date` from Bootstrap)
+ t.svg(
+ {
+ xmlns: 'http://www.w3.org/2000/svg',
+ width: '16',
+ height: '16',
+ viewBox: '0 0 16 16',
+ fill: 'currentColor',
+ 'aria-hidden': 'true',
+ },
+ [
+ t.path({
+ d: 'M6.445 12.688V7.354h-.633A12.6 12.6 0 0 0 4.5 8.16v.695c.375-.257.969-.62 1.258-.777h.012v4.61h.675zm1.188-1.305c.047.64.594 1.406 1.703 1.406 1.258 0 2-1.066 2-2.871 0-1.934-.781-2.668-1.953-2.668-.926 0-1.797.672-1.797 1.809 0 1.16.824 1.77 1.676 1.77.746 0 1.23-.376 1.383-.79h.027c-.004 1.316-.461 2.164-1.305 2.164-.664 0-1.008-.45-1.05-.82h-.684zm2.953-2.317c0 .696-.559 1.18-1.184 1.18-.601 0-1.144-.383-1.144-1.2 0-.823.582-1.21 1.168-1.21.633 0 1.16.398 1.16 1.23z',
+ }),
+ t.path({
+ d: 'M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM2 2a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H2z',
+ }),
+ t.path({
+ d: 'M2.5 4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V4z',
+ }),
+ ]
+ ),
+ ]
+ ),
+ ]);
+ }
+}
+
+class ArchiveRoomView extends TemplateView {
+ render(t, vm) {
+ const rootElement = t.div(
+ {
+ className: {
+ ArchiveRoomView: true,
+ 'right-shown': (vm) => vm.shouldShowRightPanel,
+ },
+ },
+ [
+ // The red border and yellow background trail around the event that is
+ // driving the active date as you scroll around.
+ t.if(
+ (vm) => vm._developerOptionsViewModel?.debugActiveDateIntersectionObserver,
+ (t /*, vm*/) => {
+ return t.style({}, (vm) => {
+ return `
+ [data-event-id] {
+ transition: background-color 800ms;
+ }
+ [data-event-id="${vm.currentTopPositionEventEntry?.id}"] {
+ background-color: #ffff8a;
+ outline: 1px solid #f00;
+ outline-offset: -1px;
+ transition: background-color 0ms;
+ }
+ `;
+ });
+ }
+ ),
+ t.view(
+ new RoomView(vm.roomViewModel, customViewClassForTile, {
+ RoomHeaderView,
+ })
+ ),
+ t.view(new RightPanelView(vm.rightPanelModel)),
+ t.mapView(
+ (vm) => vm.lightboxViewModel,
+ (lightboxViewModel) => (lightboxViewModel ? new LightboxView(lightboxViewModel) : null)
+ ),
+ t.ifView(
+ (vm) => vm.shouldShowDeveloperOptions,
+ (vm) => new DeveloperOptionsView(vm.developerOptionsViewModel)
+ ),
+ ]
+ );
+
+ if (typeof IntersectionObserver === 'function') {
+ const scrollRoot = rootElement.querySelector('.Timeline_scroller');
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ const eventId = entry.target.getAttribute('data-event-id');
+ const eventEntry = vm.eventEntriesByEventId[eventId];
+ vm.setCurrentTopPositionEventEntry(eventEntry);
+ }
+ });
+ },
+ {
+ root: scrollRoot,
+ // Select the current active day from the top-edge of the scroll viewport.
+ //
+ // This is a trick that pushes the bottom margin up to the top of the
+ // root so there is just a 0px region at the top to detect
+ // intersections. This way we always recognize the element at the top.
+ // As mentioned in:
+ // - https://stackoverflow.com/a/54874286/796832
+ // - https://css-tricks.com/an-explanation-of-how-the-intersection-observer-watches/#aa-creating-a-position-sticky-event
+ //
+ // The format is the same as margin: top, left, bottom, right.
+ rootMargin: '0px 0px -100% 0px',
+ threshold: 0,
+ }
+ );
+ [...scrollRoot.querySelectorAll(`:scope > ul > [data-event-id]`)].forEach((el) => {
+ observer.observe(el);
+ });
+ }
+
+ return rootElement;
+ }
+}
+
+module.exports = ArchiveRoomView;
diff --git a/shared/views/ArchiveView.js b/shared/views/ArchiveView.js
deleted file mode 100644
index 46c75908..00000000
--- a/shared/views/ArchiveView.js
+++ /dev/null
@@ -1,79 +0,0 @@
-'use strict';
-
-const {
- TemplateView,
- AvatarView,
- RoomView,
- RightPanelView,
- LightboxView,
- viewClassForTile,
-} = require('hydrogen-view-sdk');
-
-class RoomHeaderView extends TemplateView {
- render(t, vm) {
- return t.div({ className: 'RoomHeader middle-header' }, [
- t.view(new AvatarView(vm, 32)),
- t.div({ className: 'room-description' }, [t.h2((vm) => vm.name)]),
- t.button(
- {
- className: 'button-utility room-header-change-dates-button',
- 'aria-label': vm.i18n`Change dates`,
- onClick: (/*evt*/) => {
- vm.openRightPanel();
- },
- },
- [
- // Calendar icon (via `calendar2-date` from Bootstrap)
- t.svg(
- {
- xmlns: 'http://www.w3.org/2000/svg',
- width: '16',
- height: '16',
- viewBox: '0 0 16 16',
- fill: 'currentColor',
- style: 'vertical-align: middle;',
- },
- [
- t.path({
- d: 'M6.445 12.688V7.354h-.633A12.6 12.6 0 0 0 4.5 8.16v.695c.375-.257.969-.62 1.258-.777h.012v4.61h.675zm1.188-1.305c.047.64.594 1.406 1.703 1.406 1.258 0 2-1.066 2-2.871 0-1.934-.781-2.668-1.953-2.668-.926 0-1.797.672-1.797 1.809 0 1.16.824 1.77 1.676 1.77.746 0 1.23-.376 1.383-.79h.027c-.004 1.316-.461 2.164-1.305 2.164-.664 0-1.008-.45-1.05-.82h-.684zm2.953-2.317c0 .696-.559 1.18-1.184 1.18-.601 0-1.144-.383-1.144-1.2 0-.823.582-1.21 1.168-1.21.633 0 1.16.398 1.16 1.23z',
- }),
- t.path({
- d: 'M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM2 2a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H2z',
- }),
- t.path({
- d: 'M2.5 4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V4z',
- }),
- ]
- ),
- ]
- ),
- ]);
- }
-}
-
-class ArchiveView extends TemplateView {
- render(t, vm) {
- return t.div(
- {
- className: {
- ArchiveView: true,
- 'right-shown': (vm) => vm.shouldShowRightPanel,
- },
- },
- [
- t.view(
- new RoomView(vm.roomViewModel, viewClassForTile, {
- RoomHeaderView,
- })
- ),
- t.view(new RightPanelView(vm.rightPanelModel)),
- t.mapView(
- (vm) => vm.lightboxViewModel,
- (lightboxViewModel) => (lightboxViewModel ? new LightboxView(lightboxViewModel) : null)
- ),
- ]
- );
- }
-}
-
-module.exports = ArchiveView;
diff --git a/shared/views/DeveloperOptionsView.js b/shared/views/DeveloperOptionsView.js
new file mode 100644
index 00000000..3a605bc9
--- /dev/null
+++ b/shared/views/DeveloperOptionsView.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const { TemplateView } = require('hydrogen-view-sdk');
+
+class DeveloperOptionsView extends TemplateView {
+ render(t, vm) {
+ return t.div(
+ {
+ className: {
+ DeveloperOptionsView: true,
+ },
+ href: vm.closeUrl,
+ },
+ [
+ t.a({
+ className: {
+ DeveloperOptionsView_backdrop: true,
+ },
+ href: vm.closeUrl,
+ }),
+ t.div(
+ {
+ className: {
+ DeveloperOptionsView_modal: true,
+ },
+ },
+ [
+ t.header({ className: 'DeveloperOptionsView_modalHeader' }, [
+ t.h3('Developer options'),
+ t.a(
+ {
+ className: 'DeveloperOptionsView_modalDismissButton',
+ href: vm.closeUrl,
+ },
+ [
+ t.svg(
+ {
+ width: '16',
+ height: '16',
+ viewBox: '0 0 8 8',
+ fill: 'none',
+ xmlns: 'http://www.w3.org/2000/svg',
+ },
+ [
+ t.path({
+ d: 'M1.33313 1.33313L6.66646 6.66646',
+ stroke: 'currentColor',
+ 'stroke-width': '1.5',
+ 'stroke-linecap': 'round',
+ }),
+ t.path({
+ d: 'M6.66699 1.33313L1.33366 6.66646',
+ stroke: 'currentColor',
+ 'stroke-width': '1.5',
+ 'stroke-linecap': 'round',
+ }),
+ ]
+ ),
+ ]
+ ),
+ ]),
+ t.section([
+ t.h4(['Toggles']),
+ t.div({ className: 'DeveloperOptionsView_settingsFlag' }, [
+ t.label({ for: 'debugActiveDateIntersectionObserver' }, [
+ t.div({ className: 'DeveloperOptionsView_labelText' }, [
+ 'Show active date borders (debug ',
+ t.code('IntersectionObserver'),
+ ')',
+ ]),
+ t.div(
+ { className: 'DeveloperOptionsView_microcopy' },
+ 'Show red border and yellow background trail around the event that is driving the active date as you scroll around.'
+ ),
+ ]),
+ t.input({
+ id: 'debugActiveDateIntersectionObserver',
+ type: 'checkbox',
+ checked: (vm) => vm.debugActiveDateIntersectionObserver,
+ onInput: (event) =>
+ vm.toggleDebugActiveDateIntersectionObserver(event.target.checked),
+ }),
+ ]),
+ ]),
+ t.section([t.h4('Backend timing'), 'todo: window.tracingSpansForRequest']),
+ ]
+ ),
+ ]
+ );
+ }
+}
+
+module.exports = DeveloperOptionsView;
diff --git a/shared/views/NotEnoughEventsFromDaySummaryTileView.js b/shared/views/NotEnoughEventsFromDaySummaryTileView.js
new file mode 100644
index 00000000..1aebb567
--- /dev/null
+++ b/shared/views/NotEnoughEventsFromDaySummaryTileView.js
@@ -0,0 +1,71 @@
+'use strict';
+
+const { TemplateView } = require('hydrogen-view-sdk');
+
+class NotEnoughEventsFromDaySummaryTileView extends TemplateView {
+ render(t, vm) {
+ const kind = vm.daySummaryKind;
+ let selectedDayString = 'the day you selected';
+ if (vm.dayTimestamp) {
+ selectedDayString = new Date(vm.dayTimestamp).toISOString().split('T')[0];
+ }
+
+ let daySummaryMessage;
+ if (kind === 'no-events-at-all') {
+ daySummaryMessage = `We couldn't find any activity at or before ${selectedDayString}.`;
+ } else if (kind === 'no-events-in-day') {
+ daySummaryMessage = `We couldn't find any activity for ${selectedDayString}. But there is activity before this day as shown above.`;
+ } else if (kind === 'some-events-in-day') {
+ daySummaryMessage = null;
+ } else {
+ throw new Error(`Unknown kind=${kind} passed to NotEnoughEventsFromDaySummaryTileView`);
+ }
+
+ return t.div(
+ {
+ className: 'NotEnoughEventsFromDaySummaryTileView',
+ 'data-event-id': vm.eventId,
+ },
+ [
+ t.if(
+ (vm) => !!daySummaryMessage,
+ (t, vm) =>
+ t.p(
+ {
+ className: 'NotEnoughEventsFromDaySummaryTileView_summaryMessage',
+ 'data-testid': `not-enough-events-summary-kind-${kind}`,
+ },
+ daySummaryMessage
+ )
+ ),
+ t.a(
+ {
+ className: 'NotEnoughEventsFromDaySummaryTileView_nextActivityLink',
+ href: vm.jumpToNextActivityUrl,
+ },
+ [
+ 'Jump to the next activity in the room',
+ t.svg(
+ {
+ className: 'NotEnoughEventsFromDaySummaryTileView_nextActivityIcon',
+ xmlns: 'http://www.w3.org/2000/svg',
+ width: '16',
+ height: '16',
+ viewBox: '0 0 16 16',
+ fill: 'currentColor',
+ 'aria-hidden': 'true',
+ },
+ [
+ t.path({
+ d: 'M0 4v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2a2 2 0 0 0-2 2Zm4.271 1.055a.5.5 0 0 1 .52.038L8 7.386V5.5a.5.5 0 0 1 .79-.407l3.5 2.5a.5.5 0 0 1 0 .814l-3.5 2.5A.5.5 0 0 1 8 10.5V8.614l-3.21 2.293A.5.5 0 0 1 4 10.5v-5a.5.5 0 0 1 .271-.445Z',
+ }),
+ ]
+ ),
+ ]
+ ),
+ ]
+ );
+ }
+}
+
+module.exports = NotEnoughEventsFromDaySummaryTileView;
diff --git a/shared/views/RightPanelContentView.js b/shared/views/RightPanelContentView.js
index f9ee3673..b7ceb575 100644
--- a/shared/views/RightPanelContentView.js
+++ b/shared/views/RightPanelContentView.js
@@ -3,16 +3,57 @@
const { TemplateView } = require('hydrogen-view-sdk');
const CalendarView = require('matrix-public-archive-shared/views/CalendarView');
+const assert = require('matrix-public-archive-shared/lib/assert');
class RightPanelContentView extends TemplateView {
render(t, vm) {
+ assert(vm.shouldIndex !== undefined);
+ let maybeIndexedMessage = 'This room is not being indexed by search engines.';
+ if (vm.shouldIndex) {
+ maybeIndexedMessage = 'This room is being indexed by search engines.';
+ }
+
return t.div(
{
className: {
- todo: true,
+ RightPanelContentView: true,
},
},
- [t.view(new CalendarView(vm.calendarViewModel))]
+ [
+ t.view(new CalendarView(vm.calendarViewModel)),
+ t.div(
+ {
+ className: {
+ RightPanelContentView_footer: true,
+ },
+ },
+ [
+ t.p(maybeIndexedMessage),
+ t.div(
+ {
+ className: {
+ RightPanelContentView_footerLinkList: true,
+ },
+ },
+ [
+ t.a(
+ { className: 'RightPanelContentView_footerLink', href: vm.developerOptionsUrl },
+ ['Developer options']
+ ),
+ t.span('·'),
+ t.a(
+ {
+ className: 'RightPanelContentView_footerLink',
+ href: 'https://matrix.org/',
+ target: '_blank',
+ },
+ ['Matrix.org']
+ ),
+ ]
+ ),
+ ]
+ ),
+ ]
);
}
}
diff --git a/test/client-utils.js b/test/client-utils.js
index 6d570ff6..fbef4692 100644
--- a/test/client-utils.js
+++ b/test/client-utils.js
@@ -209,11 +209,9 @@ async function createMessagesInRoom({ client, roomId, numMessages, prefix, times
msgtype: 'm.text',
body: `${prefix} - message${i}`,
},
- // We can't use the exact same timestamp for every message in the tests
- // otherwise it's a toss up which event will be returned as the closest
- // for `/timestamp_to_event`. As a note, we don't have to do this after
- // https://github.com/matrix-org/synapse/pull/13658 merges but it still
- // seems like a good idea to make the tests more clear.
+ // 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);
diff --git a/test/e2e-tests.js b/test/e2e-tests.js
index 7a65bf22..d9036d44 100644
--- a/test/e2e-tests.js
+++ b/test/e2e-tests.js
@@ -14,7 +14,6 @@ const { fetchEndpointAsText, fetchEndpointAsJson } = require('../server/lib/fetc
const config = require('../server/lib/config');
const {
- getTestClientForAs,
getTestClientForHs,
createTestRoom,
joinRoom,
@@ -107,20 +106,6 @@ describe('matrix-public-archive', () => {
});
describe('Archive', () => {
- before(async () => {
- // Make sure the application service archiver user itself has a profile
- // set otherwise we run into 404, `Profile was not found` errors when
- // joining a remote federated room from the archiver user, see
- // https://github.com/matrix-org/synapse/issues/4778
- //
- // FIXME: Remove after https://github.com/matrix-org/synapse/issues/4778 is resolved
- const asClient = await getTestClientForAs();
- await updateProfile({
- client: asClient,
- displayName: 'Archiver',
- });
- });
-
// Use a fixed date at the start of the UTC day so that the tests are
// consistent. Otherwise, the tests could fail when they start close to
// midnight and it rolls over to the next day.
@@ -138,6 +123,9 @@ describe('matrix-public-archive', () => {
// messages in (we space messages out by a minute so the timestamp visibly
// changes in the UI).
numMessagesSent = 0;
+
+ // Reset any custom modifications made for a particular test
+ config.reset();
});
// Sends a message and makes sure that a timestamp was provided
@@ -160,352 +148,481 @@ describe('matrix-public-archive', () => {
return sendEvent(options);
}
- it('redirects to last day with message history', async () => {
- const client = await getTestClientForHs(testMatrixServerUrl1);
- const roomId = await createTestRoom(client);
-
- // Send an event in the room so we have some day of history to redirect to
- const eventId = await sendMessageOnArchiveDate({
- client,
- roomId,
- content: {
- msgtype: 'm.text',
- body: 'some message in the history',
- },
+ describe('Archive room view', () => {
+ it('shows all events in a given day', async () => {
+ const client = await getTestClientForHs(testMatrixServerUrl1);
+ const roomId = await createTestRoom(client);
+
+ // Just render the page initially so that the archiver user is already
+ // joined to the page. We don't want their join event masking the one-off
+ // problem where we're missing the latest message in the room. We just use the date now
+ // because it will find whatever events backwards no matter when they were sent.
+ await fetchEndpointAsText(
+ matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, new Date())
+ );
+
+ const messageTextList = [
+ `Amontons' First Law: The force of friction is directly proportional to the applied load.`,
+ `Amontons' Second Law: The force of friction is independent of the apparent area of contact.`,
+ // We're aiming for this to be the last message in the room
+ `Coulomb's Law of Friction: Kinetic friction is independent of the sliding velocity.`,
+ ];
+
+ // TODO: Can we use `createMessagesInRoom` here instead?
+ const eventIds = [];
+ for (const messageText of messageTextList) {
+ const eventId = await sendMessageOnArchiveDate({
+ client,
+ roomId,
+ content: {
+ msgtype: 'm.text',
+ body: messageText,
+ },
+ });
+ eventIds.push(eventId);
+ }
+
+ // Sanity check that we actually sent some messages
+ assert.strictEqual(eventIds.length, 3);
+
+ archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
+ const archivePageHtml = await fetchEndpointAsText(archiveUrl);
+
+ const dom = parseHTML(archivePageHtml);
+
+ // Make sure the messages are visible
+ for (let i = 0; i < eventIds.length; i++) {
+ const eventId = eventIds[i];
+ const eventText = messageTextList[i];
+ assert.match(
+ dom.document.querySelector(`[data-event-id="${eventId}"]`).outerHTML,
+ new RegExp(`.*${escapeStringRegexp(eventText)}.*`)
+ );
+ }
});
- const expectedEventIdsOnDay = [eventId];
-
- // Visit `/:roomIdOrAlias` and expect to be redirected to the last day with events
- archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForRoom(roomId);
- const archivePageHtml = await fetchEndpointAsText(archiveUrl);
-
- const dom = parseHTML(archivePageHtml);
-
- // Make sure the messages from the day we expect to get redirected to are visible
- assert.deepStrictEqual(
- expectedEventIdsOnDay.map((eventId) => {
- return dom.document
- .querySelector(`[data-event-id="${eventId}"]`)
- ?.getAttribute('data-event-id');
- }),
- expectedEventIdsOnDay
- );
- });
- it('shows all events in a given day', async () => {
- const client = await getTestClientForHs(testMatrixServerUrl1);
- const roomId = await createTestRoom(client);
+ // eslint-disable-next-line max-statements
+ it('can render diverse messages', async () => {
+ const client = await getTestClientForHs(testMatrixServerUrl1);
+ const roomId = await createTestRoom(client);
- // Just render the page initially so that the archiver user is already
- // joined to the page. We don't want their join event masking the one-off
- // problem where we're missing the latest message in the room. We just use the date now
- // because it will find whatever events backwards no matter when they were sent.
- await fetchEndpointAsText(
- matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, new Date())
- );
+ const userAvatarBuffer = Buffer.from(
+ // Purple PNG pixel
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mPsD9j0HwAFmQKScbjOAwAAAABJRU5ErkJggg==',
+ 'base64'
+ );
+ const userAvatarMxcUri = await uploadContent({
+ client,
+ roomId,
+ data: userAvatarBuffer,
+ fileName: 'client user avatar',
+ });
+ const displayName = `${client.userId}-some-display-name`;
+ await updateProfile({
+ client,
+ displayName,
+ avatarUrl: userAvatarMxcUri,
+ });
- const messageTextList = [
- `Amontons' First Law: The force of friction is directly proportional to the applied load.`,
- `Amontons' Second Law: The force of friction is independent of the apparent area of contact.`,
- // We're aiming for this to be the last message in the room
- `Coulomb's Law of Friction: Kinetic friction is independent of the sliding velocity.`,
- ];
+ // TODO: Set avatar of room
- // TODO: Can we use `createMessagesInRoom` here instead?
- const eventIds = [];
- for (const messageText of messageTextList) {
- const eventId = await sendMessageOnArchiveDate({
+ // Test image
+ // via https://en.wikipedia.org/wiki/Friction#/media/File:Friction_between_surfaces.jpg (CaoHao)
+ const imageBuffer = await readFile(
+ path.resolve(__dirname, './fixtures/friction_between_surfaces.jpg')
+ );
+ const imageFileName = 'friction_between_surfaces.jpg';
+ const mxcUri = await uploadContent({
+ client,
+ roomId,
+ data: imageBuffer,
+ fileName: imageFileName,
+ });
+ const imageEventId = await sendMessageOnArchiveDate({
+ client,
+ roomId,
+ content: {
+ body: imageFileName,
+ info: {
+ size: 17471,
+ mimetype: 'image/jpeg',
+ w: 640,
+ h: 312,
+ 'xyz.amorgan.blurhash': 'LkR3G|IU?w%NbxbIemae_NxuD$M{',
+ },
+ msgtype: 'm.image',
+ url: mxcUri,
+ },
+ });
+
+ // A normal text message
+ const normalMessageText1 =
+ '^ Figure 1: Simulated blocks with fractal rough surfaces, exhibiting static frictional interactions';
+ const normalMessageEventId1 = await sendMessageOnArchiveDate({
client,
roomId,
content: {
msgtype: 'm.text',
- body: messageText,
+ body: normalMessageText1,
+ },
+ });
+
+ // Another normal text message
+ const normalMessageText2 =
+ 'The topography of the Moon has been measured with laser altimetry and stereo image analysis.';
+ const normalMessageEventId2 = await sendMessageOnArchiveDate({
+ client,
+ roomId,
+ content: {
+ msgtype: 'm.text',
+ body: normalMessageText2,
},
});
- eventIds.push(eventId);
- }
- // Sanity check that we actually sent some messages
- assert.strictEqual(eventIds.length, 3);
+ // Test replies
+ const replyMessageText = `The concentration of maria on the near side likely reflects the substantially thicker crust of the highlands of the Far Side, which may have formed in a slow-velocity impact of a second moon of Earth a few tens of millions of years after the Moon's formation.`;
+ const replyMessageEventId = await sendMessageOnArchiveDate({
+ client,
+ roomId,
+ content: {
+ 'org.matrix.msc1767.message': [
+ {
+ body: '> <@ericgittertester:my.synapse.server> ${normalMessageText2}',
+ mimetype: 'text/plain',
+ },
+ {
+ body: `In reply to @ericgittertester:my.synapse.server
${normalMessageText2}
${replyMessageText}`,
+ mimetype: 'text/html',
+ },
+ ],
+ body: `> <@ericgittertester:my.synapse.server> ${normalMessageText2}\n\n${replyMessageText}`,
+ msgtype: 'm.text',
+ format: 'org.matrix.custom.html',
+ formatted_body: `In reply to @ericgittertester:my.synapse.server
${normalMessageText2}
${replyMessageText}`,
+ 'm.relates_to': {
+ 'm.in_reply_to': {
+ event_id: normalMessageEventId2,
+ },
+ },
+ },
+ });
- archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
- const archivePageHtml = await fetchEndpointAsText(archiveUrl);
+ // Test to make sure we can render the page when the reply is missing the
+ // event it's replying to (the relation).
+ const replyMissingRelationMessageText = `While the giant-impact theory explains many lines of evidence, some questions are still unresolved, most of which involve the Moon's composition.`;
+ const missingRelationEventId = '$someMissingEvent';
+ const replyMissingRelationMessageEventId = await sendMessageOnArchiveDate({
+ client,
+ roomId,
+ content: {
+ 'org.matrix.msc1767.message': [
+ {
+ body: '> <@ericgittertester:my.synapse.server> some missing message',
+ mimetype: 'text/plain',
+ },
+ {
+ body: `In reply to @ericgittertester:my.synapse.server
some missing message
${replyMissingRelationMessageText}`,
+ mimetype: 'text/html',
+ },
+ ],
+ body: `> <@ericgittertester:my.synapse.server> some missing message\n\n${replyMissingRelationMessageText}`,
+ msgtype: 'm.text',
+ format: 'org.matrix.custom.html',
+ formatted_body: `In reply to @ericgittertester:my.synapse.server
some missing message
${replyMissingRelationMessageText}`,
+ 'm.relates_to': {
+ 'm.in_reply_to': {
+ event_id: missingRelationEventId,
+ },
+ },
+ },
+ });
+
+ // Test reactions
+ const reactionText = '😅';
+ await sendEventOnArchiveDate({
+ client,
+ roomId,
+ eventType: 'm.reaction',
+ content: {
+ 'm.relates_to': {
+ rel_type: 'm.annotation',
+ event_id: replyMessageEventId,
+ key: reactionText,
+ },
+ },
+ });
- const dom = parseHTML(archivePageHtml);
+ archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
- // Make sure the messages are visible
- for (let i = 0; i < eventIds.length; i++) {
- const eventId = eventIds[i];
- const eventText = messageTextList[i];
+ const archivePageHtml = await fetchEndpointAsText(archiveUrl);
+
+ const dom = parseHTML(archivePageHtml);
+
+ // Make sure the user display name is visible on the message
assert.match(
- dom.document.querySelector(`[data-event-id="${eventId}"]`).outerHTML,
- new RegExp(`.*${escapeStringRegexp(eventText)}.*`)
+ dom.document.querySelector(`[data-event-id="${imageEventId}"]`).outerHTML,
+ new RegExp(`.*${escapeStringRegexp(displayName)}.*`)
);
- }
- });
- // eslint-disable-next-line max-statements
- it('can render diverse messages', async () => {
- const client = await getTestClientForHs(testMatrixServerUrl1);
- const roomId = await createTestRoom(client);
+ // Make sure the user avatar is visible on the message
+ const avatarImageElement = dom.document.querySelector(
+ `[data-event-id="${imageEventId}"] [data-testid="avatar"] img`
+ );
+ assert(avatarImageElement);
+ assert.match(avatarImageElement.getAttribute('src'), new RegExp(`^http://.*`));
- const userAvatarBuffer = Buffer.from(
- // Purple PNG pixel
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mPsD9j0HwAFmQKScbjOAwAAAABJRU5ErkJggg==',
- 'base64'
- );
- const userAvatarMxcUri = await uploadContent({
- client,
- roomId,
- data: userAvatarBuffer,
- fileName: 'client user avatar',
- });
- const displayName = `${client.userId}-some-display-name`;
- await updateProfile({
- client,
- displayName,
- avatarUrl: userAvatarMxcUri,
- });
+ // Make sure the image message is visible
+ const imageElement = dom.document.querySelector(
+ `[data-event-id="${imageEventId}"] [data-testid="media"] img`
+ );
+ assert(imageElement);
+ assert.match(imageElement.getAttribute('src'), new RegExp(`^http://.*`));
+ assert.strictEqual(imageElement.getAttribute('alt'), imageFileName);
- // TODO: Set avatar of room
+ // Make sure the normal message is visible
+ assert.match(
+ dom.document.querySelector(`[data-event-id="${normalMessageEventId1}"]`).outerHTML,
+ new RegExp(`.*${escapeStringRegexp(normalMessageText1)}.*`)
+ );
- // Test image
- // via https://en.wikipedia.org/wiki/Friction#/media/File:Friction_between_surfaces.jpg (CaoHao)
- const imageBuffer = await readFile(
- path.resolve(__dirname, './fixtures/friction_between_surfaces.jpg')
- );
- const imageFileName = 'friction_between_surfaces.jpg';
- const mxcUri = await uploadContent({
- client,
- roomId,
- data: imageBuffer,
- fileName: imageFileName,
- });
- const imageEventId = await sendMessageOnArchiveDate({
- client,
- roomId,
- content: {
- body: imageFileName,
- info: {
- size: 17471,
- mimetype: 'image/jpeg',
- w: 640,
- h: 312,
- 'xyz.amorgan.blurhash': 'LkR3G|IU?w%NbxbIemae_NxuD$M{',
- },
- msgtype: 'm.image',
- url: mxcUri,
- },
- });
+ // Make sure the other normal message is visible
+ assert.match(
+ dom.document.querySelector(`[data-event-id="${normalMessageEventId2}"]`).outerHTML,
+ new RegExp(`.*${escapeStringRegexp(normalMessageText2)}.*`)
+ );
- // A normal text message
- const normalMessageText1 =
- '^ Figure 1: Simulated blocks with fractal rough surfaces, exhibiting static frictional interactions';
- const normalMessageEventId1 = await sendMessageOnArchiveDate({
- client,
- roomId,
- content: {
- msgtype: 'm.text',
- body: normalMessageText1,
- },
- });
+ const replyMessageElement = dom.document.querySelector(
+ `[data-event-id="${replyMessageEventId}"]`
+ );
+ // Make sure the reply text is there
+ assert.match(
+ replyMessageElement.outerHTML,
+ new RegExp(`.*${escapeStringRegexp(replyMessageText)}.*`)
+ );
+ // Make sure it also includes the message we're replying to
+ assert.match(
+ replyMessageElement.outerHTML,
+ new RegExp(`.*${escapeStringRegexp(normalMessageEventId2)}.*`)
+ );
+
+ const replyMissingRelationMessageElement = dom.document.querySelector(
+ `[data-event-id="${replyMissingRelationMessageEventId}"]`
+ );
+ // Make sure the reply text is there.
+ // We don't care about the message we're replying to because it's missing on purpose.
+ assert.match(
+ replyMissingRelationMessageElement.outerHTML,
+ new RegExp(`.*${escapeStringRegexp(replyMissingRelationMessageText)}.*`)
+ );
- // Another normal text message
- const normalMessageText2 =
- 'The topography of the Moon has been measured with laser altimetry and stereo image analysis.';
- const normalMessageEventId2 = await sendMessageOnArchiveDate({
- client,
- roomId,
- content: {
- msgtype: 'm.text',
- body: normalMessageText2,
- },
+ // Make sure the reaction also exists
+ assert.match(
+ replyMessageElement.outerHTML,
+ new RegExp(`.*${escapeStringRegexp(reactionText)}.*`)
+ );
});
- // Test replies
- const replyMessageText = `The concentration of maria on the near side likely reflects the substantially thicker crust of the highlands of the Far Side, which may have formed in a slow-velocity impact of a second moon of Earth a few tens of millions of years after the Moon's formation.`;
- const replyMessageEventId = await sendMessageOnArchiveDate({
- client,
- roomId,
- content: {
- 'org.matrix.msc1767.message': [
- {
- body: '> <@ericgittertester:my.synapse.server> ${normalMessageText2}',
- mimetype: 'text/plain',
- },
- {
- body: `In reply to @ericgittertester:my.synapse.server
${normalMessageText2}
${replyMessageText}`,
- mimetype: 'text/html',
- },
- ],
- body: `> <@ericgittertester:my.synapse.server> ${normalMessageText2}\n\n${replyMessageText}`,
- msgtype: 'm.text',
- format: 'org.matrix.custom.html',
- formatted_body: `In reply to @ericgittertester:my.synapse.server
${normalMessageText2}
${replyMessageText}`,
- 'm.relates_to': {
- 'm.in_reply_to': {
- event_id: normalMessageEventId2,
- },
- },
- },
+ it(`can render day back in time from room on remote homeserver we haven't backfilled from`, async () => {
+ const hs2Client = await getTestClientForHs(testMatrixServerUrl2);
+
+ // Create a room on hs2
+ const hs2RoomId = await createTestRoom(hs2Client);
+ const room2EventIds = await createMessagesInRoom({
+ client: hs2Client,
+ roomId: hs2RoomId,
+ numMessages: 3,
+ prefix: HOMESERVER_URL_TO_PRETTY_NAME_MAP[hs2Client.homeserverUrl],
+ timestamp: archiveDate.getTime(),
+ });
+
+ archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(hs2RoomId, archiveDate, {
+ // Since hs1 doesn't know about this room on hs2 yet, we have to provide
+ // a via server to ask through.
+ viaServers: ['hs2'],
+ });
+
+ const archivePageHtml = await fetchEndpointAsText(archiveUrl);
+
+ const dom = parseHTML(archivePageHtml);
+
+ // Make sure the messages are visible
+ assert.deepStrictEqual(
+ room2EventIds.map((eventId) => {
+ return dom.document
+ .querySelector(`[data-event-id="${eventId}"]`)
+ ?.getAttribute('data-event-id');
+ }),
+ room2EventIds
+ );
});
- // Test to make sure we can render the page when the reply is missing the
- // event it's replying to (the relation).
- const replyMissingRelationMessageText = `While the giant-impact theory explains many lines of evidence, some questions are still unresolved, most of which involve the Moon's composition.`;
- const missingRelationEventId = '$someMissingEvent';
- const replyMissingRelationMessageEventId = await sendMessageOnArchiveDate({
- client,
- roomId,
- content: {
- 'org.matrix.msc1767.message': [
- {
- body: '> <@ericgittertester:my.synapse.server> some missing message',
- mimetype: 'text/plain',
- },
- {
- body: `In reply to @ericgittertester:my.synapse.server
some missing message
${replyMissingRelationMessageText}`,
- mimetype: 'text/html',
- },
- ],
- body: `> <@ericgittertester:my.synapse.server> some missing message\n\n${replyMissingRelationMessageText}`,
- msgtype: 'm.text',
- format: 'org.matrix.custom.html',
- formatted_body: `In reply to @ericgittertester:my.synapse.server
some missing message
${replyMissingRelationMessageText}`,
- 'm.relates_to': {
- 'm.in_reply_to': {
- event_id: missingRelationEventId,
- },
+ it('redirects to last day with message history', async () => {
+ const client = await getTestClientForHs(testMatrixServerUrl1);
+ const roomId = await createTestRoom(client);
+
+ // Send an event in the room so we have some day of history to redirect to
+ const eventId = await sendMessageOnArchiveDate({
+ client,
+ roomId,
+ content: {
+ msgtype: 'm.text',
+ body: 'some message in the history',
},
- },
+ });
+ const expectedEventIdsOnDay = [eventId];
+
+ // Visit `/:roomIdOrAlias` and expect to be redirected to the last day with events
+ archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForRoom(roomId);
+ const archivePageHtml = await fetchEndpointAsText(archiveUrl);
+
+ const dom = parseHTML(archivePageHtml);
+
+ // Make sure the messages from the day we expect to get redirected to are visible
+ assert.deepStrictEqual(
+ expectedEventIdsOnDay.map((eventId) => {
+ return dom.document
+ .querySelector(`[data-event-id="${eventId}"]`)
+ ?.getAttribute('data-event-id');
+ }),
+ expectedEventIdsOnDay
+ );
});
- // Test reactions
- const reactionText = '😅';
- await sendEventOnArchiveDate({
- client,
- roomId,
- eventType: 'm.reaction',
- content: {
- 'm.relates_to': {
- rel_type: 'm.annotation',
- event_id: replyMessageEventId,
- key: reactionText,
+ it('still shows surrounding messages on a day with no messages', async () => {
+ const client = await getTestClientForHs(testMatrixServerUrl1);
+ const roomId = await createTestRoom(client);
+
+ // Send an event in the room so there is some history to display in the
+ // surroundings and everything doesn't just 404 because we can't find
+ // any event.
+ const eventId = await sendMessageOnArchiveDate({
+ client,
+ roomId,
+ content: {
+ msgtype: 'm.text',
+ body: 'some message in the history',
},
- },
- });
+ });
+ const expectedEventIdsToBeDisplayed = [eventId];
- archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
+ // Visit the archive on the day ahead of where there are messages
+ const visitArchiveDate = new Date(Date.UTC(2022, 0, 5));
+ assert(
+ visitArchiveDate > archiveDate,
+ 'The date we visit the archive (`visitArchiveDate`) should be after where the messages were sent (`archiveDate`)'
+ );
+ archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, visitArchiveDate);
+ const archivePageHtml = await fetchEndpointAsText(archiveUrl);
- const archivePageHtml = await fetchEndpointAsText(archiveUrl);
+ const dom = parseHTML(archivePageHtml);
- const dom = parseHTML(archivePageHtml);
+ // Make sure the summary exists on the page
+ assert(
+ dom.document.querySelector(
+ `[data-testid="not-enough-events-summary-kind-no-events-in-day"]`
+ )
+ );
- // Make sure the user display name is visible on the message
- assert.match(
- dom.document.querySelector(`[data-event-id="${imageEventId}"]`).outerHTML,
- new RegExp(`.*${escapeStringRegexp(displayName)}.*`)
- );
+ // Make sure the messages there are some messages from the surrounding days
+ assert.deepStrictEqual(
+ expectedEventIdsToBeDisplayed.map((eventId) => {
+ return dom.document
+ .querySelector(`[data-event-id="${eventId}"]`)
+ ?.getAttribute('data-event-id');
+ }),
+ expectedEventIdsToBeDisplayed
+ );
+ });
- // Make sure the user avatar is visible on the message
- const avatarImageElement = dom.document.querySelector(
- `[data-event-id="${imageEventId}"] [data-testid="avatar"] img`
- );
- assert(avatarImageElement);
- assert.match(avatarImageElement.getAttribute('src'), new RegExp(`^http://.*`));
+ it('shows no events summary when no messages at or before the given day', async () => {
+ const client = await getTestClientForHs(testMatrixServerUrl1);
+ const roomId = await createTestRoom(client);
- // Make sure the image message is visible
- const imageElement = dom.document.querySelector(
- `[data-event-id="${imageEventId}"] [data-testid="media"] img`
- );
- assert(imageElement);
- assert.match(imageElement.getAttribute('src'), new RegExp(`^http://.*`));
- assert.strictEqual(imageElement.getAttribute('alt'), imageFileName);
-
- // Make sure the normal message is visible
- assert.match(
- dom.document.querySelector(`[data-event-id="${normalMessageEventId1}"]`).outerHTML,
- new RegExp(`.*${escapeStringRegexp(normalMessageText1)}.*`)
- );
+ // We purposely send no events in the room
- // Make sure the other normal message is visible
- assert.match(
- dom.document.querySelector(`[data-event-id="${normalMessageEventId2}"]`).outerHTML,
- new RegExp(`.*${escapeStringRegexp(normalMessageText2)}.*`)
- );
+ archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
+ const archivePageHtml = await fetchEndpointAsText(archiveUrl);
- const replyMessageElement = dom.document.querySelector(
- `[data-event-id="${replyMessageEventId}"]`
- );
- // Make sure the reply text is there
- assert.match(
- replyMessageElement.outerHTML,
- new RegExp(`.*${escapeStringRegexp(replyMessageText)}.*`)
- );
- // Make sure it also includes the message we're replying to
- assert.match(
- replyMessageElement.outerHTML,
- new RegExp(`.*${escapeStringRegexp(normalMessageEventId2)}.*`)
- );
+ const dom = parseHTML(archivePageHtml);
- const replyMissingRelationMessageElement = dom.document.querySelector(
- `[data-event-id="${replyMissingRelationMessageEventId}"]`
- );
- // Make sure the reply text is there.
- // We don't care about the message we're replying to because it's missing on purpose.
- assert.match(
- replyMissingRelationMessageElement.outerHTML,
- new RegExp(`.*${escapeStringRegexp(replyMissingRelationMessageText)}.*`)
- );
+ // Make sure the summary exists on the page
+ assert(
+ dom.document.querySelector(
+ `[data-testid="not-enough-events-summary-kind-no-events-at-all"]`
+ )
+ );
+ });
- // Make sure the reaction also exists
- assert.match(
- replyMessageElement.outerHTML,
- new RegExp(`.*${escapeStringRegexp(reactionText)}.*`)
- );
- });
+ it(`will redirect to hour pagination when there are too many messages on the same day`, async () => {
+ const client = await getTestClientForHs(testMatrixServerUrl1);
+ const roomId = await createTestRoom(client);
+ // Set this low so we can easily create more than the limit
+ config.set('archiveMessageLimit', 3);
- it(`can render day back in time from room on remote homeserver we haven't backfilled from`, async () => {
- const hs2Client = await getTestClientForHs(testMatrixServerUrl2);
+ // Create more messages than the limit
+ await createMessagesInRoom({
+ client,
+ roomId: roomId,
+ // This is larger than the `archiveMessageLimit` we set
+ numMessages: 5,
+ prefix: 'events in room',
+ timestamp: archiveDate.getTime(),
+ });
- // Create a room on hs2
- const hs2RoomId = await createTestRoom(hs2Client);
- const room2EventIds = await createMessagesInRoom({
- client: hs2Client,
- roomId: hs2RoomId,
- numMessages: 3,
- prefix: HOMESERVER_URL_TO_PRETTY_NAME_MAP[hs2Client.homeserverUrl],
- timestamp: archiveDate.getTime(),
- });
+ archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
+ const archivePageHtml = await fetchEndpointAsText(archiveUrl);
- archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(hs2RoomId, archiveDate, {
- // Since hs1 doesn't know about this room on hs2 yet, we have to provide
- // a via server to ask through.
- viaServers: ['hs2'],
+ assert.match(archivePageHtml, /TODO: Redirect user to smaller hour range/);
});
- const archivePageHtml = await fetchEndpointAsText(archiveUrl);
-
- const dom = parseHTML(archivePageHtml);
+ it(`will not redirect to hour pagination when there are too many messages from surrounding days`, async () => {
+ const client = await getTestClientForHs(testMatrixServerUrl1);
+ const roomId = await createTestRoom(client);
+ // Set this low so we can easily create more than the limit
+ config.set('archiveMessageLimit', 3);
+
+ // Create more messages than the limit on a previous day
+ const previousArchiveDate = new Date(Date.UTC(2022, 0, 2));
+ assert(
+ previousArchiveDate < archiveDate,
+ `The previousArchiveDate=${previousArchiveDate} should be before the archiveDate=${archiveDate}`
+ );
+ const surroundEventIds = await createMessagesInRoom({
+ client,
+ roomId: roomId,
+ // This is larger than the `archiveMessageLimit` we set
+ numMessages: 2,
+ prefix: 'events in room',
+ timestamp: previousArchiveDate.getTime(),
+ });
- // Make sure the messages are visible
- assert.deepStrictEqual(
- room2EventIds.map((eventId) => {
- return dom.document
- .querySelector(`[data-event-id="${eventId}"]`)
- ?.getAttribute('data-event-id');
- }),
- room2EventIds
- );
- });
+ // Create more messages than the limit
+ const eventIdsOnDay = await createMessagesInRoom({
+ client,
+ roomId: roomId,
+ // This is larger than the `archiveMessageLimit` we set
+ numMessages: 2,
+ prefix: 'events in room',
+ timestamp: archiveDate.getTime(),
+ });
- it(`will redirect to hour pagination when there are too many messages`);
+ archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
+ const archivePageHtml = await fetchEndpointAsText(archiveUrl);
- it(`will render a room with only a day of messages`);
+ const dom = parseHTML(archivePageHtml);
- it(
- `will render a room with a sparse amount of messages (a few per day) with no contamination between days`
- );
+ // Make sure the messages are displayed
+ const expectedEventIdsToBeDisplayed = [].concat(surroundEventIds).concat(eventIdsOnDay);
+ assert.deepStrictEqual(
+ expectedEventIdsToBeDisplayed.map((eventId) => {
+ return dom.document
+ .querySelector(`[data-event-id="${eventId}"]`)
+ ?.getAttribute('data-event-id');
+ }),
+ expectedEventIdsToBeDisplayed
+ );
+ });
+ });
describe('Room directory', () => {
it('room search narrows down results', async () => {