diff --git a/web/app.react.js b/web/app.react.js index 04dc34e3e..58db901db 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,373 +1,376 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classnames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { WagmiConfig } from 'wagmi'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions.js'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react.js'; import { webAndKeyserverCodeVersion } from 'lib/facts/version.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { registerConfig } from 'lib/utils/config.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { WagmiENSCacheProvider, wagmiClient } from 'lib/utils/wagmi-utils.js'; import WebEditUserAvatarProvider from './avatars/web-edit-user-avatar-provider.react.js'; import Calendar from './calendar/calendar.react.js'; import Chat from './chat/chat.react.js'; import { EditModalProvider } from './chat/edit-message-provider.js'; import { TooltipProvider } from './chat/tooltip-provider.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import { initOpaque } from './crypto/opaque-utils.js'; import electron from './electron.js'; import InputStateContainer from './input/input-state-container.react.js'; import InviteLinkHandler from './invite-links/invite-link-handler.react.js'; import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { MenuProvider } from './menu-provider.react.js'; import UpdateModalHandler from './modals/update-modal.react.js'; import SettingsSwitcher from './navigation-panels/settings-switcher.react.js'; import Topbar from './navigation-panels/topbar.react.js'; import useBadgeHandler from './push-notif/badge-handler.react.js'; import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js'; import { updateNavInfoActionType } from './redux/action-types.js'; import DeviceIDUpdater from './redux/device-id-updater.js'; import DisconnectedBarVisibilityHandler from './redux/disconnected-bar-visibility-handler.js'; import DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { persistConfig } from './redux/persist.js'; import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js'; import { useSelector } from './redux/redux-utils.js'; import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; +import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import CommunityPicker from './sidebar/community-picker.react.js'; import Splash from './splash/splash.react.js'; import './typography.css'; import css from './style.css'; import { type NavInfo } from './types/nav-types.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; initOpaque(); // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, // We use httponly cookies on web to protect against XSS attacks, so we have // no access to the cookies from JavaScript setCookieOnRequest: false, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: webAndKeyserverCodeVersion, stateVersion: persistConfig.version, }, }); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: NavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const urlInfo = infoFromURL(pathname); const newNavInfo = navInfoFromURL(urlInfo, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render() { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.props.modals} ); } return ( - - - - - - - - {content} + + + + + + + + + {content} + ); } onHeaderDoubleClick = () => electron?.doubleClickTopBar(); stopDoubleClickPropagation = electron ? e => e.stopPropagation() : null; renderMainContent() { const mainContent = this.getMainContentWithSwitcher(); let navigationArrows = null; if (electron) { navigationArrows = ; } const headerClasses = classnames({ [css.header]: true, [css['electron-draggable']]: electron, }); const wordmarkClasses = classnames({ [css.wordmark]: true, [css['electron-non-draggable']]: electron, [css['wordmark-macos']]: electron?.platform === 'macos', }); return (

Comm

{navigationArrows}
{mainContent}
); } getMainContentWithSwitcher() { const { tab, settingsSection } = this.props.navInfo; let mainContent; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'danger-zone') { mainContent = ; } return (
{mainContent}
); } if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } const mainContentClass = classnames( css['main-content-container'], css['main-content-container-column'], ); return (
{mainContent}
); } } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); useBadgeHandler(); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider; diff --git a/web/modals/search/message-search-modal.css b/web/modals/search/message-search-modal.css index 42dbf4202..e266b5e7a 100644 --- a/web/modals/search/message-search-modal.css +++ b/web/modals/search/message-search-modal.css @@ -1,22 +1,39 @@ +.container { + display: flex; + flex-direction: column; + overflow: hidden; +} + .content { overflow-y: scroll; - padding: 0 10px; + border-top: 2px solid var(--border-color); + padding: 16px 26px; } .content > * { margin-bottom: 16px; } .loading { text-align: center; margin-bottom: 8px; margin-top: 24px; } .footer { color: var(--modal-secondary-label); size: 14; font-weight: 400; text-align: center; margin: 20px; } + +.header { + display: flex; + flex-direction: row; + margin: 16px; +} + +.button { + margin: 10px 10px 10px 0; +} diff --git a/web/modals/search/message-search-modal.react.js b/web/modals/search/message-search-modal.react.js index d47b7c20e..b9622b405 100644 --- a/web/modals/search/message-search-modal.react.js +++ b/web/modals/search/message-search-modal.react.js @@ -1,228 +1,255 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { type ChatMessageItem, messageListData, } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo, modifyItemForResultScreen, } from 'lib/shared/message-utils.js'; import { useSearchMessages } from 'lib/shared/search-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './message-search-modal.css'; import { useTooltipContext } from '../../chat/tooltip-provider.js'; +import Button from '../../components/button.react.js'; import MessageResult from '../../components/message-result.react.js'; +import Search from '../../components/search.react.js'; import LoadingIndicator from '../../loading-indicator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; -import SearchModal from '../search-modal.react.js'; +import { useMessageSearchContext } from '../../search/message-search-state-provider.react.js'; +import Modal from '../modal.react.js'; type ContentProps = { +query: string, +threadInfo: ThreadInfo, }; function MessageSearchModalContent(props: ContentProps): React.Node { const { query, threadInfo } = props; const [lastID, setLastID] = React.useState(); const [searchResults, setSearchResults] = React.useState([]); const [endReached, setEndReached] = React.useState(false); const appendSearchResults = React.useCallback( (newMessages: $ReadOnlyArray, end: boolean) => { setSearchResults(oldMessages => [...oldMessages, ...newMessages]); setEndReached(end); }, [], ); React.useEffect(() => { setSearchResults([]); setLastID(undefined); setEndReached(false); }, [query]); const searchMessages = useSearchMessages(); React.useEffect( () => searchMessages(query, threadInfo.id, appendSearchResults, lastID), [appendSearchResults, lastID, query, searchMessages, threadInfo.id], ); const userInfos = useSelector(state => state.userStore.userInfos); const translatedSearchResults = React.useMemo(() => { const threadInfos = { [threadInfo.id]: threadInfo }; return searchResults .map(rawMessageInfo => createMessageInfo(rawMessageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [searchResults, threadInfo, userInfos]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedSearchResults), ); const filteredChatMessageInfos = React.useMemo(() => { if (!chatMessageInfos) { return []; } const idSet = new Set(translatedSearchResults.map(item => item.id)); const chatMessageInfoItems = chatMessageInfos.filter( item => item.messageInfo && idSet.has(item.messageInfo.id), ); const uniqueChatMessageInfoItemsMap = new Map(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); const sortedChatMessageInfoItems = []; for (let i = 0; i < translatedSearchResults.length; i++) { sortedChatMessageInfoItems.push( uniqueChatMessageInfoItemsMap.get(translatedSearchResults[i].id), ); } return sortedChatMessageInfoItems.filter(Boolean); }, [chatMessageInfos, translatedSearchResults]); const modifiedItems = React.useMemo( () => filteredChatMessageInfos.map(item => modifyItemForResultScreen(item)), [filteredChatMessageInfos], ); const renderItem = React.useCallback( item => ( ), [threadInfo], ); const messages = React.useMemo( () => modifiedItems.map(item => renderItem(item)), [modifiedItems, renderItem], ); const messageContainer = React.useRef(null); const messageContainerRef = (msgContainer: ?HTMLDivElement) => { messageContainer.current = msgContainer; messageContainer.current?.addEventListener('scroll', onScroll); }; const { clearTooltip } = useTooltipContext(); const possiblyLoadMoreMessages = React.useCallback(() => { if (!messageContainer.current) { return; } const loaderTopOffset = 32; const { scrollTop, scrollHeight, clientHeight } = messageContainer.current; if ( endReached || Math.abs(scrollTop) + clientHeight + loaderTopOffset < scrollHeight ) { return; } setLastID(modifiedItems ? oldestMessageID(modifiedItems) : undefined); }, [endReached, modifiedItems]); const onScroll = React.useCallback(() => { if (!messageContainer.current) { return; } clearTooltip(); possiblyLoadMoreMessages(); }, [clearTooltip, possiblyLoadMoreMessages]); const footer = React.useMemo(() => { if (query === '') { return (
Your search results will appear here
); } if (!endReached) { return (
); } if (modifiedItems.length > 0) { return
End of results
; } return (
No results. Please try using different keywords to refine your search
); }, [query, endReached, modifiedItems.length]); return (
{messages} {footer}
); } function oldestMessageID(data: $ReadOnlyArray) { for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return undefined; } type Props = { +threadInfo: ThreadInfo, }; function MessageSearchModal(props: Props): React.Node { const { threadInfo } = props; const { popModal } = useModalContext(); - const renderModalContent = React.useCallback( - (searchText: string) => ( - - ), - [threadInfo], + const { getQuery, setQuery, clearQuery } = useMessageSearchContext(); + + const query = React.useMemo( + () => getQuery(threadInfo.id), + [getQuery, threadInfo.id], + ); + + const [input, setInput] = React.useState(query); + + const onPressSearch = React.useCallback( + () => setQuery(input, threadInfo.id), + [setQuery, input, threadInfo.id], ); const { uiName } = useResolvedThreadInfo(threadInfo); const searchPlaceholder = `Searching in ${uiName}`; + const clearQueryWrapper = React.useCallback( + () => clearQuery(threadInfo.id), + [clearQuery, threadInfo.id], + ); + return ( - - {renderModalContent} - + +
+
+ + +
+ +
+
); } export default MessageSearchModal;