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 (
<>
{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 (
<>
>
);
}
function EmptyItem() {
return (
);
}
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 = (
);
} else {
arrow = (
);
}
return (
<>
{arrow}
>
);
}
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,
};