diff --git a/src/app/atoms/button/IconButton.jsx b/src/app/atoms/button/IconButton.jsx index c81c3b10..67b8a653 100644 --- a/src/app/atoms/button/IconButton.jsx +++ b/src/app/atoms/button/IconButton.jsx @@ -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 = ( @@ -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; diff --git a/src/app/molecules/room-selector/RoomSelector.jsx b/src/app/molecules/room-selector/RoomSelector.jsx index d5bb1c30..4966d832 100644 --- a/src/app/molecules/room-selector/RoomSelector.jsx +++ b/src/app/molecules/room-selector/RoomSelector.jsx @@ -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" /> - {twemojify(name)} + + {twemojify(name)} + {parentName && ( + + {' — '} + {twemojify(parentName)} + + )} + { isUnread && (
+ openSearch()} + tooltip="Search" + iconSrc={SearchIC} + /> { totalInvites !== 0 && ( + ); } diff --git a/src/app/organisms/search/Search.jsx b/src/app/organisms/search/Search.jsx new file mode 100644 index 00000000..80ddce82 --- /dev/null +++ b/src/app/organisms/search/Search.jsx @@ -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 ( + 0} + onClick={() => openItem(item.roomId, item.type)} + /> + ); + }; + + return ( + +
+
{ e.preventDefault(); openFirstResult()}}> + + + + +
+ +
+ { Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector) } +
+
+
+
+ Type # for rooms, @ for DMs and * for spaces. Hotkey: Ctrl + k +
+
+
+ ); +} + +export default Search; diff --git a/src/app/organisms/search/Search.scss b/src/app/organisms/search/Search.scss new file mode 100644 index 00000000..61da28b4 --- /dev/null +++ b/src/app/organisms/search/Search.scss @@ -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; + } + +} \ No newline at end of file diff --git a/src/client/action/navigation.js b/src/client/action/navigation.js index ec850445..a14e771d 100644 --- a/src/client/action/navigation.js +++ b/src/client/action/navigation.js @@ -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, }; diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 869a4765..df8b5fea 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -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', diff --git a/src/client/state/navigation.js b/src/client/state/navigation.js index 4f69fd6a..ea84dcad 100644 --- a/src/client/state/navigation.js +++ b/src/client/state/navigation.js @@ -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]?.(); } diff --git a/src/util/AsyncSearch.js b/src/util/AsyncSearch.js index b90ae15d..82123c78 100644 --- a/src/util/AsyncSearch.js +++ b/src/util/AsyncSearch.js @@ -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);