diff --git a/web/chat/chat-thread-list-item-menu.react.js b/web/chat/chat-thread-list-item-menu.react.js index 4d015b193..9902eae30 100644 --- a/web/chat/chat-thread-list-item-menu.react.js +++ b/web/chat/chat-thread-list-item-menu.react.js @@ -1,76 +1,76 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import useToggleUnreadStatus from 'lib/hooks/toggle-unread-status'; import type { ThreadInfo } from 'lib/types/thread-types'; import Button from '../components/button.react'; -import { useThreadIsActive } from '../selectors/nav-selectors'; +import { useThreadIsActive } from '../selectors/thread-selectors'; import SWMansionIcon from '../SWMansionIcon.react'; import css from './chat-thread-list-item-menu.css'; type Props = { +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, +renderStyle?: 'chat' | 'thread', }; function ChatThreadListItemMenu(props: Props): React.Node { const { renderStyle = 'chat', threadInfo, mostRecentNonLocalMessage } = props; const active = useThreadIsActive(threadInfo.id); const [menuVisible, setMenuVisible] = React.useState(false); const toggleMenu = React.useCallback( event => { event.stopPropagation(); setMenuVisible(!menuVisible); }, [menuVisible], ); const hideMenu = React.useCallback(() => { setMenuVisible(false); }, []); const toggleUnreadStatus = useToggleUnreadStatus( threadInfo, mostRecentNonLocalMessage, hideMenu, ); const onToggleUnreadStatusClicked = React.useCallback( event => { event.stopPropagation(); toggleUnreadStatus(); }, [toggleUnreadStatus], ); const toggleUnreadStatusButtonText = `Mark as ${ threadInfo.currentUser.unread ? 'read' : 'unread' }`; const menuIconSize = renderStyle === 'chat' ? 24 : 20; const menuCls = classNames(css.menu, { [css.menuSidebar]: renderStyle === 'thread', }); const btnCls = classNames(css.menuContent, { [css.menuContentVisible]: menuVisible, [css.active]: active, }); return (
); } export default ChatThreadListItemMenu; diff --git a/web/chat/chat-thread-list-item.react.js b/web/chat/chat-thread-list-item.react.js index ebdbcdba7..2b878b41c 100644 --- a/web/chat/chat-thread-list-item.react.js +++ b/web/chat/chat-thread-list-item.react.js @@ -1,179 +1,179 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors'; import { useAncestorThreads } from 'lib/shared/ancestor-threads'; import { shortAbsoluteDate } from 'lib/utils/date-utils'; import { useSelector } from '../redux/redux-utils'; import { useOnClickThread, useThreadIsActive, -} from '../selectors/nav-selectors'; +} from '../selectors/thread-selectors'; import SWMansionIcon from '../SWMansionIcon.react'; import ChatThreadListItemMenu from './chat-thread-list-item-menu.react'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react'; import css from './chat-thread-list.css'; import MessagePreview from './message-preview.react'; type Props = { +item: ChatThreadItem, }; function ChatThreadListItem(props: Props): React.Node { const { item } = props; const { threadInfo, lastUpdatedTimeIncludingSidebars, mostRecentNonLocalMessage, mostRecentMessageInfo, } = item; const { id: threadID, currentUser } = threadInfo; const ancestorThreads = useAncestorThreads(threadInfo); const timeZone = useSelector(state => state.timeZone); const lastActivity = shortAbsoluteDate( lastUpdatedTimeIncludingSidebars, timeZone, ); const active = useThreadIsActive(threadID); const isCreateMode = useSelector( state => state.navInfo.chatMode === 'create', ); const onClick = useOnClickThread(item.threadInfo); const selectItemIfNotActiveCreation = React.useCallback( (event: SyntheticEvent) => { if (!isCreateMode || !active) { onClick(event); } }, [isCreateMode, active, onClick], ); const containerClassName = React.useMemo( () => classNames({ [css.thread]: true, [css.activeThread]: active, }), [active], ); const { unread } = currentUser; const titleClassName = React.useMemo( () => classNames({ [css.title]: true, [css.unread]: unread, }), [unread], ); const lastActivityClassName = React.useMemo( () => classNames({ [css.lastActivity]: true, [css.unread]: unread, [css.dark]: !unread, }), [unread], ); const breadCrumbsClassName = React.useMemo( () => classNames(css.breadCrumbs, { [css.unread]: unread, }), [unread], ); let unreadDot; if (unread) { unreadDot =
; } const { color } = item.threadInfo; const colorSplotchStyle = React.useMemo( () => ({ backgroundColor: `#${color}` }), [color], ); const sidebars = item.sidebars.map((sidebarItem, index) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( 0} key={sidebarInfo.threadInfo.id} /> ); } else if (sidebarItem.type === 'seeMore') { return ( ); } else { return
; } }); const ancestorPath = ancestorThreads.map((thread, idx) => { const isNotLast = idx !== ancestorThreads.length - 1; const chevron = isNotLast && ( ); return ( {thread.uiName} {chevron} ); }); return ( <>
{unreadDot}

{ancestorPath}

{threadInfo.uiName}
{lastActivity}
{sidebars} ); } export default ChatThreadListItem; diff --git a/web/chat/chat-thread-list-sidebar.react.js b/web/chat/chat-thread-list-sidebar.react.js index 9722b0696..91e2e92c3 100644 --- a/web/chat/chat-thread-list-sidebar.react.js +++ b/web/chat/chat-thread-list-sidebar.react.js @@ -1,53 +1,53 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import type { SidebarInfo } from 'lib/types/thread-types'; import { useOnClickThread, useThreadIsActive, -} from '../selectors/nav-selectors'; +} from '../selectors/thread-selectors'; import ChatThreadListItemMenu from './chat-thread-list-item-menu.react'; import css from './chat-thread-list.css'; import SidebarItem from './sidebar-item.react'; type Props = { +sidebarInfo: SidebarInfo, +isSubsequentItem: boolean, }; function ChatThreadListSidebar(props: Props): React.Node { const { sidebarInfo, isSubsequentItem } = props; const { threadInfo, mostRecentNonLocalMessage } = sidebarInfo; const { currentUser: { unread }, id: threadID, } = threadInfo; const active = useThreadIsActive(threadID); const onClick = useOnClickThread(threadInfo); let unreadDot; if (unread) { unreadDot =
; } return (
{unreadDot}
); } export default ChatThreadListSidebar; diff --git a/web/chat/chat-thread-list.react.js b/web/chat/chat-thread-list.react.js index 0dd48b0d5..4aba2bda2 100644 --- a/web/chat/chat-thread-list.react.js +++ b/web/chat/chat-thread-list.react.js @@ -1,80 +1,80 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { emptyItemText } from 'lib/shared/thread-utils'; import BackgroundIllustration from '../assets/background-illustration.react'; import Button from '../components/button.react'; import Search from '../components/search.react'; import { useSelector } from '../redux/redux-utils'; -import { useOnClickNewThread } from '../selectors/nav-selectors'; +import { useOnClickNewThread } from '../selectors/thread-selectors'; import ChatThreadListItem from './chat-thread-list-item.react'; import css from './chat-thread-list.css'; import { ThreadListContext } from './thread-list-provider'; function ChatThreadList(): React.Node { const threadListContext = React.useContext(ThreadListContext); invariant( threadListContext, 'threadListContext should be set in ChatThreadList', ); const { activeTab, threadList, setSearchText, searchText, } = threadListContext; const onClickNewThread = useOnClickNewThread(); const isThreadCreation = useSelector( state => state.navInfo.chatMode === 'create', ); const isBackground = activeTab === 'Background'; const threadComponents: React.Node[] = React.useMemo(() => { const threads = threadList.map(item => ( )); if (threads.length === 0 && isBackground) { threads.push(); } return threads; }, [threadList, isBackground]); return ( <>
{threadComponents}
); } function EmptyItem() { return (
{emptyItemText}
); } export default ChatThreadList; diff --git a/web/chat/inline-sidebar.react.js b/web/chat/inline-sidebar.react.js index c3b84ab7a..71cd3172a 100644 --- a/web/chat/inline-sidebar.react.js +++ b/web/chat/inline-sidebar.react.js @@ -1,75 +1,75 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import useInlineSidebarText from 'lib/hooks/inline-sidebar-text.react'; import type { ThreadInfo } from 'lib/types/thread-types'; import CommIcon from '../CommIcon.react'; -import { useOnClickThread } from '../selectors/nav-selectors'; +import { useOnClickThread } from '../selectors/thread-selectors'; import css from './inline-sidebar.css'; type Props = { +threadInfo: ?ThreadInfo, +reactions?: $ReadOnlyArray, +positioning: 'left' | 'center' | 'right', }; function InlineSidebar(props: Props): React.Node { const { threadInfo, positioning, reactions } = props; const inlineSidebarText = useInlineSidebarText(threadInfo); const containerClasses = classNames([ css.inlineSidebarContainer, { [css.leftContainer]: positioning === 'left', [css.centerContainer]: positioning === 'center', [css.rightContainer]: positioning === 'right', }, ]); const reactionsList = React.useMemo(() => { if (!reactions || reactions.length === 0) { return null; } const reactionsItems = reactions.map(reaction => { return (
{reaction}
); }); return
{reactionsItems}
; }, [reactions]); const onClick = useOnClickThread(threadInfo); const threadInfoExists = !!threadInfo; const sidebarItem = React.useMemo(() => { if (!threadInfoExists || !inlineSidebarText) { return null; } return (
{inlineSidebarText.repliesText}
); }, [threadInfoExists, inlineSidebarText]); return ( ); } export default InlineSidebar; diff --git a/web/chat/sidebar-item.react.js b/web/chat/sidebar-item.react.js index 6d89ab6b2..51e6cfc1e 100644 --- a/web/chat/sidebar-item.react.js +++ b/web/chat/sidebar-item.react.js @@ -1,55 +1,55 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import type { SidebarInfo } from 'lib/types/thread-types'; -import { useOnClickThread } from '../selectors/nav-selectors'; +import { useOnClickThread } from '../selectors/thread-selectors'; import css from './chat-thread-list.css'; type Props = { +sidebarInfo: SidebarInfo, +extendArrow?: boolean, }; function SidebarItem(props: Props): React.Node { const { sidebarInfo: { threadInfo }, extendArrow = false, } = props; const { currentUser: { unread }, } = threadInfo; const onClick = useOnClickThread(threadInfo); const unreadCls = classNames(css.sidebarTitle, { [css.unread]: unread }); let arrow; if (extendArrow) { arrow = ( thread arrow ); } else { arrow = ( thread arrow ); } return ( <> {arrow}
{threadInfo.uiName}
); } export default SidebarItem; diff --git a/web/modals/threads/sidebars/sidebar.react.js b/web/modals/threads/sidebars/sidebar.react.js index bcaaf18fd..b4b5e9e57 100644 --- a/web/modals/threads/sidebars/sidebar.react.js +++ b/web/modals/threads/sidebars/sidebar.react.js @@ -1,80 +1,80 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors'; import { getMessagePreview } from 'lib/shared/message-utils'; import { shortAbsoluteDate } from 'lib/utils/date-utils'; import Button from '../../../components/button.react'; import { getDefaultTextMessageRules } from '../../../markdown/rules.react'; import { useSelector } from '../../../redux/redux-utils'; -import { useOnClickThread } from '../../../selectors/nav-selectors'; +import { useOnClickThread } from '../../../selectors/thread-selectors'; import css from './sidebars-modal.css'; type Props = { +sidebar: ChatThreadItem, +isLastItem?: boolean, }; function Sidebar(props: Props): React.Node { const { sidebar, isLastItem } = props; const { threadInfo, lastUpdatedTime, mostRecentMessageInfo } = sidebar; const timeZone = useSelector(state => state.timeZone); const { popModal } = useModalContext(); const navigateToThread = useOnClickThread(threadInfo); const onClickThread = React.useCallback( event => { popModal(); navigateToThread(event); }, [popModal, navigateToThread], ); const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTime, timeZone), [lastUpdatedTime, timeZone], ); const lastMessage = React.useMemo(() => { if (!mostRecentMessageInfo) { return
No messages
; } const { message, username } = getMessagePreview( mostRecentMessageInfo, threadInfo, getDefaultTextMessageRules().simpleMarkdownRules, ); const previewText = username ? `${username}: ${message}` : message; return ( <>
{previewText}
{lastActivity}
); }, [lastActivity, mostRecentMessageInfo, threadInfo]); return ( ); } export default Sidebar; diff --git a/web/modals/threads/subchannels/subchannel.react.js b/web/modals/threads/subchannels/subchannel.react.js index 16ad07fce..f20969fc3 100644 --- a/web/modals/threads/subchannels/subchannel.react.js +++ b/web/modals/threads/subchannels/subchannel.react.js @@ -1,76 +1,76 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react'; import { type ChatThreadItem } from 'lib/selectors/chat-selectors'; import { getMessagePreview } from 'lib/shared/message-utils'; import { shortAbsoluteDate } from 'lib/utils/date-utils'; import Button from '../../../components/button.react'; import { getDefaultTextMessageRules } from '../../../markdown/rules.react'; import { useSelector } from '../../../redux/redux-utils'; -import { useOnClickThread } from '../../../selectors/nav-selectors'; +import { useOnClickThread } from '../../../selectors/thread-selectors'; import SWMansionIcon from '../../../SWMansionIcon.react'; import css from './subchannels-modal.css'; type Props = { +chatThreadItem: ChatThreadItem, }; function Subchannel(props: Props): React.Node { const { chatThreadItem } = props; const { threadInfo, mostRecentMessageInfo, lastUpdatedTimeIncludingSidebars, } = chatThreadItem; const timeZone = useSelector(state => state.timeZone); const { popModal } = useModalContext(); const navigateToThread = useOnClickThread(threadInfo); const onClickThread = React.useCallback( event => { popModal(); navigateToThread(event); }, [popModal, navigateToThread], ); const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTimeIncludingSidebars, timeZone), [lastUpdatedTimeIncludingSidebars, timeZone], ); const lastMessage = React.useMemo(() => { if (!mostRecentMessageInfo) { return
No messages
; } const { message, username } = getMessagePreview( mostRecentMessageInfo, threadInfo, getDefaultTextMessageRules().simpleMarkdownRules, ); const previewText = username ? `${username}: ${message}` : message; return ( <>
{previewText}
{lastActivity}
); }, [lastActivity, mostRecentMessageInfo, threadInfo]); return ( ); } export default Subchannel; diff --git a/web/selectors/nav-selectors.js b/web/selectors/nav-selectors.js index 01f1aa98b..2b2000052 100644 --- a/web/selectors/nav-selectors.js +++ b/web/selectors/nav-selectors.js @@ -1,239 +1,140 @@ // @flow import invariant from 'invariant'; -import * as React from 'react'; -import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { nonThreadCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors'; import { currentCalendarQuery } from 'lib/selectors/nav-selectors'; -import { createPendingSidebar } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; -import type { - ComposableMessageInfo, - RobotextMessageInfo, -} from 'lib/types/message-types'; -import type { ThreadInfo } from 'lib/types/thread-types'; - -import { getDefaultTextMessageRules } from '../markdown/rules.react'; -import { updateNavInfoActionType } from '../redux/action-types'; + import type { AppState } from '../redux/redux-setup'; -import { useSelector } from '../redux/redux-utils'; import { type NavigationTab, type NavigationSettingsSection, } from '../types/nav-types'; const dateExtractionRegex = /^([0-9]{4})-([0-9]{2})-[0-9]{2}$/; function yearExtractor(startDate: string, endDate: string): ?number { const startDateResults = dateExtractionRegex.exec(startDate); const endDateResults = dateExtractionRegex.exec(endDate); if ( !startDateResults || !startDateResults[1] || !endDateResults || !endDateResults[1] || startDateResults[1] !== endDateResults[1] ) { return null; } return parseInt(startDateResults[1], 10); } function yearAssertingExtractor(startDate: string, endDate: string): number { const result = yearExtractor(startDate, endDate); invariant( result !== null && result !== undefined, `${startDate} and ${endDate} aren't in the same year`, ); return result; } const yearAssertingSelector: (state: AppState) => number = createSelector( (state: AppState) => state.navInfo.startDate, (state: AppState) => state.navInfo.endDate, yearAssertingExtractor, ); // 1-indexed function monthExtractor(startDate: string, endDate: string): ?number { const startDateResults = dateExtractionRegex.exec(startDate); const endDateResults = dateExtractionRegex.exec(endDate); if ( !startDateResults || !startDateResults[1] || !startDateResults[2] || !endDateResults || !endDateResults[1] || !endDateResults[2] || startDateResults[1] !== endDateResults[1] || startDateResults[2] !== endDateResults[2] ) { return null; } return parseInt(startDateResults[2], 10); } // 1-indexed function monthAssertingExtractor(startDate: string, endDate: string): number { const result = monthExtractor(startDate, endDate); invariant( result !== null && result !== undefined, `${startDate} and ${endDate} aren't in the same month`, ); return result; } // 1-indexed const monthAssertingSelector: (state: AppState) => number = createSelector( (state: AppState) => state.navInfo.startDate, (state: AppState) => state.navInfo.endDate, monthAssertingExtractor, ); function activeThreadSelector(state: AppState): ?string { return state.navInfo.tab === 'chat' ? state.navInfo.activeChatThreadID : null; } const webCalendarQuery: ( state: AppState, ) => () => CalendarQuery = createSelector( currentCalendarQuery, (state: AppState) => state.navInfo.tab === 'calendar', ( calendarQuery: (calendarActive: boolean) => CalendarQuery, calendarActive: boolean, ) => () => calendarQuery(calendarActive), ); const nonThreadCalendarQuery: ( state: AppState, ) => () => CalendarQuery = createSelector( webCalendarQuery, nonThreadCalendarFiltersSelector, ( calendarQuery: () => CalendarQuery, filters: $ReadOnlyArray, ) => { return (): CalendarQuery => { const query = calendarQuery(); return { startDate: query.startDate, endDate: query.endDate, filters, }; }; }, ); -function useOnClickThread( - thread: ?ThreadInfo, -): (event: SyntheticEvent) => void { - const dispatch = useDispatch(); - return React.useCallback( - (event: SyntheticEvent) => { - invariant( - thread?.id, - 'useOnClickThread should be called with threadID set', - ); - event.preventDefault(); - const { id: threadID } = thread; - - let payload; - if (threadID.includes('pending')) { - payload = { - chatMode: 'view', - activeChatThreadID: threadID, - pendingThread: thread, - }; - } else { - payload = { - chatMode: 'view', - activeChatThreadID: threadID, - }; - } - - dispatch({ type: updateNavInfoActionType, payload }); - }, - [dispatch, thread], - ); -} - -function useThreadIsActive(threadID: string): boolean { - return useSelector(state => threadID === state.navInfo.activeChatThreadID); -} - -function useOnClickPendingSidebar( - messageInfo: ComposableMessageInfo | RobotextMessageInfo, - threadInfo: ThreadInfo, -): (event: SyntheticEvent) => void { - const dispatch = useDispatch(); - const viewerID = useSelector(state => state.currentUserInfo?.id); - return React.useCallback( - (event: SyntheticEvent) => { - event.preventDefault(); - if (!viewerID) { - return; - } - const pendingSidebarInfo = createPendingSidebar( - messageInfo, - threadInfo, - viewerID, - getDefaultTextMessageRules().simpleMarkdownRules, - ); - dispatch({ - type: updateNavInfoActionType, - payload: { - activeChatThreadID: pendingSidebarInfo.id, - pendingThread: pendingSidebarInfo, - }, - }); - }, - [viewerID, messageInfo, threadInfo, dispatch], - ); -} - -function useOnClickNewThread(): (event: SyntheticEvent) => void { - const dispatch = useDispatch(); - return React.useCallback( - (event: SyntheticEvent) => { - event.preventDefault(); - dispatch({ - type: updateNavInfoActionType, - payload: { - chatMode: 'create', - selectedUserList: [], - }, - }); - }, - [dispatch], - ); -} - function navTabSelector(state: AppState): NavigationTab { return state.navInfo.tab; } function navSettingsSectionSelector( state: AppState, ): ?NavigationSettingsSection { return state.navInfo.settingsSection; } export { yearExtractor, yearAssertingSelector, monthExtractor, monthAssertingSelector, activeThreadSelector, webCalendarQuery, nonThreadCalendarQuery, - useOnClickThread, - useThreadIsActive, - useOnClickPendingSidebar, - useOnClickNewThread, navTabSelector, navSettingsSectionSelector, }; diff --git a/web/selectors/thread-selectors.js b/web/selectors/thread-selectors.js new file mode 100644 index 000000000..171f3de3b --- /dev/null +++ b/web/selectors/thread-selectors.js @@ -0,0 +1,107 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; +import { useDispatch } from 'react-redux'; + +import { createPendingSidebar } from 'lib/shared/thread-utils'; +import type { + ComposableMessageInfo, + RobotextMessageInfo, +} from 'lib/types/message-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; + +import { getDefaultTextMessageRules } from '../markdown/rules.react'; +import { updateNavInfoActionType } from '../redux/action-types'; +import { useSelector } from '../redux/redux-utils'; + +function useOnClickThread( + thread: ?ThreadInfo, +): (event: SyntheticEvent) => void { + const dispatch = useDispatch(); + return React.useCallback( + (event: SyntheticEvent) => { + invariant( + thread?.id, + 'useOnClickThread should be called with threadID set', + ); + event.preventDefault(); + const { id: threadID } = thread; + + let payload; + if (threadID.includes('pending')) { + payload = { + chatMode: 'view', + activeChatThreadID: threadID, + pendingThread: thread, + }; + } else { + payload = { + chatMode: 'view', + activeChatThreadID: threadID, + }; + } + + dispatch({ type: updateNavInfoActionType, payload }); + }, + [dispatch, thread], + ); +} + +function useThreadIsActive(threadID: string): boolean { + return useSelector(state => threadID === state.navInfo.activeChatThreadID); +} + +function useOnClickPendingSidebar( + messageInfo: ComposableMessageInfo | RobotextMessageInfo, + threadInfo: ThreadInfo, +): (event: SyntheticEvent) => void { + const dispatch = useDispatch(); + const viewerID = useSelector(state => state.currentUserInfo?.id); + return React.useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + if (!viewerID) { + return; + } + const pendingSidebarInfo = createPendingSidebar( + messageInfo, + threadInfo, + viewerID, + getDefaultTextMessageRules().simpleMarkdownRules, + ); + dispatch({ + type: updateNavInfoActionType, + payload: { + activeChatThreadID: pendingSidebarInfo.id, + pendingThread: pendingSidebarInfo, + }, + }); + }, + [viewerID, messageInfo, threadInfo, dispatch], + ); +} + +function useOnClickNewThread(): (event: SyntheticEvent) => void { + const dispatch = useDispatch(); + return React.useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + dispatch({ + type: updateNavInfoActionType, + payload: { + chatMode: 'create', + selectedUserList: [], + }, + }); + }, + [dispatch], + ); +} + +export { + useOnClickThread, + useThreadIsActive, + useOnClickPendingSidebar, + useOnClickNewThread, +}; diff --git a/web/utils/tooltip-utils.js b/web/utils/tooltip-utils.js index 381f8c671..f9d2ab3c7 100644 --- a/web/utils/tooltip-utils.js +++ b/web/utils/tooltip-utils.js @@ -1,538 +1,538 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { createMessageReply } from 'lib/shared/message-utils'; import { threadHasPermission, useSidebarExistsOrCanBeCreated, } from 'lib/shared/thread-utils'; import { isComposableMessageType } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { threadPermissions } from 'lib/types/thread-types'; import { longAbsoluteDate } from 'lib/utils/date-utils'; import { tooltipButtonStyle, tooltipLabelStyle, tooltipStyle, } from '../chat/chat-constants'; import MessageTooltip from '../chat/message-tooltip.react'; import type { PositionInfo } from '../chat/position-types'; import { useTooltipContext } from '../chat/tooltip-provider'; import CommIcon from '../CommIcon.react'; import { InputStateContext } from '../input/input-state'; import { useSelector } from '../redux/redux-utils'; import { useOnClickPendingSidebar, useOnClickThread, -} from '../selectors/nav-selectors'; +} from '../selectors/thread-selectors'; import { calculateMaxTextWidth } from '../utils/text-utils'; export const tooltipPositions = Object.freeze({ LEFT: 'left', RIGHT: 'right', LEFT_BOTTOM: 'left-bottom', RIGHT_BOTTOM: 'right-bottom', LEFT_TOP: 'left-top', RIGHT_TOP: 'right-top', TOP: 'top', BOTTOM: 'bottom', }); type TooltipSize = { +height: number, +width: number, }; export type TooltipPositionStyle = { +anchorPoint: { +x: number, +y: number, }, +verticalPosition: 'top' | 'bottom', +horizontalPosition: 'left' | 'right', +alignment: 'left' | 'center' | 'right', }; export type TooltipPosition = $Values; export type MessageTooltipAction = { +label: string, +onClick: (SyntheticEvent) => mixed, +actionButtonContent: React.Node, }; const appTopBarHeight = 65; const font = '14px "Inter", -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", ' + '"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", ui-sans-serif'; type FindTooltipPositionArgs = { +sourcePositionInfo: PositionInfo, +tooltipSize: TooltipSize, +availablePositions: $ReadOnlyArray, +defaultPosition: TooltipPosition, +preventDisplayingBelowSource?: boolean, }; function findTooltipPosition({ sourcePositionInfo, tooltipSize, availablePositions, defaultPosition, preventDisplayingBelowSource, }: FindTooltipPositionArgs): TooltipPosition { if (!window) { return defaultPosition; } const appContainerPositionInfo: PositionInfo = { height: window.innerHeight - appTopBarHeight, width: window.innerWidth, top: appTopBarHeight, bottom: window.innerHeight, left: 0, right: window.innerWidth, }; const pointingTo = sourcePositionInfo; const { top: containerTop, left: containerLeft, right: containerRight, bottom: containerBottom, } = appContainerPositionInfo; const tooltipWidth = tooltipSize.width; const tooltipHeight = tooltipSize.height; const canBeDisplayedOnLeft = containerLeft + tooltipWidth <= pointingTo.left; const canBeDisplayedOnRight = tooltipWidth + pointingTo.right <= containerRight; const willCoverSidebarOnTopSideways = preventDisplayingBelowSource && pointingTo.top + tooltipHeight > pointingTo.bottom; const canBeDisplayedOnTopSideways = pointingTo.top >= containerTop && pointingTo.top + tooltipHeight <= containerBottom && !willCoverSidebarOnTopSideways; const canBeDisplayedOnBottomSideways = pointingTo.bottom <= containerBottom && pointingTo.bottom - tooltipHeight >= containerTop; const verticalCenterOfPointingTo = pointingTo.top + pointingTo.height / 2; const horizontalCenterOfPointingTo = pointingTo.left + pointingTo.width / 2; const willCoverSidebarInTheMiddleSideways = preventDisplayingBelowSource && verticalCenterOfPointingTo + tooltipHeight / 2 > pointingTo.bottom; const canBeDisplayedInTheMiddleSideways = verticalCenterOfPointingTo - tooltipHeight / 2 >= containerTop && verticalCenterOfPointingTo + tooltipHeight / 2 <= containerBottom && !willCoverSidebarInTheMiddleSideways; const canBeDisplayedOnTop = pointingTo.top - tooltipHeight >= containerTop && horizontalCenterOfPointingTo - tooltipWidth / 2 >= containerLeft && horizontalCenterOfPointingTo + tooltipWidth / 2 <= containerRight; const canBeDisplayedOnBottom = pointingTo.bottom + tooltipHeight <= containerBottom && horizontalCenterOfPointingTo - tooltipWidth / 2 >= containerLeft && horizontalCenterOfPointingTo + tooltipWidth / 2 <= containerRight && !preventDisplayingBelowSource; for (const tooltipPosition of availablePositions) { if ( tooltipPosition === tooltipPositions.RIGHT && canBeDisplayedOnRight && canBeDisplayedInTheMiddleSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.RIGHT_BOTTOM && canBeDisplayedOnRight && canBeDisplayedOnBottomSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.LEFT && canBeDisplayedOnLeft && canBeDisplayedInTheMiddleSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.LEFT_BOTTOM && canBeDisplayedOnLeft && canBeDisplayedOnBottomSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.LEFT_TOP && canBeDisplayedOnLeft && canBeDisplayedOnTopSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.RIGHT_TOP && canBeDisplayedOnRight && canBeDisplayedOnTopSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.TOP && canBeDisplayedOnTop ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.BOTTOM && canBeDisplayedOnBottom ) { return tooltipPosition; } } return defaultPosition; } type GetMessageActionTooltipStyleParams = { +sourcePositionInfo: PositionInfo, +tooltipSize: TooltipSize, +tooltipPosition: TooltipPosition, }; function getMessageActionTooltipStyle({ sourcePositionInfo, tooltipSize, tooltipPosition, }: GetMessageActionTooltipStyleParams): TooltipPositionStyle { if (tooltipPosition === tooltipPositions.RIGHT_TOP) { return { anchorPoint: { x: sourcePositionInfo.right, y: sourcePositionInfo.top, }, horizontalPosition: 'right', verticalPosition: 'bottom', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.LEFT_TOP) { return { anchorPoint: { x: sourcePositionInfo.left, y: sourcePositionInfo.top, }, horizontalPosition: 'left', verticalPosition: 'bottom', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.RIGHT_BOTTOM) { return { anchorPoint: { x: sourcePositionInfo.right, y: sourcePositionInfo.bottom, }, horizontalPosition: 'right', verticalPosition: 'top', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.LEFT_BOTTOM) { return { anchorPoint: { x: sourcePositionInfo.left, y: sourcePositionInfo.bottom, }, horizontalPosition: 'left', verticalPosition: 'top', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.LEFT) { return { anchorPoint: { x: sourcePositionInfo.left, y: sourcePositionInfo.top + sourcePositionInfo.height / 2 - tooltipSize.height / 2, }, horizontalPosition: 'left', verticalPosition: 'bottom', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.RIGHT) { return { anchorPoint: { x: sourcePositionInfo.right, y: sourcePositionInfo.top + sourcePositionInfo.height / 2 - tooltipSize.height / 2, }, horizontalPosition: 'right', verticalPosition: 'bottom', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.TOP) { return { anchorPoint: { x: sourcePositionInfo.left + sourcePositionInfo.width / 2 - tooltipSize.width / 2, y: sourcePositionInfo.top, }, horizontalPosition: 'right', verticalPosition: 'top', alignment: 'center', }; } else if (tooltipPosition === tooltipPositions.BOTTOM) { return { anchorPoint: { x: sourcePositionInfo.left + sourcePositionInfo.width / 2 - tooltipSize.width / 2, y: sourcePositionInfo.bottom, }, horizontalPosition: 'right', verticalPosition: 'bottom', alignment: 'center', }; } invariant(false, `Unexpected tooltip position value: ${tooltipPosition}`); } type CalculateTooltipSizeArgs = { +tooltipLabels: $ReadOnlyArray, +timestamp: string, }; function calculateTooltipSize({ tooltipLabels, timestamp, }: CalculateTooltipSizeArgs): { +width: number, +height: number, } { const textWidth = calculateMaxTextWidth([...tooltipLabels, timestamp], font) + 2 * tooltipLabelStyle.padding; const buttonsWidth = tooltipLabels.length * (tooltipButtonStyle.width + tooltipButtonStyle.paddingLeft + tooltipButtonStyle.paddingRight); const width = Math.max(textWidth, buttonsWidth) + tooltipStyle.paddingLeft + tooltipStyle.paddingRight; const height = (tooltipLabelStyle.height + 2 * tooltipLabelStyle.padding) * 2 + tooltipStyle.rowGap * 2 + tooltipButtonStyle.height; return { width, height, }; } function useMessageTooltipSidebarAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { threadCreatedFromMessage, messageInfo } = item; const sidebarExists = !!threadCreatedFromMessage; const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( threadInfo, item, ); const openThread = useOnClickThread(threadCreatedFromMessage); const openPendingSidebar = useOnClickPendingSidebar(messageInfo, threadInfo); return React.useMemo(() => { if (!sidebarExistsOrCanBeCreated) { return null; } const buttonContent = ; const onClick = (event: SyntheticEvent) => { if (threadCreatedFromMessage) { openThread(event); } else { openPendingSidebar(event); } }; return { actionButtonContent: buttonContent, onClick, label: sidebarExists ? 'Go to thread' : 'Create thread', }; }, [ openPendingSidebar, openThread, sidebarExists, sidebarExistsOrCanBeCreated, threadCreatedFromMessage, ]); } function useMessageTooltipReplyAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState is required'); const { addReply } = inputState; return React.useMemo(() => { if ( !isComposableMessageType(item.messageInfo.type) || !threadHasPermission(threadInfo, threadPermissions.VOICED) ) { return null; } const buttonContent = ; const onClick = () => { if (!messageInfo.text) { return; } addReply(createMessageReply(messageInfo.text)); }; return { actionButtonContent: buttonContent, onClick, label: 'Reply', }; }, [addReply, item.messageInfo.type, messageInfo, threadInfo]); } function useMessageTooltipActions( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): $ReadOnlyArray { const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo); const replyAction = useMessageTooltipReplyAction(item, threadInfo); return React.useMemo(() => [replyAction, sidebarAction].filter(Boolean), [ replyAction, sidebarAction, ]); } type UseMessageTooltipArgs = { +availablePositions: $ReadOnlyArray, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; type UseMessageTooltipResult = { onMouseEnter: (event: SyntheticEvent) => void, onMouseLeave: ?() => mixed, }; function useMessageTooltip({ availablePositions, item, threadInfo, }: UseMessageTooltipArgs): UseMessageTooltipResult { const [onMouseLeave, setOnMouseLeave] = React.useState mixed>(null); const { renderTooltip } = useTooltipContext(); const tooltipActions = useMessageTooltipActions(item, threadInfo); const containsInlineSidebar = !!item.threadCreatedFromMessage; const timeZone = useSelector(state => state.timeZone); const messageTimestamp = React.useMemo(() => { const time = item.messageInfo.time; return longAbsoluteDate(time, timeZone); }, [item.messageInfo.time, timeZone]); const tooltipSize = React.useMemo(() => { if (typeof document === 'undefined') { return { width: 0, height: 0, }; } const tooltipLabels = tooltipActions.map(action => action.label); return calculateTooltipSize({ tooltipLabels, timestamp: messageTimestamp, }); }, [messageTimestamp, tooltipActions]); const onMouseEnter = React.useCallback( (event: SyntheticEvent) => { if (!renderTooltip) { return; } const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; const tooltipPosition = findTooltipPosition({ sourcePositionInfo: messagePosition, tooltipSize, availablePositions, defaultPosition: availablePositions[0], preventDisplayingBelowSource: containsInlineSidebar, }); if (!tooltipPosition) { return; } const tooltipPositionStyle = getMessageActionTooltipStyle({ tooltipPosition, sourcePositionInfo: messagePosition, tooltipSize: tooltipSize, }); const { alignment } = tooltipPositionStyle; const tooltip = ( ); const renderTooltipResult = renderTooltip({ newNode: tooltip, tooltipPositionStyle, }); if (renderTooltipResult) { const { onMouseLeaveCallback: callback } = renderTooltipResult; setOnMouseLeave((() => callback: () => () => mixed)); } }, [ availablePositions, containsInlineSidebar, messageTimestamp, renderTooltip, tooltipActions, tooltipSize, ], ); return { onMouseEnter, onMouseLeave, }; } export { findTooltipPosition, calculateTooltipSize, getMessageActionTooltipStyle, useMessageTooltipSidebarAction, useMessageTooltipReplyAction, useMessageTooltipActions, useMessageTooltip, };