diff --git a/web/chat/chat-thread-list-item.react.js b/web/chat/chat-thread-list-item.react.js index b92339f73..177d33892 100644 --- a/web/chat/chat-thread-list-item.react.js +++ b/web/chat/chat-thread-list-item.react.js @@ -1,151 +1,151 @@ // @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'; 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, +setModal: (modal: ?React.Node) => void, }; function ChatThreadListItem(props: Props): React.Node { const { item, setModal } = props; const { threadInfo, lastUpdatedTimeIncludingSidebars } = item; const threadID = item.threadInfo.id; const ancestorThreads = useAncestorThreads(threadInfo); - const onClick = useOnClickThread(threadID); + const onClick = useOnClickThread(item.threadInfo); const timeZone = useSelector(state => state.timeZone); const lastActivity = shortAbsoluteDate( lastUpdatedTimeIncludingSidebars, timeZone, ); const active = useThreadIsActive(threadID); const containerClassName = React.useMemo( () => classNames({ [css.thread]: true, [css.activeThread]: active, }), [active], ); const { unread } = item.threadInfo.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], ); const { color } = item.threadInfo; const colorSplotchStyle = React.useMemo( () => ({ backgroundColor: `#${color}` }), [color], ); const sidebars = item.sidebars.map(sidebarItem => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( ); } 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 ( <>

{ancestorPath}

{item.threadInfo.uiName}
{lastActivity}
{sidebars} ); } export default ChatThreadListItem; diff --git a/web/chat/inline-sidebar.react.js b/web/chat/inline-sidebar.react.js index f7f9ae10b..d9ef13e83 100644 --- a/web/chat/inline-sidebar.react.js +++ b/web/chat/inline-sidebar.react.js @@ -1,78 +1,78 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { CornerDownRight as CornerDownRightIcon, CornerDownLeft as CornerDownLeftIcon, } from 'react-feather'; import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; import { stringForUser } from 'lib/shared/user-utils'; import type { ThreadInfo } from 'lib/types/thread-types'; import { pluralizeAndTrim } from 'lib/utils/text-utils'; import { useSelector } from '../redux/redux-utils'; import { useOnClickThread } from '../selectors/nav-selectors'; import css from './inline-sidebar.css'; type Props = { +threadInfo: ThreadInfo, +positioning: 'left' | 'center' | 'right', }; function InlineSidebar(props: Props): React.Node { const { threadInfo } = props; - const onClick = useOnClickThread(threadInfo.id); + const onClick = useOnClickThread(threadInfo); let viewerIcon, nonViewerIcon, alignStyle; if (props.positioning === 'right') { viewerIcon = ( ); alignStyle = css.viewerMessageBoxContainer; } else if (props.positioning === 'left') { nonViewerIcon = ( ); alignStyle = css.nonViewerMessageBoxContainer; } else { nonViewerIcon = ( ); alignStyle = css.centerContainer; } const unreadStyle = threadInfo.currentUser.unread ? css.unread : null; const repliesCount = threadInfo.repliesCount || 1; const repliesText = `${repliesCount} ${ repliesCount > 1 ? 'replies' : 'reply' }`; const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadInfo.id), ); const sendersText = React.useMemo(() => { const senders = threadMembers .filter(member => member.isSender) .map(stringForUser); return senders.length > 0 ? `${pluralizeAndTrim(senders, 25)} sent ` : ''; }, [threadMembers]); return (
{nonViewerIcon}
{sendersText} {repliesText}
{viewerIcon}
); } const inlineSidebarHeight = 20; export { InlineSidebar, inlineSidebarHeight }; diff --git a/web/chat/message-action-button.js b/web/chat/message-action-button.js index e2e3ef309..57e977e16 100644 --- a/web/chat/message-action-button.js +++ b/web/chat/message-action-button.js @@ -1,168 +1,168 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import type { ThreadInfo } from 'lib/types/thread-types'; import { useOnClickThread, useOnClickPendingSidebar, } from '../selectors/nav-selectors'; import SWMansionIcon from '../SWMansionIcon.react'; import css from './message-action-button.css'; import type { ItemAndContainerPositionInfo, PositionInfo, } from './position-types'; import { tooltipPositions, type TooltipPosition } from './tooltip-utils'; import { TooltipMenu, type TooltipStyle, TooltipButton } from './tooltip.react'; const ellipsisIconExcessVerticalWhitespace = 10; const openSidebarText = 'Go to sidebar'; const createSidebarText = 'Create sidebar'; type MessageActionTooltipProps = { +threadInfo: ThreadInfo, +item: ChatMessageInfoItem, +containerPosition: PositionInfo, +availableTooltipPositions: $ReadOnlyArray, }; function MessageActionButton(props: MessageActionTooltipProps): React.Node { const { threadInfo, item, containerPosition, availableTooltipPositions, } = props; const [tooltipVisible, setTooltipVisible] = React.useState(false); const [pointingTo, setPointingTo] = React.useState(); const toggleTooltip = React.useCallback( (event: SyntheticEvent) => { setTooltipVisible(!tooltipVisible); if (tooltipVisible) { return; } const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, width, height } = rect; const dotsPosition: ItemAndContainerPositionInfo = { containerPosition, itemPosition: { top: top - containerPosition.top + ellipsisIconExcessVerticalWhitespace, bottom: bottom - containerPosition.top - ellipsisIconExcessVerticalWhitespace, left: left - containerPosition.left, right: right - containerPosition.left, width, height: height - ellipsisIconExcessVerticalWhitespace * 2, }, }; setPointingTo(dotsPosition); }, [containerPosition, tooltipVisible], ); const hideTooltip = React.useCallback(() => { setTooltipVisible(false); }, []); const { threadCreatedFromMessage, messageInfo } = item; - const onThreadOpen = useOnClickThread(threadCreatedFromMessage?.id); + const onThreadOpen = useOnClickThread(threadCreatedFromMessage); const onPendingSidebarOpen = useOnClickPendingSidebar( messageInfo, threadInfo, ); const onSidebarButtonClick = React.useCallback( (event: SyntheticEvent) => { if (threadCreatedFromMessage) { onThreadOpen(event); } else { onPendingSidebarOpen(event); } }, [onPendingSidebarOpen, onThreadOpen, threadCreatedFromMessage], ); const sidebarTooltipButtonText = threadCreatedFromMessage ? openSidebarText : createSidebarText; let tooltipMenu = null; if (pointingTo && tooltipVisible) { tooltipMenu = ( ); } return (
{tooltipMenu}
); } function getMessageActionTooltipStyle( tooltipPosition: TooltipPosition, ): TooltipStyle { let className; if (tooltipPosition === tooltipPositions.TOP_RIGHT) { className = classNames( css.messageActionTopRightTooltip, css.messageTopRightTooltip, css.messageActionExtraAreaTop, css.messageActionExtraAreaTopRight, ); } else if (tooltipPosition === tooltipPositions.TOP_LEFT) { className = classNames( css.messageActionTopLeftTooltip, css.messageTopLeftTooltip, css.messageActionExtraAreaTop, css.messageActionExtraAreaTopLeft, ); } else if (tooltipPosition === tooltipPositions.RIGHT) { className = classNames( css.messageActionRightTooltip, css.messageRightTooltip, css.messageActionExtraArea, css.messageActionExtraAreaRight, ); } else if (tooltipPosition === tooltipPositions.LEFT) { className = classNames( css.messageActionLeftTooltip, css.messageLeftTooltip, css.messageActionExtraArea, css.messageActionExtraAreaLeft, ); } invariant(className, `${tooltipPosition} is not valid for message tooltip`); return { className }; } export default MessageActionButton; diff --git a/web/chat/sidebar-item.react.js b/web/chat/sidebar-item.react.js index a0193f713..f44e8adbe 100644 --- a/web/chat/sidebar-item.react.js +++ b/web/chat/sidebar-item.react.js @@ -1,35 +1,35 @@ // @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 SWMansionIcon from '../SWMansionIcon.react'; import css from './chat-thread-list.css'; type Props = { +sidebarInfo: SidebarInfo, }; function SidebarItem(props: Props): React.Node { const { threadInfo } = props.sidebarInfo; - const threadID = threadInfo.id; - const onClick = useOnClickThread(threadID); + const onClick = useOnClickThread(threadInfo); + const { unread } = threadInfo.currentUser; const unreadCls = classNames(css.sidebarTitle, { [css.unread]: unread }); return ( <>
{threadInfo.uiName}
); } export default SidebarItem; diff --git a/web/selectors/nav-selectors.js b/web/selectors/nav-selectors.js index 525ed040a..3e71299a2 100644 --- a/web/selectors/nav-selectors.js +++ b/web/selectors/nav-selectors.js @@ -1,194 +1,205 @@ // @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 type { AppState } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import { updateNavInfoActionType } 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( - threadID: ?string, + thread: ?ThreadInfo, ): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { invariant( - threadID, + thread?.id, 'useOnClickThread should be called with threadID set', ); event.preventDefault(); - dispatch({ - type: updateNavInfoActionType, - payload: { - activeChatThreadID: threadID, - }, - }); + const { id: threadID } = thread; + if (threadID.includes('pending')) { + dispatch({ + type: updateNavInfoActionType, + payload: { + activeChatThreadID: threadID, + pendingThread: thread, + }, + }); + } else { + dispatch({ + type: updateNavInfoActionType, + payload: { + activeChatThreadID: threadID, + }, + }); + } }, - [dispatch, threadID], + [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], ); } export { yearExtractor, yearAssertingSelector, monthExtractor, monthAssertingSelector, activeThreadSelector, webCalendarQuery, nonThreadCalendarQuery, useOnClickThread, useThreadIsActive, useOnClickPendingSidebar, };