parent
20443f8a4d
commit
c9ec161ccc
10 changed files with 348 additions and 7 deletions
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
220
src/app/organisms/search/Search.jsx
Normal file
220
src/app/organisms/search/Search.jsx
Normal 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;
|
85
src/app/organisms/search/Search.scss
Normal file
85
src/app/organisms/search/Search.scss
Normal 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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]?.();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue