matrix-public-archive/shared/hydrogen-vm-render-script.js

318 lines
9.9 KiB
JavaScript

'use strict';
// Isomorphic script that runs in the browser and on the server for SSR (needs
// browser context) that renders Hydrogen to the `document.body`.
//
// Data is passed in via `window.matrixPublicArchiveContext`
const assert = require('matrix-public-archive-shared/lib/assert');
const {
Platform,
MediaRepository,
createNavigation,
createRouter,
tag,
RetainedObservableValue,
PowerLevels,
TilesCollection,
FragmentIdComparer,
tileClassForEntry,
EventEntry,
encodeKey,
encodeEventIdKey,
Timeline,
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 ArchiveHistory = require('matrix-public-archive-shared/lib/archive-history');
const ArchiveViewModel = require('matrix-public-archive-shared/viewmodels/ArchiveViewModel');
const fromTimestamp = window.matrixPublicArchiveContext.fromTimestamp;
assert(fromTimestamp);
const roomData = window.matrixPublicArchiveContext.roomData;
assert(roomData);
const events = window.matrixPublicArchiveContext.events;
assert(events);
const stateEventMap = window.matrixPublicArchiveContext.stateEventMap;
assert(stateEventMap);
const config = window.matrixPublicArchiveContext.config;
assert(config);
assert(config.matrixServerUrl);
assert(config.basePath);
const matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(config.basePath);
function addSupportClasses() {
const input = document.createElement('input');
input.type = 'month';
const isMonthTypeSupported = input.type === 'month';
// Signal `<input type="month">` support to our CSS
document.body.classList.toggle('fallback-input-month', !isMonthTypeSupported);
}
let eventIndexCounter = 0;
const fragmentIdComparer = new FragmentIdComparer([]);
function makeEventEntryFromEventJson(eventJson, memberEvent) {
assert(eventJson);
const eventIndex = eventIndexCounter;
const eventEntry = new EventEntry(
{
fragmentId: 0,
eventIndex: eventIndex, // TODO: What should this be?
roomId: roomData.id,
event: eventJson,
displayName: memberEvent && memberEvent.content && memberEvent.content.displayname,
avatarUrl: memberEvent && memberEvent.content && memberEvent.content.avatar_url,
key: encodeKey(roomData.id, 0, eventIndex),
eventIdKey: encodeEventIdKey(roomData.id, eventJson.event_id),
},
fragmentIdComparer
);
eventIndexCounter++;
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
async function mountHydrogen() {
const app = document.querySelector('#app');
const platformConfig = {};
const assetPaths = {};
const platform = new Platform({
container: app,
assetPaths,
config: platformConfig,
options: { development: true },
});
const navigation = createNavigation();
platform.setNavigation(navigation);
const archiveHistory = new ArchiveHistory(roomData.id);
const urlRouter = createRouter({
navigation: navigation,
// 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,
fragmentIdComparer: fragmentIdComparer,
clock: platform.clock,
logger: platform.logger,
});
const mediaRepository = new MediaRepository({
homeserver: config.matrixServerUrl,
});
const room = {
name: roomData.name,
id: roomData.id,
avatarUrl: roomData.avatarUrl,
avatarColorId: roomData.id,
mediaRepository: mediaRepository,
// Based on https://github.com/vector-im/hydrogen-web/blob/5f9cfffa3b547991b665f57a8bf715270a1b2ef1/src/matrix/room/BaseRoom.js#L480
observePowerLevels: async function () {
let powerLevelsObservable = this._powerLevelsObservable;
if (!powerLevelsObservable) {
const powerLevels = new PowerLevels({
powerLevelEvent: {},
ownUserId: 'xxx-ownUserId',
membership: null,
});
powerLevelsObservable = new RetainedObservableValue(powerLevels, () => {
this._powerLevels = null;
});
this._powerLevelsObservable = powerLevelsObservable;
}
return powerLevelsObservable;
},
};
// Something we can modify with new state updates as we see them
const workingStateEventMap = {
...stateEventMap,
};
const eventEntries = events.map((event) => {
if (event.type === 'm.room.member') {
workingStateEventMap[event.state_key] = event;
}
const memberEvent = workingStateEventMap[event.user_id];
return makeEventEntryFromEventJson(event, memberEvent);
});
//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.
timeline._setupEntries([]);
// Make it safe to iterate a derived observable collection
timeline.entries.subscribe({ onAdd: () => null, onUpdate: () => null });
// We use the timeline to setup the relations between entries
timeline.addEntries(eventEntries);
//console.log('timeline.entries', timeline.entries.length, timeline.entries);
const tiles = new TilesCollection(timeline.entries, {
tileClassForEntry,
platform,
navigation,
urlCreator: urlRouter,
timeline,
roomVM: {
room,
},
});
// Trigger `onSubscribeFirst` -> `tiles._populateTiles()` so it creates a tile
// for each entry to display. This way we can also call `tile.notifyVisible()`
// on each tile so that the tile creation doesn't happen later when the
// `TilesListView` is mounted and subscribes which is a bit out of our
// control.
tiles.subscribe({ onAdd: () => null, onUpdate: () => null });
// Make the lazy-load images appear
for (const tile of tiles) {
tile.notifyVisible();
}
const timelineViewModel = {
showJumpDown: false,
setVisibleTileRange: () => {},
tiles,
};
const roomViewModel = new RoomViewModel({
room,
// This is an arbitrary string (doesn't need to match anything and it shouldn't)
ownUserId: 'xxx-ownUserId',
platform,
urlCreator: urlRouter,
navigation,
});
roomViewModel.openRightPanel = function () {
let path = this.navigation.path.until('room');
path = path.with(this.navigation.segment('right-panel', true));
path = path.with(this.navigation.segment('change-dates', true));
this.navigation.applyPath(path);
};
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({
// Hydrogen options
navigation: navigation,
urlCreator: urlRouter,
history: archiveHistory,
// Our options
roomViewModel,
room,
fromDate,
eventEntriesByEventId,
basePath: config.basePath,
});
const view = new ArchiveView(archiveViewModel);
app.replaceChildren(view.mount());
addSupportClasses();
supressBlankAnchorsReloadingThePage();
}
// 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();