diff --git a/lib/utils/date-utils.js b/lib/utils/date-utils.js index 221915040..a153a0f90 100644 --- a/lib/utils/date-utils.js +++ b/lib/utils/date-utils.js @@ -1,164 +1,164 @@ // @flow import dateFormat from 'dateformat'; import invariant from 'invariant'; // Javascript uses 0-indexed months which is weird?? function getDate( yearInput: number, monthInput: number, dayOfMonth: number, ): Date { return new Date(yearInput, monthInput - 1, dayOfMonth); } function padMonthOrDay(n: number): string | number { return n < 10 ? '0' + n : n; } function daysInMonth(year: number, month: number) { switch (month) { case 2: return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28; case 4: case 6: case 9: case 11: return 30; default: return 31; } } function dateString( first: Date | number, month?: number, day?: number, ): string { if (arguments.length === 1) { return dateFormat(first, 'yyyy-mm-dd'); } else if (arguments.length === 3) { invariant(month && day, 'month/day should be set in call to dateString'); invariant(typeof first === 'number', 'first param should be a number'); return `${first}-${padMonthOrDay(month)}-${padMonthOrDay(day)}`; } invariant(false, 'incorrect number of params passed to dateString'); } function startDateForYearAndMonth(year: number, month: number): string { return dateString(year, month, 1); } function endDateForYearAndMonth(year: number, month: number): string { return dateString(year, month, daysInMonth(year, month)); } function fifteenDaysEarlier(timeZone?: ?string): string { const earlier = currentDateInTimeZone(timeZone); earlier.setDate(earlier.getDate() - 15); return dateString(earlier); } function fifteenDaysLater(timeZone?: ?string): string { const later = currentDateInTimeZone(timeZone); later.setDate(later.getDate() + 15); return dateString(later); } function prettyDate(dayString: string): string { return dateFormat(dateFromString(dayString), 'dddd, mmmm dS, yyyy'); } function dateFromString(dayString: string): Date { const matches = dayString.match(/^([0-9]+)-([0-1][0-9])-([0-3][0-9])$/); invariant(matches && matches.length === 4, `invalid dayString ${dayString}`); return getDate( parseInt(matches[1], 10), parseInt(matches[2], 10), parseInt(matches[3], 10), ); } const millisecondsInDay = 24 * 60 * 60 * 1000; const millisecondsInWeek = millisecondsInDay * 7; const millisecondsInYear = millisecondsInDay * 365; // Takes a millisecond timestamp and displays the time in the local timezone -function shortAbsoluteDate(timestamp: number, timeZone?: ?string): string { +function shortAbsoluteDate(timestamp: number): string { const now = Date.now(); const msSince = now - timestamp; - const date = changeTimeZone(new Date(timestamp), timeZone); + const date = new Date(timestamp); if (msSince < millisecondsInDay) { return dateFormat(date, 'h:MM TT'); } else if (msSince < millisecondsInWeek) { return dateFormat(date, 'ddd'); } else if (msSince < millisecondsInYear) { return dateFormat(date, 'mmm d'); } else { return dateFormat(date, 'mmm d yyyy'); } } // Same as above, but longer -function longAbsoluteDate(timestamp: number, timeZone?: ?string): string { +function longAbsoluteDate(timestamp: number): string { const now = Date.now(); const msSince = now - timestamp; - const date = changeTimeZone(new Date(timestamp), timeZone); + const date = new Date(timestamp); if (msSince < millisecondsInDay) { return dateFormat(date, 'h:MM TT'); } else if (msSince < millisecondsInWeek) { return dateFormat(date, 'ddd h:MM TT'); } else if (msSince < millisecondsInYear) { return dateFormat(date, 'mmmm d, h:MM TT'); } else { return dateFormat(date, 'mmmm d yyyy, h:MM TT'); } } function thisMonthDates( timeZone?: ?string, ): { startDate: string, endDate: string } { const now = currentDateInTimeZone(timeZone); const year = now.getFullYear(); const month = now.getMonth() + 1; return { startDate: startDateForYearAndMonth(year, month), endDate: endDateForYearAndMonth(year, month), }; } // The Date object doesn't support time zones, and is hardcoded to the server's // time zone. Thus, the best way to convert Date between time zones is to offset // the Date by the difference between the time zones function changeTimeZone(date: Date, timeZone: ?string): Date { if (!timeZone) { return date; } const localeString = date.toLocaleString('en-US', { timeZone }); const localeDate = new Date(localeString); const diff = localeDate.getTime() - date.getTime(); return new Date(date.getTime() + diff); } function currentDateInTimeZone(timeZone: ?string): Date { return changeTimeZone(new Date(), timeZone); } const threeDays = millisecondsInDay * 3; export { getDate, padMonthOrDay, dateString, startDateForYearAndMonth, endDateForYearAndMonth, fifteenDaysEarlier, fifteenDaysLater, prettyDate, dateFromString, shortAbsoluteDate, longAbsoluteDate, thisMonthDates, currentDateInTimeZone, threeDays, }; diff --git a/web/calendar/day.react.js b/web/calendar/day.react.js index ccfed31d1..6fed498fd 100644 --- a/web/calendar/day.react.js +++ b/web/calendar/day.react.js @@ -1,265 +1,258 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _some from 'lodash/fp/some'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions'; import { useModalContext, type PushModal, } from 'lib/components/modal-provider.react'; import { onScreenThreadInfos as onScreenThreadInfosSelector } from 'lib/selectors/thread-selectors'; import { entryKey } from 'lib/shared/entry-utils'; import type { EntryInfo } from 'lib/types/entry-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { ThreadInfo } from 'lib/types/thread-types'; -import { - dateString, - dateFromString, - currentDateInTimeZone, -} from 'lib/utils/date-utils'; +import { dateString, dateFromString } from 'lib/utils/date-utils'; import LogInFirstModal from '../modals/account/log-in-first-modal.react'; import HistoryModal from '../modals/history/history-modal.react'; import ThreadPickerModal from '../modals/threads/thread-picker-modal.react'; import { useSelector } from '../redux/redux-utils'; import { htmlTargetFromEvent } from '../vector-utils'; import { AddVector, HistoryVector } from '../vectors.react'; import css from './calendar.css'; import type { InnerEntry } from './entry.react'; import Entry from './entry.react'; type BaseProps = { +dayString: string, +entryInfos: $ReadOnlyArray, +startingTabIndex: number, }; type Props = { ...BaseProps, +onScreenThreadInfos: $ReadOnlyArray, +viewerID: ?string, +loggedIn: boolean, +nextLocalID: number, - +timeZone: ?string, +dispatch: Dispatch, +pushModal: PushModal, +popModal: () => void, }; type State = { +hovered: boolean, }; class Day extends React.PureComponent { state: State = { hovered: false, }; entryContainer: ?HTMLDivElement; entryContainerSpacer: ?HTMLDivElement; actionLinks: ?HTMLDivElement; entries: Map = new Map(); componentDidUpdate(prevProps: Props) { if (this.props.entryInfos.length > prevProps.entryInfos.length) { invariant(this.entryContainer, 'entryContainer ref not set'); this.entryContainer.scrollTop = this.entryContainer.scrollHeight; } } render() { - const now = currentDateInTimeZone(this.props.timeZone); + const now = new Date(); const isToday = dateString(now) === this.props.dayString; const tdClasses = classNames(css.day, { [css.currentDay]: isToday }); let actionLinks = null; const hovered = this.state.hovered; if (hovered) { const actionLinksClassName = `${css.actionLinks} ${css.dayActionLinks}`; actionLinks = (
Add History
); } const entries = this.props.entryInfos .filter(entryInfo => _some(['id', entryInfo.threadID])(this.props.onScreenThreadInfos), ) .map((entryInfo, i) => { const key = entryKey(entryInfo); return ( ); }); const entryContainerClasses = classNames(css.entryContainer, { [css.focusedEntryContainer]: hovered, }); const date = dateFromString(this.props.dayString); return (

{date.getDate()}

{entries}
{actionLinks} ); } actionLinksRef = (actionLinks: ?HTMLDivElement) => { this.actionLinks = actionLinks; }; entryContainerRef = (entryContainer: ?HTMLDivElement) => { this.entryContainer = entryContainer; }; entryContainerSpacerRef = (entryContainerSpacer: ?HTMLDivElement) => { this.entryContainerSpacer = entryContainerSpacer; }; entryRef = (key: string, entry: InnerEntry) => { this.entries.set(key, entry); }; onMouseEnter = () => { this.setState({ hovered: true }); }; onMouseLeave = () => { this.setState({ hovered: false }); }; onClick = (event: SyntheticEvent) => { const target = htmlTargetFromEvent(event); invariant( this.entryContainer instanceof HTMLDivElement, "entryContainer isn't div", ); invariant( this.entryContainerSpacer instanceof HTMLDivElement, "entryContainerSpacer isn't div", ); if ( target === this.entryContainer || target === this.entryContainerSpacer || (this.actionLinks && target === this.actionLinks) ) { this.onAddEntry(event); } }; onAddEntry = (event: SyntheticEvent<*>) => { event.preventDefault(); invariant( this.props.onScreenThreadInfos.length > 0, "onAddEntry shouldn't be clicked if no onScreenThreadInfos", ); if (this.props.onScreenThreadInfos.length === 1) { this.createNewEntry(this.props.onScreenThreadInfos[0].id); } else if (this.props.onScreenThreadInfos.length > 1) { this.props.pushModal( , ); } }; createNewEntry = (threadID: string) => { if (!this.props.loggedIn) { this.props.pushModal(); return; } const viewerID = this.props.viewerID; invariant(viewerID, 'should have viewerID in order to create thread'); this.props.dispatch({ type: createLocalEntryActionType, payload: createLocalEntry( threadID, this.props.nextLocalID, this.props.dayString, viewerID, ), }); }; onHistory = (event: SyntheticEvent) => { event.preventDefault(); this.props.pushModal( , ); }; focusOnFirstEntryNewerThan = (time: number) => { const entryInfo = this.props.entryInfos.find( candidate => candidate.creationTime > time, ); if (entryInfo) { const entry = this.entries.get(entryKey(entryInfo)); invariant(entry, 'entry for entryinfo should be defined'); entry.focus(); } }; } const ConnectedDay: React.ComponentType = React.memo( function ConnectedDay(props) { const onScreenThreadInfos = useSelector(onScreenThreadInfosSelector); const viewerID = useSelector(state => state.currentUserInfo?.id); const loggedIn = useSelector( state => !!(state.currentUserInfo && !state.currentUserInfo.anonymous && true), ); const nextLocalID = useSelector(state => state.nextLocalID); - const timeZone = useSelector(state => state.timeZone); const dispatch = useDispatch(); const { pushModal, popModal } = useModalContext(); return ( ); }, ); export default ConnectedDay; diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index e0dc62316..ba7d69660 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,355 +1,350 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import * as React from 'react'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors'; import { messageKey } from 'lib/shared/message-utils'; import { threadIsPending } from 'lib/shared/thread-utils'; import type { FetchMessageInfosPayload } from 'lib/types/message-types'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { type InputState, InputStateContext } from '../input/input-state'; import LoadingIndicator from '../loading-indicator.react'; import { useTextMessageRulesFunc } from '../markdown/rules.react'; import { useSelector } from '../redux/redux-utils'; import css from './chat-message-list.css'; import { MessageListContext } from './message-list-types'; import Message from './message.react'; import RelationshipPrompt from './relationship-prompt/relationship-prompt'; import { useTooltipContext } from './tooltip-provider'; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +activeChatThreadID: ?string, +messageListData: ?$ReadOnlyArray, +startReached: boolean, - +timeZone: ?string, +supportsReverseFlex: boolean, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withInputState +inputState: ?InputState, +clearTooltip: () => mixed, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; class ChatMessageList extends React.PureComponent { container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; componentDidMount() { this.scrollToBottom(); } getSnapshotBeforeUpdate(prevProps: Props) { if ( ChatMessageList.hasNewMessage(this.props, prevProps) && this.messageContainer ) { const { scrollTop, scrollHeight } = this.messageContainer; return { scrollTop, scrollHeight }; } return null; } static hasNewMessage(props: Props, prevProps: Props) { const { messageListData } = props; if (!messageListData || messageListData.length === 0) { return false; } const prevMessageListData = prevProps.messageListData; if (!prevMessageListData || prevMessageListData.length === 0) { return true; } return ( ChatMessageList.keyExtractor(prevMessageListData[0]) !== ChatMessageList.keyExtractor(messageListData[0]) ); } componentDidUpdate(prevProps: Props, prevState, snapshot: ?Snapshot) { const { messageListData } = this.props; const prevMessageListData = prevProps.messageListData; if ( this.loadingFromScroll && messageListData && (!prevMessageListData || messageListData.length > prevMessageListData.length || this.props.startReached) ) { this.loadingFromScroll = false; } const { messageContainer } = this; if (messageContainer && prevMessageListData !== messageListData) { this.onScroll(); } // We'll scroll to the bottom if the user was already scrolled to the bottom // before the new message, or if the new message was composed locally const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps); if ( this.props.activeChatThreadID !== prevProps.activeChatThreadID || (hasNewMessage && messageListData && messageListData[0].itemType === 'message' && messageListData[0].messageInfo.localID) || (hasNewMessage && snapshot && Math.abs(snapshot.scrollTop) <= 1) ) { this.scrollToBottom(); } else if (hasNewMessage && messageContainer && snapshot) { const { scrollTop, scrollHeight } = messageContainer; if ( scrollHeight > snapshot.scrollHeight && scrollTop === snapshot.scrollTop ) { const newHeight = scrollHeight - snapshot.scrollHeight; const newScrollTop = Math.abs(scrollTop) + newHeight; if (this.props.supportsReverseFlex) { messageContainer.scrollTop = -1 * newScrollTop; } else { messageContainer.scrollTop = newScrollTop; } } } } scrollToBottom() { if (this.messageContainer) { this.messageContainer.scrollTop = 0; } } static keyExtractor(item: ChatMessageItem) { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } renderItem = item => { if (item.itemType === 'loader') { return (
); } const { threadInfo } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; render() { const { messageListData, threadInfo, inputState } = this.props; if (!messageListData) { return
; } invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); let relationshipPrompt = null; if (threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ; } const messageContainerStyle = classNames({ [css.messageContainer]: true, [css.mirroredMessageContainer]: !this.props.supportsReverseFlex, }); return (
{relationshipPrompt}
{messages}
); } messageContainerRef = (messageContainer: ?HTMLDivElement) => { this.messageContainer = messageContainer; // In case we already have all the most recent messages, // but they're not enough this.possiblyLoadMoreMessages(); if (messageContainer) { messageContainer.addEventListener('scroll', this.onScroll); } }; onScroll = () => { if (!this.messageContainer) { return; } this.props.clearTooltip(); this.possiblyLoadMoreMessages(); }; possiblyLoadMoreMessages() { if (!this.messageContainer) { return; } const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; if ( this.props.startReached || Math.abs(scrollTop) + clientHeight + 55 < scrollHeight ) { return; } if (this.loadingFromScroll) { return; } this.loadingFromScroll = true; const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); const oldestMessageServerID = this.oldestMessageServerID(); if (oldestMessageServerID) { this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } else { this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadID), ); } } oldestMessageServerID(): ?string { const data = this.props.messageListData; invariant(data, 'should be set'); for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedChatMessageList: React.ComponentType = React.memo( function ConnectedChatMessageList(props: BaseProps): React.Node { const { threadInfo } = props; const userAgent = useSelector(state => state.userAgent); const supportsReverseFlex = React.useMemo(() => { const browser = detectBrowser(userAgent); return ( !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81 ); }, [userAgent]); - const timeZone = useSelector(state => state.timeZone); - const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); const startReached = !!useSelector(state => { const activeID = threadInfo.id; if (!activeID) { return null; } if (threadIsPending(activeID)) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const inputState = React.useContext(InputStateContext); const { clearTooltip } = useTooltipContext(); const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadInfo.id); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); return ( ); }, ); export default ConnectedChatMessageList; diff --git a/web/chat/chat-thread-list-item.react.js b/web/chat/chat-thread-list-item.react.js index 4cea476aa..5f090c064 100644 --- a/web/chat/chat-thread-list-item.react.js +++ b/web/chat/chat-thread-list-item.react.js @@ -1,163 +1,159 @@ // @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/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 lastActivity = shortAbsoluteDate(lastUpdatedTimeIncludingSidebars); 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 = classNames({ [css.thread]: true, [css.activeThread]: active, }); const { unread } = currentUser; const titleClassName = classNames({ [css.title]: true, [css.unread]: unread, }); const lastActivityClassName = classNames({ [css.lastActivity]: true, [css.unread]: unread, [css.dark]: !unread, }); const breadCrumbsClassName = classNames(css.breadCrumbs, { [css.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/message.react.js b/web/chat/message.react.js index f8b6d4f99..9aab62ed4 100644 --- a/web/chat/message.react.js +++ b/web/chat/message.react.js @@ -1,52 +1,51 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { longAbsoluteDate } from 'lib/utils/date-utils'; import css from './chat-message-list.css'; import MultimediaMessage from './multimedia-message.react'; import RobotextMessage from './robotext-message.react'; import TextMessage from './text-message.react'; type Props = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, - +timeZone: ?string, }; function Message(props: Props): React.Node { - const { item, timeZone } = props; + const { item } = props; let conversationHeader = null; if (item.startsConversation) { conversationHeader = (
- {longAbsoluteDate(item.messageInfo.time, timeZone)} + {longAbsoluteDate(item.messageInfo.time)}
); } let message; if (item.messageInfo.type === messageTypes.TEXT) { message = ; } else if ( item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA ) { message = ; } else { invariant(item.robotext, "Flow can't handle our fancy types :("); message = ; } return (
{conversationHeader} {message}
); } export default Message; diff --git a/web/modals/threads/sidebars/sidebar.react.js b/web/modals/threads/sidebars/sidebar.react.js index adf5a221a..607730fe0 100644 --- a/web/modals/threads/sidebars/sidebar.react.js +++ b/web/modals/threads/sidebars/sidebar.react.js @@ -1,86 +1,83 @@ // @flow import classNames from 'classnames'; 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/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 { unread } = threadInfo.currentUser; - const timeZone = useSelector(state => state.timeZone); const { popModal } = useModalContext(); const navigateToThread = useOnClickThread(threadInfo); const onClickThread = React.useCallback( event => { popModal(); navigateToThread(event); }, [popModal, navigateToThread], ); const sidebarInfoClassName = classNames({ [css.sidebarInfo]: true, [css.unread]: unread, }); - const lastActivity = React.useMemo( - () => shortAbsoluteDate(lastUpdatedTime, timeZone), - [lastUpdatedTime, timeZone], - ); + const lastActivity = React.useMemo(() => shortAbsoluteDate(lastUpdatedTime), [ + lastUpdatedTime, + ]); 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 b21af6870..2b99b9535 100644 --- a/web/modals/threads/subchannels/subchannel.react.js +++ b/web/modals/threads/subchannels/subchannel.react.js @@ -1,84 +1,82 @@ // @flow import classNames from 'classnames'; 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/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 { unread } = threadInfo.currentUser; const subchannelTitleClassName = classNames({ [css.subchannelInfo]: true, [css.unread]: unread, }); - 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], + () => shortAbsoluteDate(lastUpdatedTimeIncludingSidebars), + [lastUpdatedTimeIncludingSidebars], ); 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/redux/redux-setup.js b/web/redux/redux-setup.js index 8baa30d41..7b9b6bc5c 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,251 +1,250 @@ // @flow import invariant from 'invariant'; import type { PersistState } from 'redux-persist/src/types'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import type { Shape } from 'lib/types/core'; import type { DraftStore } from 'lib/types/draft-types'; import type { EnabledApps } from 'lib/types/enabled-apps'; import type { EntryStore } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { UserPolicies } from 'lib/types/policy-types.js'; import type { BaseAction } from 'lib/types/redux-types'; import type { ReportStore } from 'lib/types/report-types'; import type { ConnectionInfo } from 'lib/types/socket-types'; import type { ThreadStore } from 'lib/types/thread-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { activeThreadSelector } from '../selectors/nav-selectors'; import { type NavInfo } from '../types/nav-types'; import { updateWindowActiveActionType, setDeviceIDActionType, updateNavInfoActionType, updateWindowDimensionsActionType, } from './action-types'; import { reduceDeviceID } from './device-id-reducer'; import reduceNavInfo from './nav-reducer'; import { getVisibility } from './visibility'; export type WindowDimensions = { width: number, height: number }; export type AppState = { navInfo: NavInfo, deviceID: ?string, currentUserInfo: ?CurrentUserInfo, draftStore: DraftStore, sessionID: ?string, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, windowDimensions: WindowDimensions, cookie?: void, deviceToken?: void, baseHref: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, lifecycleState: LifecycleState, enabledApps: EnabledApps, reportStore: ReportStore, nextLocalID: number, - timeZone: ?string, userAgent: ?string, dataLoaded: boolean, windowActive: boolean, userPolicies: UserPolicies, _persist: ?PersistState, }; export type Action = | BaseAction | { type: 'UPDATE_NAV_INFO', payload: Shape } | { type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, } | { type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, } | { type: 'SET_DEVICE_ID', payload: string, }; export function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; if (action.type === updateWindowDimensionsActionType) { return validateState(oldState, { ...state, windowDimensions: action.payload, }); } else if (action.type === updateWindowActiveActionType) { return validateState(oldState, { ...state, windowActive: action.payload, }); } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } state = { ...state, sessionID: action.payload.sessionChange.sessionID, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return oldState; } if ( action.type !== updateNavInfoActionType && action.type !== setDeviceIDActionType ) { state = baseReducer(state, action).state; } state = { ...state, navInfo: reduceNavInfo( state.navInfo, action, state.threadStore.threadInfos, ), deviceID: reduceDeviceID(state.deviceID, action), }; return validateState(oldState, state); } function validateState(oldState: AppState, state: AppState): AppState { if ( (state.navInfo.activeChatThreadID && !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID]) || (!state.navInfo.activeChatThreadID && isLoggedIn(state)) ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentlyReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && getVisibility().hidden() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but visibilityjs reports the window is not visible', ); } if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && typeof document !== 'undefined' && document && 'hasFocus' in document && !document.hasFocus() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but document.hasFocus() is false', ); } if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && 'hasFocus' in document && document.hasFocus() && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { // Update messageStore.threads[activeThread].lastNavigatedTo state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [activeThread]: { ...state.messageStore.threads[activeThread], lastNavigatedTo: Date.now(), }, }, }, }; } return state; } diff --git a/web/utils/tooltip-utils.js b/web/utils/tooltip-utils.js index f9d2ab3c7..2be9aecc9 100644 --- a/web/utils/tooltip-utils.js +++ b/web/utils/tooltip-utils.js @@ -1,538 +1,535 @@ // @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/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]); + return longAbsoluteDate(time); + }, [item.messageInfo.time]); 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, };