diff --git a/package-lock.json b/package-lock.json index 8dfd360..e8fe361 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 52d4cb1..4fd2496 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 9bed638..9e0d366 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,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 { 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 91a6b63..8012307 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 c474cb8..99a8c98 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 7bd1041..53a8868 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 1bb1247..f743aae 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 656da75..fc92a2d 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 d7cebf7..b40bda5 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 0000000..06cadf1 --- /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 e03550a..9f73b6c 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 0000000..fbe1406 --- /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 de157e8..0000000 --- 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 6cdb950..320a9df 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 0000000..f9e8bd3 --- /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 0000000..e32ff80 --- /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 0000000..e98d95f --- /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 46c7590..0000000 --- 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 0000000..3a605bc --- /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 0000000..1aebb56 --- /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 f9ee367..b7ceb57 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 6d570ff..fbef469 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 7a65bf2..d9036d4 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,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: `
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, + }, + }, + }, + }); + + // 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, + }, + }, + }); + + 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: `
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, - }, - }, - }, - }); - - // 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, - }, - }, - }); - - 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);