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:
parent
f73246768f
commit
92668996d7
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ class RoomCardView extends TemplateView {
|
|||
className: {
|
||||
RoomCardView: true,
|
||||
},
|
||||
'data-room-id': vm.roomId,
|
||||
'data-testid': 'room-card',
|
||||
},
|
||||
[
|
||||
t.a(
|
||||
|
|
|
@ -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' }, [
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in New Issue