Show surrounding messages for a full screen of content (#71)

1. Add surrounding messages to the given messages so we have a full screen of content to make it feel lively even in quiet rooms
    - As you scroll around the timeline across different days, the date changes in the URL, calendar, etc
 2. Add summary item to the bottom of the timeline that explains if we couldn't find any messages in the specific day requested 
    - Also allows you to the jump to the next activity in the room. Adds `/:roomId/jump?ts=xxx&dir=[f|b]` to facilitate this.
    - Part of https://github.com/matrix-org/matrix-public-archive/issues/46
 3. Add developer options modal which is linked from the bottom of the right-panel
    - Adds an option so you can debug the `IntersectionObserver` and how it's selecting the active day from the top-edge of the scroll viewport.
    - In the future, this will also include a nice little visualization of the backend timing traces
This commit is contained in:
Eric Eastwood 2022-09-20 16:02:09 -05:00 committed by GitHub
parent 92668996d7
commit be837515fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1573 additions and 640 deletions

14
package-lock.json generated
View File

@ -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",

View File

@ -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/",

View File

@ -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,11 +29,21 @@ body {
min-width: 0;
}
/* No need to open the right-panel when it's always visible at desktop widths */
.room-header-change-dates-button {
display: none;
.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 */
.RoomHeader_changeDatesButton {
display: none;
}
/* No need to close the right-panel when it's always visible at desktop widths */
.RightPanelView_buttons .close {
display: none;
@ -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 {

View File

@ -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 = `<meta name="robots" content="noindex, nofollow" />`;
}

View File

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

View File

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

View File

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

View File

@ -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 `<a href="">` (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');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,353 +148,482 @@ 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);
describe('Archive room view', () => {
it('shows all events in a given day', 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',
},
// 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);
// eslint-disable-next-line max-statements
it('can render diverse messages', async () => {
const client = await getTestClientForHs(testMatrixServerUrl1);
const roomId = await createTestRoom(client);
const dom = parseHTML(archivePageHtml);
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 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
);
});
// TODO: Set avatar of room
it('shows all events in a given day', async () => {
const client = await getTestClientForHs(testMatrixServerUrl1);
const roomId = await createTestRoom(client);
// 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,
},
});
// 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())
);
// 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 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.`,
];
// 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,
},
});
// TODO: Can we use `createMessagesInRoom` here instead?
const eventIds = [];
for (const messageText of messageTextList) {
// 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: `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${normalMessageEventId2}?via=my.synapse.server">In reply to</a> <a href="https://matrix.to/#/@ericgittertester:my.synapse.server">@ericgittertester:my.synapse.server</a><br>${normalMessageText2}</blockquote></mx-reply>${replyMessageText}`,
mimetype: 'text/html',
},
],
body: `> <@ericgittertester:my.synapse.server> ${normalMessageText2}\n\n${replyMessageText}`,
msgtype: 'm.text',
format: 'org.matrix.custom.html',
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${normalMessageEventId2}?via=my.synapse.server">In reply to</a> <a href="https://matrix.to/#/@ericgittertester:my.synapse.server">@ericgittertester:my.synapse.server</a><br>${normalMessageText2}</blockquote></mx-reply>${replyMessageText}`,
'm.relates_to': {
'm.in_reply_to': {
event_id: normalMessageEventId2,
},
},
},
});
// 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: `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${missingRelationEventId}?via=my.synapse.server">In reply to</a> <a href="https://matrix.to/#/@ericgittertester:my.synapse.server">@ericgittertester:my.synapse.server</a><br>some missing message</blockquote></mx-reply>${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: `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${missingRelationEventId}?via=my.synapse.server">In reply to</a> <a href="https://matrix.to/#/@ericgittertester:my.synapse.server">@ericgittertester:my.synapse.server</a><br>some missing message</blockquote></mx-reply>${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,
},
},
});
archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
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="${imageEventId}"]`).outerHTML,
new RegExp(`.*${escapeStringRegexp(displayName)}.*`)
);
// 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://.*`));
// 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)}.*`)
);
// Make sure the other normal message is visible
assert.match(
dom.document.querySelector(`[data-event-id="${normalMessageEventId2}"]`).outerHTML,
new RegExp(`.*${escapeStringRegexp(normalMessageText2)}.*`)
);
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)}.*`)
);
// Make sure the reaction also exists
assert.match(
replyMessageElement.outerHTML,
new RegExp(`.*${escapeStringRegexp(reactionText)}.*`)
);
});
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
);
});
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: messageText,
body: 'some message in the history',
},
});
eventIds.push(eventId);
}
const expectedEventIdsOnDay = [eventId];
// Sanity check that we actually sent some messages
assert.strictEqual(eventIds.length, 3);
// Visit `/:roomIdOrAlias` and expect to be redirected to the last day with events
archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForRoom(roomId);
const archivePageHtml = await fetchEndpointAsText(archiveUrl);
archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
const archivePageHtml = await fetchEndpointAsText(archiveUrl);
const dom = parseHTML(archivePageHtml);
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)}.*`)
// 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('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];
// 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 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 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
);
});
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);
// We purposely send no events in the room
archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
const archivePageHtml = await fetchEndpointAsText(archiveUrl);
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-at-all"]`
)
);
});
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);
// 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(),
});
archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
const archivePageHtml = await fetchEndpointAsText(archiveUrl);
assert.match(archivePageHtml, /TODO: Redirect user to smaller hour range/);
});
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(),
});
// 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(),
});
archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
const archivePageHtml = await fetchEndpointAsText(archiveUrl);
const dom = parseHTML(archivePageHtml);
// 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
);
});
});
// eslint-disable-next-line max-statements
it('can render diverse messages', async () => {
const client = await getTestClientForHs(testMatrixServerUrl1);
const roomId = await createTestRoom(client);
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,
});
// TODO: Set avatar of room
// 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: 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,
},
});
// 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: `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${normalMessageEventId2}?via=my.synapse.server">In reply to</a> <a href="https://matrix.to/#/@ericgittertester:my.synapse.server">@ericgittertester:my.synapse.server</a><br>${normalMessageText2}</blockquote></mx-reply>${replyMessageText}`,
mimetype: 'text/html',
},
],
body: `> <@ericgittertester:my.synapse.server> ${normalMessageText2}\n\n${replyMessageText}`,
msgtype: 'm.text',
format: 'org.matrix.custom.html',
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${normalMessageEventId2}?via=my.synapse.server">In reply to</a> <a href="https://matrix.to/#/@ericgittertester:my.synapse.server">@ericgittertester:my.synapse.server</a><br>${normalMessageText2}</blockquote></mx-reply>${replyMessageText}`,
'm.relates_to': {
'm.in_reply_to': {
event_id: normalMessageEventId2,
},
},
},
});
// 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: `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${missingRelationEventId}?via=my.synapse.server">In reply to</a> <a href="https://matrix.to/#/@ericgittertester:my.synapse.server">@ericgittertester:my.synapse.server</a><br>some missing message</blockquote></mx-reply>${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: `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${missingRelationEventId}?via=my.synapse.server">In reply to</a> <a href="https://matrix.to/#/@ericgittertester:my.synapse.server">@ericgittertester:my.synapse.server</a><br>some missing message</blockquote></mx-reply>${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,
},
},
});
archiveUrl = matrixPublicArchiveURLCreator.archiveUrlForDate(roomId, archiveDate);
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="${imageEventId}"]`).outerHTML,
new RegExp(`.*${escapeStringRegexp(displayName)}.*`)
);
// 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://.*`));
// 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)}.*`)
);
// Make sure the other normal message is visible
assert.match(
dom.document.querySelector(`[data-event-id="${normalMessageEventId2}"]`).outerHTML,
new RegExp(`.*${escapeStringRegexp(normalMessageText2)}.*`)
);
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)}.*`)
);
// Make sure the reaction also exists
assert.match(
replyMessageElement.outerHTML,
new RegExp(`.*${escapeStringRegexp(reactionText)}.*`)
);
});
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
);
});
it(`will redirect to hour pagination when there are too many messages`);
it(`will render a room with only a day of messages`);
it(
`will render a room with a sparse amount of messages (a few per day) with no contamination between days`
);
describe('Room directory', () => {
it('room search narrows down results', async () => {
const client = await getTestClientForHs(testMatrixServerUrl1);