Add lightbox support and Hydrogen URL hashes relative to the room (#12)

Add image lightbox support and Hydrogen URL hashes relative to the room

Related to https://github.com/vector-im/hydrogen-web/issues/677

Requires the changes from https://github.com/vector-im/hydrogen-web/pull/749 (split out from https://github.com/vector-im/hydrogen-web/pull/653)

![](https://user-images.githubusercontent.com/558581/172526457-38c108e8-8c46-4e0c-9979-734348ec67fc.gif)


### Hydrogen routing relative to the room (remove session and room from the URL hash)

Before:
Page URL: doesn't work
```html
<div class="Timeline_messageBody">
   <div class="media" style="max-width: 400px">
      <div class="spacer" style="padding-top: 48.75%;"></div>
      <a href="undefined">
          <img src="http://192.168.1.182:8008//_matrix/media/r0/thumbnail/my.synapse.server/RxfuMxEgYcXHKYWERkKVUkqO?width=400&amp;height=195&amp;method=scale" alt="Friction_between_surfaces.jpeg" title="Friction_between_surfaces.jpeg" style="max-width: 400px; max-height: 195px;">
      </a>
      <time>2/24 6:20 PM</time>
   </div>
   <!--node binding placeholder-->
</div>
```

Before (not relative):
Page URL: `http://localhost:3050/!HBehERstyQBxyJDLfR:my.synapse.server/date/2022/02/24#/session/123/room/!HBehERstyQBxyJDLfR:my.synapse.server/lightbox/$17cgP6YBP9ny9xuU1vBmpOYFhRG4zpOe9SOgWi2Wxsk`
```html
<div class="Timeline_messageBody">
   <div class="media" style="max-width: 400px">
      <div class="spacer" style="padding-top: 48.75%;"></div>
      <a href="#/session/123/room/!HBehERstyQBxyJDLfR:my.synapse.server/lightbox/$17cgP6YBP9ny9xuU1vBmpOYFhRG4zpOe9SOgWi2Wxsk">
          <img src="http://192.168.1.182:8008//_matrix/media/r0/thumbnail/my.synapse.server/RxfuMxEgYcXHKYWERkKVUkqO?width=400&amp;height=195&amp;method=scale" alt="Friction_between_surfaces.jpeg" title="Friction_between_surfaces.jpeg" style="max-width: 400px; max-height: 195px;">
      </a>
      <time>2/24 6:20 PM</time>
   </div>
   <!--node binding placeholder-->
</div>
```

After (nice relative links):
Page URL: `http://localhost:3050/!HBehERstyQBxyJDLfR:my.synapse.server/date/2022/02/24#/lightbox/$17cgP6YBP9ny9xuU1vBmpOYFhRG4zpOe9SOgWi2Wxsk`
```html
<div class="Timeline_messageBody">
   <div class="media" style="max-width: 400px">
      <div class="spacer" style="padding-top: 48.75%;"></div>
      <a href="#/lightbox/$17cgP6YBP9ny9xuU1vBmpOYFhRG4zpOe9SOgWi2Wxsk">
          <img src="http://192.168.1.182:8008//_matrix/media/r0/thumbnail/my.synapse.server/RxfuMxEgYcXHKYWERkKVUkqO?width=400&amp;height=195&amp;method=scale" alt="Friction_between_surfaces.jpeg" title="Friction_between_surfaces.jpeg" style="max-width: 400px; max-height: 195px;">
      </a>
      <time>2/24 6:20 PM</time>
   </div>
   <!--node binding placeholder-->
</div>
```
This commit is contained in:
Eric Eastwood 2022-06-08 14:03:36 -05:00 committed by GitHub
parent 940c73868f
commit 7dfe8cabc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 152 additions and 63 deletions

View File

@ -5,7 +5,7 @@
"node": true "node": true
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 2018, "ecmaVersion": 2022,
"sourceType": "script" "sourceType": "script"
}, },
"plugins": ["node"], "plugins": ["node"],

View File

@ -1,6 +1,12 @@
'use strict'; '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 { class ArchiveView extends TemplateView {
render(t, vm) { render(t, vm) {
@ -13,6 +19,10 @@ class ArchiveView extends TemplateView {
[ [
t.view(new RoomView(vm.roomViewModel, viewClassForTile)), t.view(new RoomView(vm.roomViewModel, viewClassForTile)),
t.view(new RightPanelView(vm.rightPanelModel)), t.view(new RightPanelView(vm.rightPanelModel)),
t.mapView(
(vm) => vm.lightboxViewModel,
(lightboxViewModel) => (lightboxViewModel ? new LightboxView(lightboxViewModel) : null)
),
] ]
); );
} }

View File

@ -14,15 +14,15 @@ const {
encodeKey, encodeKey,
encodeEventIdKey, encodeEventIdKey,
Timeline, Timeline,
// TimelineView,
// RoomView,
RoomViewModel, RoomViewModel,
ViewModel, ViewModel,
setupLightboxNavigation,
} = require('hydrogen-view-sdk'); } = require('hydrogen-view-sdk');
const ArchiveView = require('matrix-public-archive-shared/ArchiveView'); const ArchiveView = require('matrix-public-archive-shared/ArchiveView');
const RightPanelContentView = require('matrix-public-archive-shared/RightPanelContentView'); const RightPanelContentView = require('matrix-public-archive-shared/RightPanelContentView');
const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); 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; const fromTimestamp = window.matrixPublicArchiveContext.fromTimestamp;
assert(fromTimestamp); assert(fromTimestamp);
@ -73,6 +73,45 @@ function makeEventEntryFromEventJson(eventJson, memberEvent) {
return eventEntry; return eventEntry;
} }
// For any `<a href="">` (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 `<a href="">` (anchor with a blank href), instead of reloading
// the page just remove the hash.
if (
e.type === 'click' &&
e.target.tagName?.toLowerCase() === 'a' &&
e.target?.getAttribute('href') === ''
) {
this.clearHash();
// Prevent the page navigation (reload)
e.preventDefault();
}
// 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 // eslint-disable-next-line max-statements
async function mountHydrogen() { async function mountHydrogen() {
const app = document.querySelector('#app'); const app = document.querySelector('#app');
@ -88,54 +127,33 @@ async function mountHydrogen() {
const navigation = createNavigation(); const navigation = createNavigation();
platform.setNavigation(navigation); platform.setNavigation(navigation);
const archiveHistory = new ArchiveHistory(roomData.id);
const urlRouter = createRouter({ const urlRouter = createRouter({
navigation: navigation, 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 // We use the timeline to setup the relations between entries
const timeline = new Timeline({ const timeline = new Timeline({
roomId: roomData.id, roomId: roomData.id,
//storage: this._storage,
fragmentIdComparer: fragmentIdComparer, fragmentIdComparer: fragmentIdComparer,
clock: platform.clock, clock: platform.clock,
logger: platform.logger, logger: platform.logger,
//hsApi: this._hsApi
}); });
const mediaRepository = new MediaRepository({ const mediaRepository = new MediaRepository({
homeserver: config.matrixServerUrl, 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 = { const room = {
name: roomData.name, name: roomData.name,
id: roomData.id, id: roomData.id,
@ -156,9 +174,14 @@ async function mountHydrogen() {
const memberEvent = workingStateEventMap[event.user_id]; const memberEvent = workingStateEventMap[event.user_id];
return makeEventEntryFromEventJson(event, memberEvent); return makeEventEntryFromEventJson(event, memberEvent);
}); });
//console.log('eventEntries', eventEntries);
console.log('eventEntries', eventEntries.length); 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 // We have to use `timeline._setupEntries([])` because it sets
// `this._allEntries` in `Timeline` and we don't want to use `timeline.load()` // `this._allEntries` in `Timeline` and we don't want to use `timeline.load()`
// to request remote things. // to request remote things.
@ -198,23 +221,6 @@ async function mountHydrogen() {
tiles, 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({ const roomViewModel = new RoomViewModel({
room, room,
ownUserId: 'xxx', ownUserId: 'xxx',
@ -223,6 +229,7 @@ async function mountHydrogen() {
navigation, navigation,
}); });
// FIXME: We shouldn't have to dive into the internal fields to make this work
roomViewModel._timelineVM = timelineViewModel; roomViewModel._timelineVM = timelineViewModel;
roomViewModel._composerVM = { roomViewModel._composerVM = {
kind: 'none', kind: 'none',
@ -277,9 +284,9 @@ async function mountHydrogen() {
} }
const fromDate = new Date(fromTimestamp); const fromDate = new Date(fromTimestamp);
const archiveViewModel = { class ArchiveViewModel extends ViewModel {
roomViewModel, roomViewModel = roomViewModel;
rightPanelModel: { rightPanelModel = {
activeViewModel: { activeViewModel: {
type: 'custom', type: 'custom',
customView: RightPanelContentView, customView: RightPanelContentView,
@ -290,18 +297,40 @@ async function mountHydrogen() {
calendarDate: fromDate, 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); const view = new ArchiveView(archiveViewModel);
//console.log('view.mount()', view.mount());
app.replaceChildren(view.mount()); app.replaceChildren(view.mount());
addSupportClasses(); addSupportClasses();
supressBlankAnchorsReloadingThePage();
} }
// N.B.: When we run this in a `vm`, it will return the last statement. It's // N.B.: When we run this in a virtual machine (`vm`), it will return the last
// important to leave this as the last statement so we can await the promise it // statement. It's important to leave this as the last statement so we can await
// returns and signal that all of the async tasks completed. // the promise it returns and signal that all of the async tasks completed.
mountHydrogen(); mountHydrogen();

View File

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