Add ability to search room messages
authorAjay Bura <ajbura@gmail.com>
Sun, 16 Jan 2022 08:47:50 +0000 (14:17 +0530)
committerAjay Bura <ajbura@gmail.com>
Sun, 16 Jan 2022 08:47:50 +0000 (14:17 +0530)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
src/app/molecules/message/Message.jsx
src/app/molecules/room-search/RoomSearch.jsx [new file with mode: 0644]
src/app/molecules/room-search/RoomSearch.scss [new file with mode: 0644]
src/app/organisms/room/RoomSettings.jsx
src/app/organisms/room/RoomSettings.scss
src/client/state/navigation.js

index 054f60be3a3132eb4c55480090d00439a8050015..6fe001bcf78a71c88d46f03dba40d587f0fbdade 100644 (file)
@@ -52,17 +52,14 @@ function PlaceholderMessage() {
 }
 
 const MessageAvatar = React.memo(({
-  roomId, mEvent, userId, username,
-}) => {
-  const avatarSrc = mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop');
-  return (
-    <div className="message__avatar-container">
-      <button type="button" onClick={() => openProfileViewer(userId, roomId)}>
-        <Avatar imageSrc={avatarSrc} text={username} bgColor={colorMXID(userId)} size="small" />
-      </button>
-    </div>
-  );
-});
+  roomId, avatarSrc, userId, username,
+}) => (
+  <div className="message__avatar-container">
+    <button type="button" onClick={() => openProfileViewer(userId, roomId)}>
+      <Avatar imageSrc={avatarSrc} text={username} bgColor={colorMXID(userId)} size="small" />
+    </button>
+  </div>
+));
 
 const MessageHeader = React.memo(({
   userId, username, time,
@@ -597,7 +594,8 @@ function Message({
   mEvent, isBodyOnly, roomTimeline, focus, time,
 }) {
   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')];
   if (focus) className.push('message--focus');
@@ -606,7 +604,8 @@ function Message({
   const msgType = content?.msgtype;
   const senderId = mEvent.getSender();
   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(() => {
     setIsEditing(true);
@@ -619,8 +618,10 @@ function Message({
   if (msgType === 'm.emote') className.push('message--type-emote');
 
   let isCustomHTML = content.format === 'org.matrix.custom.html';
-  const isEdited = editedTimeline.has(eventId);
-  const haveReactions = reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation');
+  const isEdited = roomTimeline ? editedTimeline.has(eventId) : false;
+  const haveReactions = roomTimeline
+    ? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
+    : false;
   const isReply = !!mEvent.replyEventId;
   let customHTML = isCustomHTML ? content.formatted_body : null;
 
@@ -640,13 +641,20 @@ function Message({
       {
         isBodyOnly
           ? <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">
         {!isBodyOnly && (
           <MessageHeader userId={senderId} username={username} time={time} />
         )}
-        {isReply && (
+        {roomTimeline && isReply && (
           <MessageReplyWrapper
             roomTimeline={roomTimeline}
             eventId={mEvent.replyEventId}
@@ -676,7 +684,7 @@ function Message({
         {haveReactions && (
           <MessageReactionGroup roomTimeline={roomTimeline} mEvent={mEvent} />
         )}
-        {!isEditing && (
+        {roomTimeline && !isEditing && (
           <MessageOptions
             roomTimeline={roomTimeline}
             mEvent={mEvent}
@@ -691,11 +699,12 @@ function Message({
 Message.defaultProps = {
   isBodyOnly: false,
   focus: false,
+  roomTimeline: null,
 };
 Message.propTypes = {
   mEvent: PropTypes.shape({}).isRequired,
   isBodyOnly: PropTypes.bool,
-  roomTimeline: PropTypes.shape({}).isRequired,
+  roomTimeline: PropTypes.shape({}),
   focus: PropTypes.bool,
   time: PropTypes.string.isRequired,
 };
diff --git a/src/app/molecules/room-search/RoomSearch.jsx b/src/app/molecules/room-search/RoomSearch.jsx
new file mode 100644 (file)
index 0000000..cbbaf23
--- /dev/null
@@ -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;
diff --git a/src/app/molecules/room-search/RoomSearch.scss b/src/app/molecules/room-search/RoomSearch.scss
new file mode 100644 (file)
index 0000000..a40945e
--- /dev/null
@@ -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;
+      }
+    }
+  }
+}
\ No newline at end of file
index 98f49bc010e255292ddf3122f9ec9ccf3bcae062..057d45a2e598c2c25125698cdabcca059f99453f 100644 (file)
@@ -14,6 +14,7 @@ import ScrollView from '../../atoms/scroll/ScrollView';
 import Tabs from '../../atoms/tabs/Tabs';
 import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
 import RoomProfile from '../../molecules/room-profile/RoomProfile';
+import RoomSearch from '../../molecules/room-search/RoomSearch';
 import RoomNotification from '../../molecules/room-notification/RoomNotification';
 import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
 import RoomAliases from '../../molecules/room-aliases/RoomAliases';
@@ -151,6 +152,7 @@ function RoomSettings({ roomId }) {
           />
           <div className="room-settings__cards-wrapper">
             {selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
+            {selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
             {selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
             {selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
           </div>
index 617024a9cccf954ae3a3817efce7e1863d3e4e0f..909dbb309c9d5f97619f450b49b2f1d4ef83dd13 100644 (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;
 }
\ No newline at end of file
index 977cf7e97048dffb16eb19c6686486a87927c6c8..26ffca3fce1511ddb904d04ae2d7f4d7d6056912 100644 (file)
@@ -73,8 +73,6 @@ class Navigation extends EventEmitter {
         this.emit(cons.events.navigation.SPACE_SELECTED, this.selectedSpaceId);
       },
       [cons.actions.navigation.SELECT_ROOM]: () => {
-        if (this.selectedRoomId === action.roomId) return;
-
         const prevSelectedRoomId = this.selectedRoomId;
         this.selectedRoomId = action.roomId;
         this.removeRecentRoom(prevSelectedRoomId);