Add ability to search room messages

Signed-off-by: Ajay Bura <ajbura@gmail.com>
This commit is contained in:
Ajay Bura 2022-01-16 14:17:50 +05:30
parent eddba3c652
commit dcef08009d
6 changed files with 289 additions and 22 deletions

View file

@ -52,17 +52,14 @@ function PlaceholderMessage() {
} }
const MessageAvatar = React.memo(({ const MessageAvatar = React.memo(({
roomId, mEvent, userId, username, roomId, avatarSrc, userId, username,
}) => { }) => (
const avatarSrc = mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop'); <div className="message__avatar-container">
return ( <button type="button" onClick={() => openProfileViewer(userId, roomId)}>
<div className="message__avatar-container"> <Avatar imageSrc={avatarSrc} text={username} bgColor={colorMXID(userId)} size="small" />
<button type="button" onClick={() => openProfileViewer(userId, roomId)}> </button>
<Avatar imageSrc={avatarSrc} text={username} bgColor={colorMXID(userId)} size="small" /> </div>
</button> ));
</div>
);
});
const MessageHeader = React.memo(({ const MessageHeader = React.memo(({
userId, username, time, userId, username, time,
@ -597,7 +594,8 @@ function Message({
mEvent, isBodyOnly, roomTimeline, focus, time, mEvent, isBodyOnly, roomTimeline, focus, time,
}) { }) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const { roomId, editedTimeline, reactionTimeline } = roomTimeline; const roomId = mEvent.getRoomId();
const { editedTimeline, reactionTimeline } = roomTimeline ?? {};
const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')]; const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')];
if (focus) className.push('message--focus'); if (focus) className.push('message--focus');
@ -606,7 +604,8 @@ function Message({
const msgType = content?.msgtype; const msgType = content?.msgtype;
const senderId = mEvent.getSender(); const senderId = mEvent.getSender();
let { body } = content; let { body } = content;
const username = getUsernameOfRoomMember(mEvent.sender); const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId);
const avatarSrc = mEvent.sender?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop') ?? null;
const edit = useCallback(() => { const edit = useCallback(() => {
setIsEditing(true); setIsEditing(true);
@ -619,8 +618,10 @@ function Message({
if (msgType === 'm.emote') className.push('message--type-emote'); if (msgType === 'm.emote') className.push('message--type-emote');
let isCustomHTML = content.format === 'org.matrix.custom.html'; let isCustomHTML = content.format === 'org.matrix.custom.html';
const isEdited = editedTimeline.has(eventId); const isEdited = roomTimeline ? editedTimeline.has(eventId) : false;
const haveReactions = reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation'); const haveReactions = roomTimeline
? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
: false;
const isReply = !!mEvent.replyEventId; const isReply = !!mEvent.replyEventId;
let customHTML = isCustomHTML ? content.formatted_body : null; let customHTML = isCustomHTML ? content.formatted_body : null;
@ -640,13 +641,20 @@ function Message({
{ {
isBodyOnly isBodyOnly
? <div className="message__avatar-container" /> ? <div className="message__avatar-container" />
: <MessageAvatar roomId={roomId} mEvent={mEvent} userId={senderId} username={username} /> : (
<MessageAvatar
roomId={roomId}
avatarSrc={avatarSrc}
userId={senderId}
username={username}
/>
)
} }
<div className="message__main-container"> <div className="message__main-container">
{!isBodyOnly && ( {!isBodyOnly && (
<MessageHeader userId={senderId} username={username} time={time} /> <MessageHeader userId={senderId} username={username} time={time} />
)} )}
{isReply && ( {roomTimeline && isReply && (
<MessageReplyWrapper <MessageReplyWrapper
roomTimeline={roomTimeline} roomTimeline={roomTimeline}
eventId={mEvent.replyEventId} eventId={mEvent.replyEventId}
@ -676,7 +684,7 @@ function Message({
{haveReactions && ( {haveReactions && (
<MessageReactionGroup roomTimeline={roomTimeline} mEvent={mEvent} /> <MessageReactionGroup roomTimeline={roomTimeline} mEvent={mEvent} />
)} )}
{!isEditing && ( {roomTimeline && !isEditing && (
<MessageOptions <MessageOptions
roomTimeline={roomTimeline} roomTimeline={roomTimeline}
mEvent={mEvent} mEvent={mEvent}
@ -691,11 +699,12 @@ function Message({
Message.defaultProps = { Message.defaultProps = {
isBodyOnly: false, isBodyOnly: false,
focus: false, focus: false,
roomTimeline: null,
}; };
Message.propTypes = { Message.propTypes = {
mEvent: PropTypes.shape({}).isRequired, mEvent: PropTypes.shape({}).isRequired,
isBodyOnly: PropTypes.bool, isBodyOnly: PropTypes.bool,
roomTimeline: PropTypes.shape({}).isRequired, roomTimeline: PropTypes.shape({}),
focus: PropTypes.bool, focus: PropTypes.bool,
time: PropTypes.string.isRequired, time: PropTypes.string.isRequired,
}; };

View file

@ -0,0 +1,193 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './RoomSearch.scss';
import dateFormat from 'dateformat';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { selectRoom } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import { Message } from '../message/Message';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import { useStore } from '../../hooks/useStore';
const roomIdToBackup = new Map();
function useRoomSearch(roomId) {
const [searchData, setSearchData] = useState(roomIdToBackup.get(roomId) ?? null);
const [status, setStatus] = useState({
type: cons.status.PRE_FLIGHT,
term: null,
});
const mountStore = useStore(roomId);
const mx = initMatrix.matrixClient;
useEffect(() => mountStore.setItem(true), [roomId]);
useEffect(() => {
if (searchData?.results?.length > 0) {
roomIdToBackup.set(roomId, searchData);
} else {
roomIdToBackup.delete(roomId);
}
}, [searchData]);
const search = async (term) => {
setSearchData(null);
if (term === '') {
setStatus({ type: cons.status.PRE_FLIGHT, term: null });
return;
}
setStatus({ type: cons.status.IN_FLIGHT, term });
const body = {
search_categories: {
room_events: {
search_term: term,
filter: {
limit: 10,
rooms: [roomId],
},
order_by: 'recent',
event_context: {
before_limit: 0,
after_limit: 0,
include_profile: true,
},
},
},
};
try {
const res = await mx.search({ body });
const data = mx.processRoomEventsSearch({
_query: body,
results: [],
highlights: [],
}, res);
if (!mountStore.getItem()) return;
setStatus({ type: cons.status.SUCCESS, term });
setSearchData(data);
if (!mountStore.getItem()) return;
} catch (error) {
setSearchData(null);
setStatus({ type: cons.status.ERROR, term });
}
};
const paginate = async () => {
if (searchData === null) return;
const term = searchData._query.search_categories.room_events.search_term;
setStatus({ type: cons.status.IN_FLIGHT, term });
try {
const data = await mx.backPaginateRoomEventsSearch(searchData);
if (!mountStore.getItem()) return;
setStatus({ type: cons.status.SUCCESS, term });
setSearchData(data);
} catch (error) {
if (!mountStore.getItem()) return;
setSearchData(null);
setStatus({ type: cons.status.ERROR, term });
}
};
return [searchData, search, paginate, status];
}
function RoomSearch({ roomId }) {
const [searchData, search, paginate, status] = useRoomSearch(roomId);
const searchTerm = searchData?._query.search_categories.room_events.search_term ?? '';
const handleSearch = (e) => {
e.preventDefault();
const searchTermInput = e.target.elements['room-search-input'];
const term = searchTermInput.value.trim();
search(term);
};
const renderTimeline = (timeline) => (
<div className="room-search__result-item" key={timeline[0].getId()}>
{ timeline.map((mEvent) => {
const time = dateFormat(mEvent.getDate(), 'dd/mm/yyyy - hh:MM TT');
const id = mEvent.getId();
return (
<React.Fragment key={id}>
<Message
mEvent={mEvent}
isBodyOnly={false}
time={time}
/>
<Button onClick={() => selectRoom(roomId, id)}>View</Button>
</React.Fragment>
);
})}
</div>
);
return (
<div className="room-search">
<form className="room-search__form" onSubmit={handleSearch}>
<MenuHeader>Room search</MenuHeader>
<div>
<Input
placeholder="Search for keywords"
name="room-search-input"
/>
<Button iconSrc={SearchIC} variant="primary" type="submit">Search</Button>
</div>
{searchData?.results.length > 0 && (
<Text>{`${searchData.count} results for "${searchTerm}"`}</Text>
)}
</form>
{searchData === null && (
<div className="room-search__help">
{status.type === cons.status.IN_FLIGHT && <Spinner />}
{status.type === cons.status.IN_FLIGHT && <Text>Searching room messages...</Text>}
{status.type === cons.status.PRE_FLIGHT && <RawIcon src={SearchIC} size="large" />}
{status.type === cons.status.PRE_FLIGHT && <Text>Search room messages</Text>}
{status.type === cons.status.ERROR && <Text>Failed to search messages</Text>}
</div>
)}
{searchData?.results.length === 0 && (
<div className="room-search__help">
<Text>No result found</Text>
</div>
)}
{searchData?.results.length > 0 && (
<>
<div className="room-search__content">
{searchData.results.map((searchResult) => {
const { timeline } = searchResult.context;
return renderTimeline(timeline);
})}
</div>
{searchData?.next_batch && (
<div className="room-search__more">
{status.type !== cons.status.IN_FLIGHT && (
<Button onClick={paginate}>Load more</Button>
)}
{status.type === cons.status.IN_FLIGHT && <Spinner />}
</div>
)}
</>
)}
</div>
);
}
RoomSearch.propTypes = {
roomId: PropTypes.string.isRequired,
};
export default RoomSearch;

View file

@ -0,0 +1,62 @@
@use '../../partials/flex';
@use '../../partials/dir';
.room-search {
&__form {
& div:nth-child(2) {
display: flex;
align-items: flex-end;
padding: var(--sp-normal);;
& .input-container {
@extend .cp-fx__item-one;
@include dir.side(margin, 0, var(--sp-normal));
}
& button {
height: 46px;
}
}
& .context-menu__header {
margin-bottom: 0;
}
& > .text {
padding: 0 var(--sp-normal) var(--sp-tight);
}
}
&__help {
height: 248px;
@extend .cp-fx__column--c-c;
& .ic-raw {
opacity: .5;
}
.text {
margin-top: var(--sp-normal);
}
}
&__more {
margin-bottom: var(--sp-normal);
@extend .cp-fx__row--c-c;
button {
width: 100%;
}
}
&__result-item {
padding: var(--sp-tight) var(--sp-normal);
display: flex;
align-items: flex-start;
.message {
@include dir.side(margin, 0, var(--sp-normal));
@extend .cp-fx__item-one;
padding: 0;
&:hover {
background-color: transparent;
}
& .message__time {
flex: 0;
}
}
}
}

View file

@ -14,6 +14,7 @@ import ScrollView from '../../atoms/scroll/ScrollView';
import Tabs from '../../atoms/tabs/Tabs'; import Tabs from '../../atoms/tabs/Tabs';
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu'; import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import RoomProfile from '../../molecules/room-profile/RoomProfile'; import RoomProfile from '../../molecules/room-profile/RoomProfile';
import RoomSearch from '../../molecules/room-search/RoomSearch';
import RoomNotification from '../../molecules/room-notification/RoomNotification'; import RoomNotification from '../../molecules/room-notification/RoomNotification';
import RoomVisibility from '../../molecules/room-visibility/RoomVisibility'; import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
import RoomAliases from '../../molecules/room-aliases/RoomAliases'; import RoomAliases from '../../molecules/room-aliases/RoomAliases';
@ -151,6 +152,7 @@ function RoomSettings({ roomId }) {
/> />
<div className="room-settings__cards-wrapper"> <div className="room-settings__cards-wrapper">
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />} {selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
{selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />} {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />} {selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
</div> </div>

View file

@ -46,6 +46,9 @@
} }
} }
.room-settings .room-permissions__card { .room-settings .room-permissions__card,
.room-settings .room-search__form,
.room-settings .room-search__help,
.room-settings .room-search__result-item {
@extend .room-settings__card; @extend .room-settings__card;
} }

View file

@ -73,8 +73,6 @@ class Navigation extends EventEmitter {
this.emit(cons.events.navigation.SPACE_SELECTED, this.selectedSpaceId); this.emit(cons.events.navigation.SPACE_SELECTED, this.selectedSpaceId);
}, },
[cons.actions.navigation.SELECT_ROOM]: () => { [cons.actions.navigation.SELECT_ROOM]: () => {
if (this.selectedRoomId === action.roomId) return;
const prevSelectedRoomId = this.selectedRoomId; const prevSelectedRoomId = this.selectedRoomId;
this.selectedRoomId = action.roomId; this.selectedRoomId = action.roomId;
this.removeRecentRoom(prevSelectedRoomId); this.removeRecentRoom(prevSelectedRoomId);