Add search modal (#132)

Signed-off-by: Ajay Bura <ajbura@gmail.com>
This commit is contained in:
Ajay Bura 2021-12-10 17:22:53 +05:30
parent 20443f8a4d
commit c9ec161ccc
10 changed files with 348 additions and 7 deletions

View file

@ -9,7 +9,7 @@ import Text from '../text/Text';
const IconButton = React.forwardRef(({
variant, size, type,
tooltip, tooltipPlacement, src, onClick,
tooltip, tooltipPlacement, src, onClick, tabIndex,
}, ref) => {
const btn = (
<button
@ -19,6 +19,7 @@ const IconButton = React.forwardRef(({
onClick={onClick}
// eslint-disable-next-line react/button-has-type
type={type}
tabIndex={tabIndex}
>
<RawIcon size={size} src={src} />
</button>
@ -41,6 +42,7 @@ IconButton.defaultProps = {
tooltip: null,
tooltipPlacement: 'top',
onClick: null,
tabIndex: 0,
};
IconButton.propTypes = {
@ -51,6 +53,7 @@ IconButton.propTypes = {
tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
src: PropTypes.string.isRequired,
onClick: PropTypes.func,
tabIndex: PropTypes.number,
};
export default IconButton;

View file

@ -41,7 +41,7 @@ RoomSelectorWrapper.propTypes = {
};
function RoomSelector({
name, roomId, imageSrc, iconSrc,
name, parentName, roomId, imageSrc, iconSrc,
isSelected, isUnread, notificationCount, isAlert,
options, onClick,
}) {
@ -58,7 +58,15 @@ function RoomSelector({
iconSrc={iconSrc}
size="extra-small"
/>
<Text variant="b1">{twemojify(name)}</Text>
<Text variant="b1">
{twemojify(name)}
{parentName && (
<span className="text text-b3">
{' — '}
{twemojify(parentName)}
</span>
)}
</Text>
{ isUnread && (
<NotificationBadge
alert={isAlert}
@ -73,6 +81,7 @@ function RoomSelector({
);
}
RoomSelector.defaultProps = {
parentName: null,
isSelected: false,
imageSrc: null,
iconSrc: null,
@ -80,6 +89,7 @@ RoomSelector.defaultProps = {
};
RoomSelector.propTypes = {
name: PropTypes.string.isRequired,
parentName: PropTypes.string,
roomId: PropTypes.string.isRequired,
imageSrc: PropTypes.string,
iconSrc: PropTypes.string,

View file

@ -6,7 +6,7 @@ import cons from '../../../client/state/cons';
import colorMXID from '../../../util/colorMXID';
import logout from '../../../client/action/logout';
import {
selectTab, openInviteList, openPublicRooms, openSettings,
selectTab, openInviteList, openSearch, openSettings,
} from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation';
import { abbreviateNumber } from '../../../util/common';
@ -17,7 +17,7 @@ import ContextMenu, { MenuItem, MenuHeader, MenuBorder } from '../../atoms/conte
import HomeIC from '../../../../public/res/ic/outlined/home.svg';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import PowerIC from '../../../../public/res/ic/outlined/power.svg';
@ -205,6 +205,11 @@ function SideBar() {
<div className="sidebar__sticky">
<div className="sidebar-divider" />
<div className="sticky-container">
<SidebarAvatar
onClick={() => openSearch()}
tooltip="Search"
iconSrc={SearchIC}
/>
{ totalInvites !== 0 && (
<SidebarAvatar
isUnread

View file

@ -2,12 +2,14 @@ import React from 'react';
import ReadReceipts from '../read-receipts/ReadReceipts';
import ProfileViewer from '../profile-viewer/ProfileViewer';
import Search from '../search/Search';
function Dialogs() {
return (
<>
<ReadReceipts />
<ProfileViewer />
<Search />
</>
);
}

View file

@ -0,0 +1,220 @@
import React, { useState, useEffect, useRef } from 'react';
import './Search.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import AsyncSearch from '../../../util/AsyncSearch';
import { selectRoom, selectTab } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import RawModal from '../../atoms/modal/RawModal';
import ScrollView from '../../atoms/scroll/ScrollView';
import Divider from '../../atoms/divider/Divider';
import RoomSelector from '../../molecules/room-selector/RoomSelector';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import HashIC from '../../../../public/res/ic/outlined/hash.svg';
import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
function useVisiblityToggle(setResult) {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleSearchOpen = (term) => {
setResult({
term,
chunk: [],
});
setIsOpen(true);
};
navigation.on(cons.events.navigation.SEARCH_OPENED, handleSearchOpen);
return () => {
navigation.removeListener(cons.events.navigation.SEARCH_OPENED, handleSearchOpen);
};
}, []);
useEffect(() => {
if (isOpen === false) {
setResult(undefined);
}
}, [isOpen]);
const requestClose = () => setIsOpen(false);
return [isOpen, requestClose];
}
function Search() {
const [result, setResult] = useState(null);
const [asyncSearch] = useState(new AsyncSearch());
const [isOpen, requestClose] = useVisiblityToggle(setResult);
const searchRef = useRef(null);
const mx = initMatrix.matrixClient;
const handleSearchResults = (chunk, term) => {
setResult({
term,
chunk,
});
};
const generateResults = (term) => {
const prefix = term.match(/^[#@*]/)?.[0];
const { roomIdToParents } = initMatrix.roomList;
const mapRoomIds = (roomIds, type) => roomIds.map((roomId) => {
const room = mx.getRoom(roomId);
const parentSet = roomIdToParents.get(roomId);
const parentNames = parentSet
? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
: undefined;
const parents = parentNames ? parentNames.join(', ') : null;
return ({
type,
name: room.name,
parents,
roomId,
room,
});
});
if (term.length === 1) {
const { roomList } = initMatrix;
const spaces = mapRoomIds([...roomList.spaces], 'space').reverse();
const rooms = mapRoomIds([...roomList.rooms], 'room').reverse();
const directs = mapRoomIds([...roomList.directs], 'direct').reverse();
if (prefix === '*') {
asyncSearch.setup(spaces, { keys: 'name', isContain: true, limit: 20 });
handleSearchResults(spaces, '*');
} else if (prefix === '#') {
asyncSearch.setup(rooms, { keys: 'name', isContain: true, limit: 20 });
handleSearchResults(rooms, '#');
} else if (prefix === '@') {
asyncSearch.setup(directs, { keys: 'name', isContain: true, limit: 20 });
handleSearchResults(directs, '@');
} else {
const dataList = spaces.concat(rooms, directs);
asyncSearch.setup(dataList, { keys: 'name', isContain: true, limit: 20 });
asyncSearch.search(term);
}
} else {
asyncSearch.search(prefix ? term.slice(1) : term);
}
};
const handleAfterOpen = () => {
searchRef.current.focus();
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchResults);
if (typeof result.term === 'string') {
generateResults(result.term);
searchRef.current.value = result.term;
}
};
const handleAfterClose = () => {
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchResults);
};
const handleOnChange = () => {
const { value } = searchRef.current;
generateResults(value);
};
const handleCross = (e) => {
e.preventDefault();
const { value } = searchRef.current;
if (value.length === 0) requestClose();
else {
searchRef.current.value = '';
searchRef.current.focus();
}
};
const openItem = (roomId, type) => {
if (type === 'space') selectTab(roomId);
else selectRoom(roomId);
requestClose();
};
const openFirstResult = () => {
const { chunk } = result;
if (chunk?.length > 0) {
const item = chunk[0];
openItem(item.roomId, item.type);
}
};
const notifs = initMatrix.notifications;
const renderRoomSelector = (item) => {
const isPrivate = item.room.getJoinRule() === 'invite';
let imageSrc = null;
let iconSrc = null;
if (item.type === 'room') iconSrc = isPrivate ? HashLockIC : HashIC;
if (item.type === 'space') iconSrc = isPrivate ? SpaceLockIC : SpaceIC;
if (item.type === 'direct') imageSrc = item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
const isUnread = notifs.hasNoti(item.roomId);
const noti = notifs.getNoti(item.roomId);
return (
<RoomSelector
key={item.roomId}
name={item.name}
parentName={item.parents}
roomId={item.roomId}
imageSrc={imageSrc}
iconSrc={iconSrc}
isUnread={isUnread}
notificationCount={noti.total}
isAlert={noti.total > 0}
onClick={() => openItem(item.roomId, item.type)}
/>
);
};
return (
<RawModal
className="search-dialog__model dialog-model"
isOpen={isOpen}
onAfterOpen={handleAfterOpen}
onAfterClose={handleAfterClose}
onRequestClose={requestClose}
size="small"
>
<div className="search-dialog">
<form className="search-dialog__input" onSubmit={(e) => { e.preventDefault(); openFirstResult()}}>
<RawIcon src={SearchIC} size="small" />
<Input
onChange={handleOnChange}
forwardRef={searchRef}
placeholder="Search"
/>
<IconButton size="small" src={CrossIC} type="reset" onClick={handleCross} tabIndex={-1} />
</form>
<div className="search-dialog__content-wrapper">
<ScrollView autoHide>
<div className="search-dialog__content">
{ Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector) }
</div>
</ScrollView>
</div>
<div className="search-dialog__footer">
<Text variant="b3">Type # for rooms, @ for DMs and * for spaces. Hotkey: Ctrl + k</Text>
</div>
</div>
</RawModal>
);
}
export default Search;

View file

@ -0,0 +1,85 @@
.search-dialog__model {
--modal-height: 380px;
height: 100%;
background-color: var(--bg-surface);
}
.search-dialog {
display: flex;
flex-direction: column;
height: 100%;
&__input {
padding: var(--sp-normal);
display: flex;
align-items: center;
position: relative;
& > .ic-raw {
position: absolute;
left: calc(var(--sp-normal) + var(--sp-tight));
[dir=rtl] & {
left: unset;
right: calc(var(--sp-normal) + var(--sp-tight));
}
}
& > .ic-btn {
border-radius: calc(var(--bo-radius) / 2);
position: absolute;
right: calc(var(--sp-normal) + var(--sp-extra-tight));
[dir=rtl] & {
right: unset;
left: calc(var(--sp-normal) + var(--sp-extra-tight));
}
}
& .input-container {
min-width: 0;
flex: 1;
}
& input {
padding-left: 40px;
padding-right: 40px;
font-size: var(--fs-s1);
letter-spacing: var(--ls-s1);
line-height: var(--lh-s1);
color: var(--tc-surface-high);
}
}
&__content-wrapper {
min-height: 0;
flex: 1;
position: relative;
&::before,
&::after {
position: absolute;
top: 0;
content: "";
display: inline-block;
width: 100%;
height: 8px;
background-image: linear-gradient(to bottom, var(--bg-surface), var(--bg-surface-transparent));
}
&::after {
top: unset;
bottom: 0;
background-image: linear-gradient(to bottom, var(--bg-surface-transparent), var(--bg-surface));
}
}
&__content {
padding: var(--sp-extra-tight) var(--sp-normal);
padding-right: var(--sp-extra-tight);
[dir=rtl] & {
padding-left: var(--sp-extra-tight);
padding-right: var(--sp-normal);
}
}
&__footer {
padding: var(--sp-tight) var(--sp-normal);
text-align: center;
}
}

View file

@ -97,6 +97,13 @@ function replyTo(userId, eventId, body) {
});
}
function openSearch(term) {
appDispatcher.dispatch({
type: cons.actions.navigation.OPEN_SEARCH,
term,
});
}
export {
selectTab,
selectSpace,
@ -111,4 +118,5 @@ export {
openReadReceipts,
openRoomOptions,
replyTo,
openSearch,
};

View file

@ -41,6 +41,7 @@ const cons = {
OPEN_READRECEIPTS: 'OPEN_READRECEIPTS',
OPEN_ROOMOPTIONS: 'OPEN_ROOMOPTIONS',
CLICK_REPLY_TO: 'CLICK_REPLY_TO',
OPEN_SEARCH: 'OPEN_SEARCH',
},
room: {
JOIN: 'JOIN',
@ -73,6 +74,7 @@ const cons = {
READRECEIPTS_OPENED: 'READRECEIPTS_OPENED',
ROOMOPTIONS_OPENED: 'ROOMOPTIONS_OPENED',
REPLY_TO_CLICKED: 'REPLY_TO_CLICKED',
SEARCH_OPENED: 'SEARCH_OPENED',
},
roomList: {
ROOMLIST_UPDATED: 'ROOMLIST_UPDATED',

View file

@ -103,6 +103,12 @@ class Navigation extends EventEmitter {
action.body,
);
},
[cons.actions.navigation.OPEN_SEARCH]: () => {
this.emit(
cons.events.navigation.SEARCH_OPENED,
action.term,
);
},
};
actions[action.type]?.();
}

View file

@ -56,7 +56,7 @@ class AsyncSearch extends EventEmitter {
this._softReset();
this.term = (this.isCaseSensitive) ? term : term.toLocaleLowerCase();
if (this.ignoreWhitespace) this.term = this.term.replace(' ', '');
if (this.ignoreWhitespace) this.term = this.term.replaceAll(' ', '');
if (this.term === '') {
this._sendFindings();
return;
@ -114,7 +114,7 @@ class AsyncSearch extends EventEmitter {
_compare(item) {
if (typeof item !== 'string') return false;
let myItem = (this.isCaseSensitive) ? item : item.toLocaleLowerCase();
if (this.ignoreWhitespace) myItem = myItem.replace(' ', '');
if (this.ignoreWhitespace) myItem = myItem.replaceAll(' ', '');
if (this.isContain) return myItem.indexOf(this.term) !== -1;
return myItem.startsWith(this.term);