diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 4fc2c5616..566f114fd 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,478 +1,485 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import * as React from 'react'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions.js'; import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors.js'; import { messageKey } from 'lib/shared/message-utils.js'; -import { threadIsPending } from 'lib/shared/thread-utils.js'; +import { + threadIsPending, + useThreadChatMentionCandidates, +} from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { editBoxHeight, defaultMaxTextAreaHeight } from './chat-constants.js'; import css from './chat-message-list.css'; import type { ScrollToMessageCallback } from './edit-message-provider.js'; import { useEditModalContext } from './edit-message-provider.js'; import { MessageListContext } from './message-list-types.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; import { useTooltipContext } from './tooltip-provider.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; const browser = detectBrowser(); const supportsReverseFlex = !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81; // Margin between the top of the maximum height edit box // and the top of the container const editBoxTopMargin = 10; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +activeChatThreadID: ?string, +messageListData: ?$ReadOnlyArray, +startReached: boolean, +dispatchActionPromise: DispatchActionPromise, +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, +inputState: ?InputState, +clearTooltip: () => mixed, +oldestMessageServerID: ?string, +isEditState: boolean, +addScrollToMessageListener: ScrollToMessageCallback => mixed, +removeScrollToMessageListener: ScrollToMessageCallback => mixed, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; type State = { +scrollingEndCallback: ?() => mixed, }; class ChatMessageList extends React.PureComponent { container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; constructor(props: Props) { super(props); this.state = { scrollingEndCallback: null, }; } componentDidMount() { this.scrollToBottom(); this.props.addScrollToMessageListener(this.scrollToMessage); } componentWillUnmount() { this.props.removeScrollToMessageListener(this.scrollToMessage); } 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; 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.props.isEditState) ) { 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 (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 ( ); }; scrollingEndCallbackWrapper = ( composedMessageID: string, callback: (maxHeight: number) => mixed, ): (() => mixed) => { return () => { const maxHeight = this.getMaxEditTextAreaHeight(composedMessageID); callback(maxHeight); }; }; scrollToMessage = ( composedMessageID: string, callback: (maxHeight: number) => mixed, ) => { const element = document.getElementById(composedMessageID); if (!element) { return; } const scrollingEndCallback = this.scrollingEndCallbackWrapper( composedMessageID, callback, ); if (!this.willMessageEditWindowOverflow(composedMessageID)) { scrollingEndCallback(); return; } this.setState( { scrollingEndCallback, }, () => { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // It covers the case when browser decide not to scroll to the message // because it's already in the view. // In this case, the 'scroll' event won't be triggered, // so we need to call the callback manually. this.debounceEditModeAfterScrollToMessage(); }, ); }; getMaxEditTextAreaHeight = (composedMessageID: string): number => { const { messageContainer } = this; if (!messageContainer) { return defaultMaxTextAreaHeight; } const messageElement = document.getElementById(composedMessageID); if (!messageElement) { console.log(`couldn't find the message element`); return defaultMaxTextAreaHeight; } const msgPos = messageElement.getBoundingClientRect(); const containerPos = messageContainer.getBoundingClientRect(); const messageBottom = msgPos.bottom; const containerTop = containerPos.top; const maxHeight = messageBottom - containerTop - editBoxHeight - editBoxTopMargin; return maxHeight; }; willMessageEditWindowOverflow(composedMessageID: string) { const { messageContainer } = this; if (!messageContainer) { return false; } const messageElement = document.getElementById(composedMessageID); if (!messageElement) { console.log(`couldn't find the message element`); return false; } const msgPos = messageElement.getBoundingClientRect(); const containerPos = messageContainer.getBoundingClientRect(); const containerTop = containerPos.top; const containerBottom = containerPos.bottom; const availableTextAreaHeight = (containerBottom - containerTop) / 2 - editBoxHeight; const messageHeight = msgPos.height; const expectedMinimumHeight = Math.min( defaultMaxTextAreaHeight, availableTextAreaHeight, ); const offset = Math.max( 0, expectedMinimumHeight + editBoxHeight + editBoxTopMargin - messageHeight, ); const messageTop = msgPos.top - offset; const messageBottom = msgPos.bottom; return messageBottom > containerBottom || messageTop < containerTop; } render() { const { messageListData, threadInfo, inputState, isEditState } = 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.disableAnchor]: this.state.scrollingEndCallback !== null || isEditState, [css.messageContainer]: true, [css.mirroredMessageContainer]: !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(); this.debounceEditModeAfterScrollToMessage(); }; debounceEditModeAfterScrollToMessage = _debounce(() => { if (this.state.scrollingEndCallback) { this.state.scrollingEndCallback(); } this.setState({ scrollingEndCallback: null }); }, 100); async 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'); try { const { oldestMessageServerID } = this.props; if (oldestMessageServerID) { await this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } else { await this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadID), ); } } finally { this.loadingFromScroll = false; } } } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedChatMessageList: React.ComponentType = React.memo(function ConnectedChatMessageList( props: BaseProps, ): React.Node { const { threadInfo } = props; 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); + const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); + const getTextMessageMarkdownRules = useTextMessageRulesFunc( + threadInfo, + chatMentionCandidates, + ); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); const oldestMessageServerID = useOldestMessageServerID(threadInfo.id); const { editState, addScrollToMessageListener, removeScrollToMessageListener, } = useEditModalContext(); const isEditState = editState !== null; return ( ); }); export default ConnectedChatMessageList; diff --git a/web/chat/message-preview.react.js b/web/chat/message-preview.react.js index ab94618de..c00dbd7e7 100644 --- a/web/chat/message-preview.react.js +++ b/web/chat/message-preview.react.js @@ -1,79 +1,81 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { useMessagePreview } from 'lib/shared/message-utils.js'; +import { useThreadChatMentionCandidates } from 'lib/shared/thread-utils.js'; import { type MessageInfo } from 'lib/types/message-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import css from './chat-thread-list.css'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; type Props = { +messageInfo: ?MessageInfo, +threadInfo: ThreadInfo, }; function MessagePreview(props: Props): React.Node { const { messageInfo, threadInfo } = props; + const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const messagePreviewResult = useMessagePreview( messageInfo, threadInfo, - getDefaultTextMessageRules().simpleMarkdownRules, + getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); if (!messageInfo) { return (
No messages
); } invariant( messagePreviewResult, 'useMessagePreview should only return falsey if pass null or undefined', ); const { message, username } = messagePreviewResult; let usernameText = null; if (username) { let usernameStyle; if (username.style === 'unread') { usernameStyle = css.unread; } else if (username.style === 'secondary') { usernameStyle = css.messagePreviewSecondary; } invariant( usernameStyle, `MessagePreview doesn't support ${username.style} style for username, ` + 'only unread and secondary', ); usernameText = ( {`${username.text}: `} ); } let messageStyle; if (message.style === 'unread') { messageStyle = css.unread; } else if (message.style === 'primary') { messageStyle = css.messagePreviewPrimary; } else if (message.style === 'secondary') { messageStyle = css.messagePreviewSecondary; } invariant( messageStyle, `MessagePreview doesn't support ${message.style} style for message, ` + 'only unread, primary, and secondary', ); return (
{usernameText} {message.text}
); } export default MessagePreview; diff --git a/web/components/message-result.react.js b/web/components/message-result.react.js index ae0ecd4cf..7a5f88b97 100644 --- a/web/components/message-result.react.js +++ b/web/components/message-result.react.js @@ -1,65 +1,70 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { useThreadChatMentionCandidates } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import css from './message-result.css'; import { MessageListContext } from '../chat/message-list-types.js'; import Message from '../chat/message.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; type MessageResultProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +scrollable: boolean, }; function MessageResult(props: MessageResultProps): React.Node { const { item, threadInfo, scrollable } = props; - const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadInfo); + const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); + const getTextMessageMarkdownRules = useTextMessageRulesFunc( + threadInfo, + chatMentionCandidates, + ); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); const shouldShowUsername = !item.startsConversation && !item.startsCluster; const username = useStringForUser( shouldShowUsername ? item.messageInfo.creator : null, ); const messageContainerClassNames = classNames({ [css.messageContainer]: true, [css.messageContainerOverflow]: scrollable, }); return (
{username}
{longAbsoluteDate(item.messageInfo.time)}
); } export default MessageResult; diff --git a/web/markdown/rules.react.js b/web/markdown/rules.react.js index c4c51fcc0..5aa3dfefc 100644 --- a/web/markdown/rules.react.js +++ b/web/markdown/rules.react.js @@ -1,208 +1,219 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import * as SimpleMarkdown from 'simple-markdown'; import * as SharedMarkdown from 'lib/shared/markdown.js'; -import type { RelativeMemberInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + RelativeMemberInfo, + ThreadInfo, + ChatMentionCandidates, +} from 'lib/types/thread-types.js'; import MarkdownSpoiler from './markdown-spoiler.react.js'; export type MarkdownRules = { +simpleMarkdownRules: SharedMarkdown.ParserRules, +useDarkStyle: boolean, }; const linkRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const simpleMarkdownRules = { // We are using default simple-markdown rules // For more details, look at native/markdown/rules.react link: { ...SimpleMarkdown.defaultRules.link, match: () => null, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, paragraph: { ...SimpleMarkdown.defaultRules.paragraph, match: SimpleMarkdown.blockRegex(SharedMarkdown.paragraphRegex), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, text: SimpleMarkdown.defaultRules.text, url: { ...SimpleMarkdown.defaultRules.url, match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, }; return { simpleMarkdownRules: simpleMarkdownRules, useDarkStyle, }; }); const markdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const linkMarkdownRules = linkRules(useDarkStyle); const simpleMarkdownRules = { ...linkMarkdownRules.simpleMarkdownRules, autolink: SimpleMarkdown.defaultRules.autolink, link: { ...linkMarkdownRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SharedMarkdown.matchBlockQuote(SharedMarkdown.blockQuoteRegex), parse: SharedMarkdown.parseBlockQuote, }, spoiler: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex), parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { const content = capture[1]; return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, inlineCode: SimpleMarkdown.defaultRules.inlineCode, em: SimpleMarkdown.defaultRules.em, strong: SimpleMarkdown.defaultRules.strong, del: SimpleMarkdown.defaultRules.del, u: SimpleMarkdown.defaultRules.u, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex(SharedMarkdown.headingRegex), }, mailto: SimpleMarkdown.defaultRules.mailto, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex(SharedMarkdown.codeBlockRegex), parse: (capture: SharedMarkdown.Capture) => ({ content: capture[0].replace(/^ {4}/gm, ''), }), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex(SharedMarkdown.fenceRegex), parse: (capture: SharedMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SharedMarkdown.Capture) => { const jsonCapture: SharedMarkdown.JSONCapture = (capture: any); return { type: 'codeBlock', content: SharedMarkdown.jsonPrint(jsonCapture), }; }, }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...linkMarkdownRules, simpleMarkdownRules, useDarkStyle, }; }); function useTextMessageRulesFunc( threadInfo: ThreadInfo, + chatMentionCandidates: ChatMentionCandidates, ): boolean => MarkdownRules { const { members } = threadInfo; return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => - textMessageRules(members, useDarkStyle), + textMessageRules(members, chatMentionCandidates, useDarkStyle), ), - [members], + [chatMentionCandidates, members], ); } function textMessageRules( members: $ReadOnlyArray, + chatMentionCandidates: ChatMentionCandidates, useDarkStyle: boolean, ): MarkdownRules { const baseRules = markdownRules(useDarkStyle); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, mention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchMentions(members), parse: (capture: SharedMarkdown.Capture) => ({ content: capture[0], }), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => {node.content}, }, }, }; } let defaultTextMessageRules = null; -function getDefaultTextMessageRules(): MarkdownRules { +function getDefaultTextMessageRules( + overrideDefaultChatMentionCandidates: ChatMentionCandidates = {}, +): MarkdownRules { + if (Object.keys(overrideDefaultChatMentionCandidates).length > 0) { + return textMessageRules([], overrideDefaultChatMentionCandidates, false); + } if (!defaultTextMessageRules) { - defaultTextMessageRules = textMessageRules([], false); + defaultTextMessageRules = textMessageRules([], {}, false); } return defaultTextMessageRules; } export { linkRules, useTextMessageRulesFunc, getDefaultTextMessageRules }; diff --git a/web/modals/threads/sidebars/sidebar.react.js b/web/modals/threads/sidebars/sidebar.react.js index 9c9c7a9a9..255777465 100644 --- a/web/modals/threads/sidebars/sidebar.react.js +++ b/web/modals/threads/sidebars/sidebar.react.js @@ -1,101 +1,103 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useMessagePreview } from 'lib/shared/message-utils.js'; +import { useThreadChatMentionCandidates } from 'lib/shared/thread-utils.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './sidebars-modal.css'; import ThreadAvatar from '../../../avatars/thread-avatar.react.js'; import Button from '../../../components/button.react.js'; import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js'; import { useOnClickThread } from '../../../selectors/thread-selectors.js'; 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 { 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 previewTextClassName = classNames([ css.longTextEllipsis, css.avatarOffset, ]); const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTime), [lastUpdatedTime], ); + const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const messagePreviewResult = useMessagePreview( mostRecentMessageInfo, threadInfo, - getDefaultTextMessageRules().simpleMarkdownRules, + getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const lastMessage = React.useMemo(() => { if (!messagePreviewResult) { return
No messages
; } const { message, username } = messagePreviewResult; const previewText = username ? `${username.text}: ${message.text}` : message.text; return ( <>
{previewText}
{lastActivity}
); }, [lastActivity, messagePreviewResult, previewTextClassName]); const { uiName } = useResolvedThreadInfo(threadInfo); return ( ); } export default Sidebar; diff --git a/web/modals/threads/subchannels/subchannel.react.js b/web/modals/threads/subchannels/subchannel.react.js index 27f72c215..d726cd54d 100644 --- a/web/modals/threads/subchannels/subchannel.react.js +++ b/web/modals/threads/subchannels/subchannel.react.js @@ -1,89 +1,90 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { type ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useMessagePreview } from 'lib/shared/message-utils.js'; +import { useThreadChatMentionCandidates } from 'lib/shared/thread-utils.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './subchannels-modal.css'; import ThreadAvatar from '../../../avatars/thread-avatar.react.js'; import Button from '../../../components/button.react.js'; import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js'; import { useOnClickThread } from '../../../selectors/thread-selectors.js'; 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 { popModal } = useModalContext(); const navigateToThread = useOnClickThread(threadInfo); const onClickThread = React.useCallback( event => { popModal(); navigateToThread(event); }, [popModal, navigateToThread], ); const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTimeIncludingSidebars), [lastUpdatedTimeIncludingSidebars], ); - + const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const messagePreviewResult = useMessagePreview( mostRecentMessageInfo, threadInfo, - getDefaultTextMessageRules().simpleMarkdownRules, + getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const lastMessage = React.useMemo(() => { if (!messagePreviewResult) { return
No messages
; } const { message, username } = messagePreviewResult; const previewText = username ? `${username.text}: ${message.text}` : message.text; return ( <>
{previewText}
{lastActivity}
); }, [lastActivity, messagePreviewResult]); const { uiName } = useResolvedThreadInfo(threadInfo); return ( ); } export default Subchannel; diff --git a/web/selectors/thread-selectors.js b/web/selectors/thread-selectors.js index 78224eae3..0db2eb54c 100644 --- a/web/selectors/thread-selectors.js +++ b/web/selectors/thread-selectors.js @@ -1,152 +1,162 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { createPendingSidebar, threadInHomeChatList, + useThreadChatMentionCandidates, } from 'lib/shared/thread-utils.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { ThreadInfo, RawThreadInfo } from 'lib/types/thread-types.js'; import { values } from 'lib/utils/objects.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import type { AppState } from '../redux/redux-setup.js'; import { useSelector } from '../redux/redux-utils.js'; 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, tab: 'chat', }; } else { payload = { chatMode: 'view', activeChatThreadID: threadID, tab: 'chat', }; } 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) => mixed { const dispatch = useDispatch(); const loggedInUserInfo = useLoggedInUserInfo(); const cacheContext = React.useContext(ENSCacheContext); const { getENSNames } = cacheContext; + const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); return React.useCallback( async (event: SyntheticEvent) => { event.preventDefault(); if (!loggedInUserInfo) { return; } const pendingSidebarInfo = await createPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, - markdownRules: getDefaultTextMessageRules().simpleMarkdownRules, + markdownRules: getDefaultTextMessageRules(chatMentionCandidates) + .simpleMarkdownRules, getENSNames, }); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: pendingSidebarInfo.id, pendingThread: pendingSidebarInfo, }, }); }, - [loggedInUserInfo, messageInfo, threadInfo, dispatch, getENSNames], + [ + loggedInUserInfo, + chatMentionCandidates, + threadInfo, + messageInfo, + getENSNames, + 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 useDrawerSelectedThreadID(): ?string { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const pickedCommunityID = useSelector( state => state.communityPickerStore.calendar, ); const inCalendar = useSelector(state => state.navInfo.tab === 'calendar'); return inCalendar ? pickedCommunityID : activeChatThreadID; } const unreadCountInSelectedCommunity: (state: AppState) => number = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.communityPickerStore.chat, ( threadInfos: { +[id: string]: RawThreadInfo }, communityID: ?string, ): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread && (!communityID || communityID === threadInfo.community), ).length, ); export { useOnClickThread, useThreadIsActive, useOnClickPendingSidebar, useOnClickNewThread, useDrawerSelectedThreadID, unreadCountInSelectedCommunity, };