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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,8 @@ function StatusError(status, inputMessage) {
}
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.name = 'StatusError';
Error.captureStackTrace(this, StatusError);

View File

@ -29,11 +29,13 @@ router.get(
'/',
asyncHandler(async function (req, res) {
const paginationToken = req.query.page;
const searchTerm = req.query.search;
const { rooms, nextPaginationToken, prevPaginationToken } = await fetchPublicRooms(
matrixAccessToken,
{
//server: TODO,
searchTerm,
paginationToken,
// 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
@ -54,7 +56,7 @@ router.get(
rooms,
nextPaginationToken,
prevPaginationToken,
searchTerm: 'foobar (TODO)',
searchTerm,
config: {
basePath,
matrixServerUrl,

View File

@ -81,10 +81,14 @@ router.get(
'/',
asyncHandler(async function (req, res) {
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,
// 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();
// We have to wait for the room join to happen first before we can fetch
@ -99,7 +103,7 @@ router.get(
direction: 'b',
});
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
@ -127,7 +131,10 @@ router.get(
timeoutMiddleware,
asyncHandler(async function (req, res) {
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 } =
parseArchiveRangeFromReq(req);

View File

@ -123,6 +123,8 @@ function supressBlankAnchorsReloadingThePage() {
// eslint-disable-next-line max-statements
async function mountHydrogen() {
console.log('Mounting Hydrogen...');
console.time('Completed mounting Hydrogen');
const appElement = document.querySelector('#app');
const platformConfig = {};
@ -309,6 +311,8 @@ async function mountHydrogen() {
addSupportClasses();
supressBlankAnchorsReloadingThePage();
console.timeEnd('Completed mounting Hydrogen');
}
// 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;
}
roomDirectoryUrl({ paginationToken } = {}) {
roomDirectoryUrl({ searchTerm, paginationToken } = {}) {
let qs = new URLSearchParams();
if (searchTerm) {
qs.append('search', searchTerm);
}
if (paginationToken) {
qs.append('page', paginationToken);
}

View File

@ -17,7 +17,6 @@ assert(rooms);
const nextPaginationToken = window.matrixPublicArchiveContext.nextPaginationToken;
const prevPaginationToken = window.matrixPublicArchiveContext.prevPaginationToken;
const searchTerm = window.matrixPublicArchiveContext.searchTerm;
assert(searchTerm);
const config = window.matrixPublicArchiveContext.config;
assert(config);
assert(config.matrixServerUrl);
@ -27,6 +26,8 @@ assert(config.basePath);
const matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(config.basePath);
async function mountHydrogen() {
console.log('Mounting Hydrogen...');
console.time('Completed mounting Hydrogen');
const appElement = document.querySelector('#app');
const roomDirectoryViewModel = new RoomDirectoryViewModel({
@ -34,6 +35,7 @@ async function mountHydrogen() {
homeserverName: config.matrixServerName,
matrixPublicArchiveURLCreator,
rooms,
searchTerm,
nextPaginationToken,
prevPaginationToken,
});
@ -41,6 +43,7 @@ async function mountHydrogen() {
const view = new RoomDirectoryView(roomDirectoryViewModel);
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

View File

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

View File

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

View File

@ -7,6 +7,14 @@ const RoomCardView = require('./RoomCardView');
class RoomDirectoryView extends TemplateView {
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(
{
className: 'RoomDirectoryView_roomList',
@ -30,6 +38,7 @@ class RoomDirectoryView extends TemplateView {
},
[
t.header({ className: 'RoomDirectoryView_header' }, [
t.form({ className: 'RoomDirectoryView_headerForm', method: 'GET' }, [
t.a(
{
className: 'RoomDirectoryView_matrixLogo',
@ -59,19 +68,34 @@ class RoomDirectoryView extends TemplateView {
]
),
t.input({
type: 'search',
className: 'RoomDirectoryView_searchInput',
placeholder: 'Search rooms (disabled, not implemented yet)',
disabled: true,
placeholder: 'Search rooms',
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({}, 'Show: Matrix rooms on'),
t.select(
{ className: 'RoomDirectoryView_homeserverSelector' },
{ className: 'RoomDirectoryView_homeserverSelector', name: 'homeserver' },
availableHomeserverOptionElements
),
]),
]),
]),
t.main({ className: 'RoomDirectoryView_mainContent' }, [
t.view(roomList),
t.div({ className: 'RoomDirectoryView_paginationButtonCombo' }, [

View File

@ -16,6 +16,19 @@ function getTxnId() {
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 }) {
const registerResponse = await fetchEndpointAsJson(
urlJoin(matrixServerUrl, '/_matrix/client/v3/register'),
@ -73,19 +86,23 @@ async function getTestClientForHs(testMatrixServerUrl) {
}
// Create a public room to test in
async function createTestRoom(client, overrideCreateOptions) {
async function createTestRoom(client, overrideCreateOptions = {}) {
let qs = new URLSearchParams();
if (client.applicationServiceUserIdOverride) {
qs.append('user_id', client.applicationServiceUserIdOverride);
}
const roomName = overrideCreateOptions.name || 'the hangout spot';
const roomAlias = slugify(roomName + getTxnId());
const createRoomResponse = await fetchEndpointAsJson(
urlJoin(client.homeserverUrl, `/_matrix/client/v3/createRoom?${qs.toString()}`),
{
method: 'POST',
body: {
preset: 'public_chat',
name: 'the hangout spot',
name: roomName,
room_alias_name: roomAlias,
initial_state: [
{
type: 'm.room.history_visibility',
@ -95,6 +112,7 @@ async function createTestRoom(client, overrideCreateOptions) {
},
},
],
visibility: 'public',
...overrideCreateOptions,
},
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`
);
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', () => {
it('not allowed to view private room even when the archiver user is in the room', async () => {
const client = await getTestClientForHs(testMatrixServerUrl1);