diff --git a/.eslintrc.json b/.eslintrc.json index 70f28763..d7dcb5d9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,7 +5,7 @@ "node": true }, "parserOptions": { - "ecmaVersion": 2018, + "ecmaVersion": 2022, "sourceType": "script" }, "plugins": ["node"], diff --git a/shared/ArchiveView.js b/shared/ArchiveView.js index 84adc4b0..343cafaa 100644 --- a/shared/ArchiveView.js +++ b/shared/ArchiveView.js @@ -1,6 +1,12 @@ 'use strict'; -const { TemplateView, RoomView, RightPanelView, viewClassForTile } = require('hydrogen-view-sdk'); +const { + TemplateView, + RoomView, + RightPanelView, + LightboxView, + viewClassForTile, +} = require('hydrogen-view-sdk'); class ArchiveView extends TemplateView { render(t, vm) { @@ -13,6 +19,10 @@ class ArchiveView extends TemplateView { [ t.view(new RoomView(vm.roomViewModel, viewClassForTile)), t.view(new RightPanelView(vm.rightPanelModel)), + t.mapView( + (vm) => vm.lightboxViewModel, + (lightboxViewModel) => (lightboxViewModel ? new LightboxView(lightboxViewModel) : null) + ), ] ); } diff --git a/shared/hydrogen-vm-render-script.js b/shared/hydrogen-vm-render-script.js index d47b4c50..33e5b833 100644 --- a/shared/hydrogen-vm-render-script.js +++ b/shared/hydrogen-vm-render-script.js @@ -14,15 +14,15 @@ const { encodeKey, encodeEventIdKey, Timeline, - // TimelineView, - // RoomView, RoomViewModel, ViewModel, + setupLightboxNavigation, } = require('hydrogen-view-sdk'); const ArchiveView = require('matrix-public-archive-shared/ArchiveView'); const RightPanelContentView = require('matrix-public-archive-shared/RightPanelContentView'); const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); +const ArchiveHistory = require('matrix-public-archive-shared/lib/archive-history'); const fromTimestamp = window.matrixPublicArchiveContext.fromTimestamp; assert(fromTimestamp); @@ -73,6 +73,45 @@ function makeEventEntryFromEventJson(eventJson, memberEvent) { return eventEntry; } +// For any `` (anchor with a blank href), instead of reloading the +// page just remove the hash. Also cleanup whenever the hash changes for +// whatever reason. +// +// For example, when closing the lightbox by clicking the close "x" icon, it +// would reload the page instead of SPA because `href=""` will cause a page +// navigation if we didn't have this code. Also cleanup whenever the hash is +// emptied out (like when pressing escape in the lightbox). +function supressBlankAnchorsReloadingThePage() { + const eventHandler = { + clearHash() { + // Cause a `hashchange` event to be fired + document.location.hash = ''; + // Cleanup the leftover `#` left on the URL + window.history.replaceState(null, null, window.location.pathname); + }, + 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(); + } + // Also cleanup whenever the hash is emptied out (like when pressing escape in the lightbox) + else if (e.type === 'hashchange' && document.location.hash === '') { + this.clearHash(); + } + }, + }; + + document.addEventListener('click', eventHandler); + window.addEventListener('hashchange', eventHandler); +} + // eslint-disable-next-line max-statements async function mountHydrogen() { const app = document.querySelector('#app'); @@ -88,54 +127,33 @@ async function mountHydrogen() { const navigation = createNavigation(); platform.setNavigation(navigation); + + const archiveHistory = new ArchiveHistory(roomData.id); const urlRouter = createRouter({ navigation: navigation, - history: platform.history, + // We use our own history because we want the hash to be relative to the + // room and not include the session/room. + // + // Normally, people use `history: platform.history,` + history: archiveHistory, }); + // Make it listen to changes from the history instance. And populate the + // `Navigation` with path segments to work from so `href`'s rendered on the + // page don't say `undefined`. + urlRouter.attach(); // We use the timeline to setup the relations between entries const timeline = new Timeline({ roomId: roomData.id, - //storage: this._storage, fragmentIdComparer: fragmentIdComparer, clock: platform.clock, logger: platform.logger, - //hsApi: this._hsApi }); const mediaRepository = new MediaRepository({ homeserver: config.matrixServerUrl, }); - // const urlRouter = { - // urlUntilSegment: () => { - // return 'todo'; - // }, - // urlForSegments: (segments) => { - // const isLightBox = segments.find((segment) => { - // return segment.type === 'lightbox'; - // console.log('segment', segment); - // }); - - // if (isLightBox) { - // return '#'; - // } - - // return 'todo'; - // }, - // }; - - // const navigation = { - // segment: (type, value) => { - // return new Segment(type, value); - // }, - // }; - - const lightbox = navigation.observe('lightbox'); - lightbox.subscribe((eventId) => { - this._updateLightbox(eventId); - }); - const room = { name: roomData.name, id: roomData.id, @@ -156,9 +174,14 @@ async function mountHydrogen() { const memberEvent = workingStateEventMap[event.user_id]; return makeEventEntryFromEventJson(event, memberEvent); }); - //console.log('eventEntries', eventEntries); console.log('eventEntries', eventEntries.length); + // Map of `event_id` to `EventEntry` + const eventEntriesByEventId = eventEntries.reduce((currentMap, eventEntry) => { + currentMap[eventEntry.id] = eventEntry; + return currentMap; + }, {}); + // We have to use `timeline._setupEntries([])` because it sets // `this._allEntries` in `Timeline` and we don't want to use `timeline.load()` // to request remote things. @@ -198,23 +221,6 @@ async function mountHydrogen() { tiles, }; - // const view = new TimelineView(timelineViewModel); - - // const roomViewModel = { - // kind: 'room', - // timelineViewModel, - // composerViewModel: { - // kind: 'none', - // }, - // i18n: RoomViewModel.prototype.i18n, - - // id: roomData.id, - // name: roomData.name, - // avatarUrl(size) { - // return getAvatarHttpUrl(roomData.avatarUrl, size, platform, mediaRepository); - // }, - // }; - const roomViewModel = new RoomViewModel({ room, ownUserId: 'xxx', @@ -223,6 +229,7 @@ async function mountHydrogen() { navigation, }); + // FIXME: We shouldn't have to dive into the internal fields to make this work roomViewModel._timelineVM = timelineViewModel; roomViewModel._composerVM = { kind: 'none', @@ -277,9 +284,9 @@ async function mountHydrogen() { } const fromDate = new Date(fromTimestamp); - const archiveViewModel = { - roomViewModel, - rightPanelModel: { + class ArchiveViewModel extends ViewModel { + roomViewModel = roomViewModel; + rightPanelModel = { activeViewModel: { type: 'custom', customView: RightPanelContentView, @@ -290,18 +297,40 @@ async function mountHydrogen() { calendarDate: fromDate, }), }, - }, - }; + }; + + constructor(options) { + super(options); + + this.#setupNavigation(); + } + + #setupNavigation() { + setupLightboxNavigation(this, 'lightboxViewModel', (eventId) => { + return { + room, + eventEntry: eventEntriesByEventId[eventId], + }; + }); + } + } + + const archiveViewModel = new ArchiveViewModel({ + navigation: navigation, + urlCreator: urlRouter, + history: archiveHistory, + }); const view = new ArchiveView(archiveViewModel); - //console.log('view.mount()', view.mount()); app.replaceChildren(view.mount()); addSupportClasses(); + + supressBlankAnchorsReloadingThePage(); } -// N.B.: When we run this in a `vm`, it will return the last statement. It's -// important to leave this as the last statement so we can await the promise it -// returns and signal that all of the async tasks completed. +// N.B.: When we run this in a virtual machine (`vm`), it will return the last +// statement. It's important to leave this as the last statement so we can await +// the promise it returns and signal that all of the async tasks completed. mountHydrogen(); diff --git a/shared/lib/archive-history.js b/shared/lib/archive-history.js new file mode 100644 index 00000000..35d0a304 --- /dev/null +++ b/shared/lib/archive-history.js @@ -0,0 +1,50 @@ +'use strict'; + +const { History } = require('hydrogen-view-sdk'); +const assert = require('./assert'); + +// Mock a full hash whenever someone asks via `history.get()` but when +// constructing URL's for use `href` etc, they should relative to the room +// (remove session and room from the hash). +class ArchiveHistory extends History { + constructor(roomId) { + super(); + + assert(roomId); + this._baseHash = `#/session/123/room/${roomId}`; + } + + // Even though the page hash is relative to the room, we still expose the full + // hash for Hydrogen to route things internally as expected. + get() { + const hash = super.get()?.replace(/^#/, '') ?? ''; + return this._baseHash + hash; + } + + replaceUrlSilently(url) { + // We don't need to do this when server-side rendering in Node.js because + // the #hash is not available to servers. This will be called as a + // downstream call of `urlRouter.attach()` which we do when bootstraping + // everything. + if (window.history) { + super.replaceUrlSilently(url); + } + } + + // Make the URLs we use in the UI of the app relative to the room: + // Before: #/session/123/room/!HBehERstyQBxyJDLfR:my.synapse.server/lightbox/$17cgP6YBP9ny9xuU1vBmpOYFhRG4zpOe9SOgWi2Wxsk + // After: #/lightbox/$17cgP6YBP9ny9xuU1vBmpOYFhRG4zpOe9SOgWi2Wxsk + pathAsUrl(path) { + const leftoverPath = super.pathAsUrl(path).replace(this._baseHash, ''); + // Only add back the hash when there is hash content beyond the base so we + // don't end up with an extraneous `#` on the end of the URL. This will end + // up creating some `` (anchors with a blank href) but we have + // some code to clean this up, see `supressBlankAnchorsReloadingThePage`. + if (leftoverPath.length) { + return `#${leftoverPath}`; + } + return leftoverPath; + } +} + +module.exports = ArchiveHistory;