Add search to room directory landing page (#70)

Part of https://github.com/matrix-org/matrix-public-archive/issues/6
This commit is contained in:
Eric Eastwood 2022-09-15 20:41:55 -05:00 committed by GitHub
parent f73246768f
commit 92668996d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 194 additions and 61 deletions

View File

@ -3,10 +3,6 @@
} }
.RoomDirectoryView_header { .RoomDirectoryView_header {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 80px;
padding-left: 10px; padding-left: 10px;
padding-bottom: 80px; padding-bottom: 80px;
padding-right: 10px; padding-right: 10px;
@ -14,6 +10,13 @@
background-color: #fafafa; background-color: #fafafa;
} }
.RoomDirectoryView_headerForm {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 80px;
}
.RoomDirectoryView_matrixLogo { .RoomDirectoryView_matrixLogo {
width: 100%; width: 100%;
max-width: 148px; max-width: 148px;
@ -50,6 +53,7 @@
width: 100%; width: 100%;
height: 32px; height: 32px;
padding-left: 32px; padding-left: 32px;
padding-right: 8px;
background: rgba(141, 151, 165, 0.15); background: rgba(141, 151, 165, 0.15);
border-radius: 8px; border-radius: 8px;
@ -71,7 +75,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin-bottom: 80px; margin-bottom: 160px;
} }
.RoomDirectoryView_paginationButtonCombo { .RoomDirectoryView_paginationButtonCombo {

View File

@ -1,2 +1 @@
import mounted from 'matrix-public-archive-shared/hydrogen-vm-render-script'; import 'matrix-public-archive-shared/hydrogen-vm-render-script';
console.log('mounted', mounted);

View File

@ -1,2 +1 @@
import mounted from 'matrix-public-archive-shared/room-directory-vm-render-script'; import 'matrix-public-archive-shared/room-directory-vm-render-script';
console.log('mounted', mounted);

View File

@ -10,19 +10,13 @@ const config = require('../config');
const matrixServerUrl = config.get('matrixServerUrl'); const matrixServerUrl = config.get('matrixServerUrl');
assert(matrixServerUrl); assert(matrixServerUrl);
async function fetchPublicRooms(accessToken, { server, paginationToken, limit } = {}) { async function fetchPublicRooms(accessToken, { server, searchTerm, paginationToken, limit } = {}) {
assert(accessToken); assert(accessToken);
let qs = new URLSearchParams(); let qs = new URLSearchParams();
if (server) { if (server) {
qs.append('server', server); qs.append('server', server);
} }
if (paginationToken) {
qs.append('since', paginationToken);
}
if (limit) {
qs.append('limit', limit);
}
const publicRoomsEndpoint = urlJoin( const publicRoomsEndpoint = urlJoin(
matrixServerUrl, matrixServerUrl,
@ -30,6 +24,14 @@ async function fetchPublicRooms(accessToken, { server, paginationToken, limit }
); );
const publicRoomsRes = await fetchEndpointAsJson(publicRoomsEndpoint, { const publicRoomsRes = await fetchEndpointAsJson(publicRoomsEndpoint, {
method: 'POST',
body: {
filter: {
generic_search_term: searchTerm,
},
since: paginationToken,
limit,
},
accessToken, accessToken,
}); });

View File

@ -10,6 +10,8 @@ function StatusError(status, inputMessage) {
} }
this.message = `${status} - ${message}`; this.message = `${status} - ${message}`;
// This will be picked by the default Express error handler and assign the status code,
// https://expressjs.com/en/guide/error-handling.html#the-default-error-handler
this.status = status; this.status = status;
this.name = 'StatusError'; this.name = 'StatusError';
Error.captureStackTrace(this, StatusError); Error.captureStackTrace(this, StatusError);

View File

@ -29,11 +29,13 @@ router.get(
'/', '/',
asyncHandler(async function (req, res) { asyncHandler(async function (req, res) {
const paginationToken = req.query.page; const paginationToken = req.query.page;
const searchTerm = req.query.search;
const { rooms, nextPaginationToken, prevPaginationToken } = await fetchPublicRooms( const { rooms, nextPaginationToken, prevPaginationToken } = await fetchPublicRooms(
matrixAccessToken, matrixAccessToken,
{ {
//server: TODO, //server: TODO,
searchTerm,
paginationToken, paginationToken,
// It would be good to grab more rooms than we display in case we need // It would be good to grab more rooms than we display in case we need
// to filter any out but then the pagination tokens with the homeserver // to filter any out but then the pagination tokens with the homeserver
@ -54,7 +56,7 @@ router.get(
rooms, rooms,
nextPaginationToken, nextPaginationToken,
prevPaginationToken, prevPaginationToken,
searchTerm: 'foobar (TODO)', searchTerm,
config: { config: {
basePath, basePath,
matrixServerUrl, matrixServerUrl,

View File

@ -81,10 +81,14 @@ router.get(
'/', '/',
asyncHandler(async function (req, res) { asyncHandler(async function (req, res) {
const roomIdOrAlias = req.params.roomIdOrAlias; const roomIdOrAlias = req.params.roomIdOrAlias;
assert(roomIdOrAlias.startsWith('!') || roomIdOrAlias.startsWith('#')); const isValidAlias = roomIdOrAlias.startsWith('!') || roomIdOrAlias.startsWith('#');
if (!isValidAlias) {
throw new StatusError(404, `Invalid alias given: ${roomIdOrAlias}`);
}
// In case we're joining a new room for the first time, // In case we're joining a new room for the first time,
// let's avoid redirecting to our join event // let's avoid redirecting to our join event by getting
// the time before we join and looking backwards.
const dateBeforeJoin = Date.now(); const dateBeforeJoin = Date.now();
// We have to wait for the room join to happen first before we can fetch // We have to wait for the room join to happen first before we can fetch
@ -99,7 +103,7 @@ router.get(
direction: 'b', direction: 'b',
}); });
if (!originServerTs) { if (!originServerTs) {
throw new StatusError(404, 'Unable to find day with an history'); throw new StatusError(404, 'Unable to find day with history');
} }
// Redirect to a day with messages // Redirect to a day with messages
@ -127,7 +131,10 @@ router.get(
timeoutMiddleware, timeoutMiddleware,
asyncHandler(async function (req, res) { asyncHandler(async function (req, res) {
const roomIdOrAlias = req.params.roomIdOrAlias; const roomIdOrAlias = req.params.roomIdOrAlias;
assert(roomIdOrAlias.startsWith('!') || roomIdOrAlias.startsWith('#')); const isValidAlias = roomIdOrAlias.startsWith('!') || roomIdOrAlias.startsWith('#');
if (!isValidAlias) {
throw new StatusError(404, `Invalid alias given: ${roomIdOrAlias}`);
}
const { fromTimestamp, toTimestamp, hourRange, fromHour, toHour } = const { fromTimestamp, toTimestamp, hourRange, fromHour, toHour } =
parseArchiveRangeFromReq(req); parseArchiveRangeFromReq(req);

View File

@ -123,6 +123,8 @@ function supressBlankAnchorsReloadingThePage() {
// eslint-disable-next-line max-statements // eslint-disable-next-line max-statements
async function mountHydrogen() { async function mountHydrogen() {
console.log('Mounting Hydrogen...');
console.time('Completed mounting Hydrogen');
const appElement = document.querySelector('#app'); const appElement = document.querySelector('#app');
const platformConfig = {}; const platformConfig = {};
@ -309,6 +311,8 @@ async function mountHydrogen() {
addSupportClasses(); addSupportClasses();
supressBlankAnchorsReloadingThePage(); supressBlankAnchorsReloadingThePage();
console.timeEnd('Completed mounting Hydrogen');
} }
// N.B.: When we run this in a virtual machine (`vm`), it will return the last // N.B.: When we run this in a virtual machine (`vm`), it will return the last

View File

@ -17,8 +17,11 @@ class URLCreator {
this._basePath = basePath; this._basePath = basePath;
} }
roomDirectoryUrl({ paginationToken } = {}) { roomDirectoryUrl({ searchTerm, paginationToken } = {}) {
let qs = new URLSearchParams(); let qs = new URLSearchParams();
if (searchTerm) {
qs.append('search', searchTerm);
}
if (paginationToken) { if (paginationToken) {
qs.append('page', paginationToken); qs.append('page', paginationToken);
} }

View File

@ -17,7 +17,6 @@ assert(rooms);
const nextPaginationToken = window.matrixPublicArchiveContext.nextPaginationToken; const nextPaginationToken = window.matrixPublicArchiveContext.nextPaginationToken;
const prevPaginationToken = window.matrixPublicArchiveContext.prevPaginationToken; const prevPaginationToken = window.matrixPublicArchiveContext.prevPaginationToken;
const searchTerm = window.matrixPublicArchiveContext.searchTerm; const searchTerm = window.matrixPublicArchiveContext.searchTerm;
assert(searchTerm);
const config = window.matrixPublicArchiveContext.config; const config = window.matrixPublicArchiveContext.config;
assert(config); assert(config);
assert(config.matrixServerUrl); assert(config.matrixServerUrl);
@ -27,6 +26,8 @@ assert(config.basePath);
const matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(config.basePath); const matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(config.basePath);
async function mountHydrogen() { async function mountHydrogen() {
console.log('Mounting Hydrogen...');
console.time('Completed mounting Hydrogen');
const appElement = document.querySelector('#app'); const appElement = document.querySelector('#app');
const roomDirectoryViewModel = new RoomDirectoryViewModel({ const roomDirectoryViewModel = new RoomDirectoryViewModel({
@ -34,6 +35,7 @@ async function mountHydrogen() {
homeserverName: config.matrixServerName, homeserverName: config.matrixServerName,
matrixPublicArchiveURLCreator, matrixPublicArchiveURLCreator,
rooms, rooms,
searchTerm,
nextPaginationToken, nextPaginationToken,
prevPaginationToken, prevPaginationToken,
}); });
@ -41,6 +43,7 @@ async function mountHydrogen() {
const view = new RoomDirectoryView(roomDirectoryViewModel); const view = new RoomDirectoryView(roomDirectoryViewModel);
appElement.replaceChildren(view.mount()); appElement.replaceChildren(view.mount());
console.timeEnd('Completed mounting Hydrogen');
} }
// N.B.: When we run this in a virtual machine (`vm`), it will return the last // N.B.: When we run this in a virtual machine (`vm`), it will return the last

View File

@ -14,6 +14,7 @@ class RoomDirectoryViewModel extends ViewModel {
homeserverName, homeserverName,
matrixPublicArchiveURLCreator, matrixPublicArchiveURLCreator,
rooms, rooms,
searchTerm,
nextPaginationToken, nextPaginationToken,
prevPaginationToken, prevPaginationToken,
} = options; } = options;
@ -39,6 +40,7 @@ class RoomDirectoryViewModel extends ViewModel {
}; };
}) })
); );
this._searchTerm = searchTerm;
this._nextPaginationToken = nextPaginationToken; this._nextPaginationToken = nextPaginationToken;
this._prevPaginationToken = prevPaginationToken; this._prevPaginationToken = prevPaginationToken;
} }
@ -51,9 +53,19 @@ class RoomDirectoryViewModel extends ViewModel {
return this._matrixPublicArchiveURLCreator.roomDirectoryUrl(); return this._matrixPublicArchiveURLCreator.roomDirectoryUrl();
} }
get searchTerm() {
return this._searchTerm || '';
}
setSearchTerm(newSearchTerm) {
this._searchTerm = newSearchTerm;
this.emitChange('searchTerm');
}
get nextPageUrl() { get nextPageUrl() {
if (this._nextPaginationToken) { if (this._nextPaginationToken) {
return this._matrixPublicArchiveURLCreator.roomDirectoryUrl({ return this._matrixPublicArchiveURLCreator.roomDirectoryUrl({
searchTerm: this.searchTerm,
paginationToken: this._nextPaginationToken, paginationToken: this._nextPaginationToken,
}); });
} }
@ -64,6 +76,7 @@ class RoomDirectoryViewModel extends ViewModel {
get prevPageUrl() { get prevPageUrl() {
if (this._prevPaginationToken) { if (this._prevPaginationToken) {
return this._matrixPublicArchiveURLCreator.roomDirectoryUrl({ return this._matrixPublicArchiveURLCreator.roomDirectoryUrl({
searchTerm: this.searchTerm,
paginationToken: this._prevPaginationToken, paginationToken: this._prevPaginationToken,
}); });
} }

View File

@ -32,6 +32,8 @@ class RoomCardView extends TemplateView {
className: { className: {
RoomCardView: true, RoomCardView: true,
}, },
'data-room-id': vm.roomId,
'data-testid': 'room-card',
}, },
[ [
t.a( t.a(

View File

@ -7,6 +7,14 @@ const RoomCardView = require('./RoomCardView');
class RoomDirectoryView extends TemplateView { class RoomDirectoryView extends TemplateView {
render(t, vm) { render(t, vm) {
// Make sure we don't overwrite the search input value if someone has typed
// before the JavaScript has loaded
const searchInputBeforeRendering = document.querySelector('.RoomDirectoryView_searchInput');
if (searchInputBeforeRendering) {
const searchInputValueBeforeRendering = searchInputBeforeRendering.value;
vm.setSearchTerm(searchInputValueBeforeRendering);
}
const roomList = new ListView( const roomList = new ListView(
{ {
className: 'RoomDirectoryView_roomList', className: 'RoomDirectoryView_roomList',
@ -30,6 +38,7 @@ class RoomDirectoryView extends TemplateView {
}, },
[ [
t.header({ className: 'RoomDirectoryView_header' }, [ t.header({ className: 'RoomDirectoryView_header' }, [
t.form({ className: 'RoomDirectoryView_headerForm', method: 'GET' }, [
t.a( t.a(
{ {
className: 'RoomDirectoryView_matrixLogo', className: 'RoomDirectoryView_matrixLogo',
@ -59,19 +68,34 @@ class RoomDirectoryView extends TemplateView {
] ]
), ),
t.input({ t.input({
type: 'search',
className: 'RoomDirectoryView_searchInput', className: 'RoomDirectoryView_searchInput',
placeholder: 'Search rooms (disabled, not implemented yet)', placeholder: 'Search rooms',
disabled: true, name: 'search',
value: vm.searchTerm,
// Autocomplete is disabled because browsers share autocomplete
// suggestions across domains and this uses a very common
// `name="search"`. The name is important because it's what
// shows up in the query parameters when the `<form
// method="GET">` is submitted. I wish we could scope the
// autocomplete suggestions to the apps domain
// (https://github.com/whatwg/html/issues/8284). Trying some
// custom non-spec value here also doesn't seem to work (Chrome
// decides to autofill based on `name="search"`).
autocomplete: 'off',
autocapitalize: 'off',
'data-testid': 'room-directory-search-input',
}), }),
]), ]),
t.div({ className: 'RoomDirectoryView_homeserverSelectSection' }, [ t.div({ className: 'RoomDirectoryView_homeserverSelectSection' }, [
t.div({}, 'Show: Matrix rooms on'), t.div({}, 'Show: Matrix rooms on'),
t.select( t.select(
{ className: 'RoomDirectoryView_homeserverSelector' }, { className: 'RoomDirectoryView_homeserverSelector', name: 'homeserver' },
availableHomeserverOptionElements availableHomeserverOptionElements
), ),
]), ]),
]), ]),
]),
t.main({ className: 'RoomDirectoryView_mainContent' }, [ t.main({ className: 'RoomDirectoryView_mainContent' }, [
t.view(roomList), t.view(roomList),
t.div({ className: 'RoomDirectoryView_paginationButtonCombo' }, [ t.div({ className: 'RoomDirectoryView_paginationButtonCombo' }, [

View File

@ -16,6 +16,19 @@ function getTxnId() {
return `${new Date().getTime()}--${txnCount}`; return `${new Date().getTime()}--${txnCount}`;
} }
// Basic slugify function, plenty of edge cases and should not be used for
// production.
function slugify(inputText) {
return (
inputText
.toLowerCase()
// Replace whitespace with hyphens
.replace(/\s+/g, '-')
// Remove anything not alpha-numeric or hypen
.replace(/[^a-z0-9-]+/g, '')
);
}
async function ensureUserRegistered({ matrixServerUrl, username }) { async function ensureUserRegistered({ matrixServerUrl, username }) {
const registerResponse = await fetchEndpointAsJson( const registerResponse = await fetchEndpointAsJson(
urlJoin(matrixServerUrl, '/_matrix/client/v3/register'), urlJoin(matrixServerUrl, '/_matrix/client/v3/register'),
@ -73,19 +86,23 @@ async function getTestClientForHs(testMatrixServerUrl) {
} }
// Create a public room to test in // Create a public room to test in
async function createTestRoom(client, overrideCreateOptions) { async function createTestRoom(client, overrideCreateOptions = {}) {
let qs = new URLSearchParams(); let qs = new URLSearchParams();
if (client.applicationServiceUserIdOverride) { if (client.applicationServiceUserIdOverride) {
qs.append('user_id', client.applicationServiceUserIdOverride); qs.append('user_id', client.applicationServiceUserIdOverride);
} }
const roomName = overrideCreateOptions.name || 'the hangout spot';
const roomAlias = slugify(roomName + getTxnId());
const createRoomResponse = await fetchEndpointAsJson( const createRoomResponse = await fetchEndpointAsJson(
urlJoin(client.homeserverUrl, `/_matrix/client/v3/createRoom?${qs.toString()}`), urlJoin(client.homeserverUrl, `/_matrix/client/v3/createRoom?${qs.toString()}`),
{ {
method: 'POST', method: 'POST',
body: { body: {
preset: 'public_chat', preset: 'public_chat',
name: 'the hangout spot', name: roomName,
room_alias_name: roomAlias,
initial_state: [ initial_state: [
{ {
type: 'm.room.history_visibility', type: 'm.room.history_visibility',
@ -95,6 +112,7 @@ async function createTestRoom(client, overrideCreateOptions) {
}, },
}, },
], ],
visibility: 'public',
...overrideCreateOptions, ...overrideCreateOptions,
}, },
accessToken: client.accessToken, accessToken: client.accessToken,

View File

@ -507,6 +507,57 @@ describe('matrix-public-archive', () => {
`will render a room with a sparse amount of messages (a few per day) with no contamination between days` `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);
// This is just an extra room to fill out the room directory and make sure
// that it does not appear when searching.
await createTestRoom(client);
// Then create two rooms we will find with search
const timeToken = Date.now();
const roomPlanetPrefix = `planet-${timeToken}`;
const roomSaturnId = await createTestRoom(client, {
name: `${roomPlanetPrefix}-saturn`,
});
const roomMarsId = await createTestRoom(client, {
name: `${roomPlanetPrefix}-mars`,
});
// Browse the room directory without search to see many rooms
archiveUrl = matrixPublicArchiveURLCreator.roomDirectoryUrl();
const roomDirectoryPageHtml = await fetchEndpointAsText(archiveUrl);
const dom = parseHTML(roomDirectoryPageHtml);
const roomsOnPageWithoutSearch = [
...dom.document.querySelectorAll(`[data-testid="room-card"]`),
].map((roomCardEl) => {
return roomCardEl.getAttribute('data-room-id');
});
// Then browse the room directory again, this time with the search
// narrowing down results.
archiveUrl = matrixPublicArchiveURLCreator.roomDirectoryUrl({
searchTerm: roomPlanetPrefix,
});
const roomDirectoryWithSearchPageHtml = await fetchEndpointAsText(archiveUrl);
const domWithSearch = parseHTML(roomDirectoryWithSearchPageHtml);
const roomsOnPageWithSearch = [
...domWithSearch.document.querySelectorAll(`[data-testid="room-card"]`),
].map((roomCardEl) => {
return roomCardEl.getAttribute('data-room-id');
});
// Assert that the rooms we searched for are visible
assert.deepStrictEqual(roomsOnPageWithSearch.sort(), [roomSaturnId, roomMarsId].sort());
// Sanity check that search does something. Assert that it's not showing
// the same results as if we didn't make any search.
assert.notDeepStrictEqual(roomsOnPageWithSearch.sort(), roomsOnPageWithoutSearch.sort());
});
});
describe('access controls', () => { describe('access controls', () => {
it('not allowed to view private room even when the archiver user is in the room', async () => { it('not allowed to view private room even when the archiver user is in the room', async () => {
const client = await getTestClientForHs(testMatrixServerUrl1); const client = await getTestClientForHs(testMatrixServerUrl1);