diff --git a/web/chat/chat-message-list.css b/web/chat/chat-message-list.css index e248662ee..137705a5f 100644 --- a/web/chat/chat-message-list.css +++ b/web/chat/chat-message-list.css @@ -1,488 +1,519 @@ div.container { margin-left: 400px; height: 100%; background-color: white; display: flex; flex-direction: column; box-sizing: border-box; } div.activeContainer { border: 2px solid #5989D6; margin-left: 402px; } +div.outerMessageContainer { + position: relative; + height: 100%; + min-height: 0; +} div.messageContainer { - flex-grow: 1; - flex: 1; + height: 100%; overflow-y: auto; display: flex; flex-direction: column-reverse; } div.mirroredMessageContainer { flex-direction: column !important; transform: scaleY(-1); } div.mirroredMessageContainer > div { transform: scaleY(-1); } div.message { display: flex; flex-direction: column; flex-shrink: 0; } div.inputBar { display: flex; flex-direction: column; border-top: 2px solid #E4E4E4; } div.inputBarTextInput { display: flex; padding: 10px; align-items: center; flex-shrink: 0; } div.inputBarTextInput > textarea { flex-grow: 1; border: none; font-family: sans-serif; font-size: 15px; line-height: 15px; width: 100%; resize: none; padding-right: 20px; min-height: 17px; } div.inputBarTextInput > textarea:focus { outline: none; } a.send { display: flex; align-items: center; text-transform: uppercase; font-family: 'Open Sans', sans-serif; font-weight: 600; color: #555555; cursor: pointer; } svg.sendButton { padding: 0 4px; font-size: 24px; color: #88BB88; } div.joinButtonContainer > a { margin: 3px 12px; padding: 3px 0 5px 0; display: flex; background-color: #44CC99; border-radius: 5px; justify-content: center; cursor: pointer; } span.joinButtonText { font-size: 18px; color: white; text-align: center; } span.explanation { color: #777777; text-align: center; padding-top: 4px; padding-bottom: 8px; } div.loading { text-align: center; padding: 12px; } div.conversationHeader { text-transform: uppercase; color: #777777; font-size: 14px; padding: 7px 0; text-align: center; } div.conversationHeader:last-child { padding-top: 6px; } div.robotextContainer { text-align: center; color: #333333; padding: 6px 0; margin: 0 40px 5px 40px; font-size: 15px; } div.innerRobotextContainer { display: inline-flex; position: relative; word-break: break-word; } div.messageTimestampTooltip { position: absolute; - margin-bottom: 8px; background-color: black; color: white; padding: 5px; border-radius: 5px; font-size: 14px; z-index: 1; pointer-events: none; + white-space: nowrap; } div.messageTimestampTooltip:after { content: ""; position: absolute; width: 0; height: 0; border-width: 7px; border-style: solid; } -div.messageTimestampLeftTooltip:after { +div.messageLeftTooltip { + transform: translate(0%, -50%); +} +div.messageRightTooltip { + transform: translate(0%, -50%); +} +div.messageLeftTooltip:after { top: 7px; right: -14px; border-color: transparent transparent transparent black; } -div.messageTimestampRightTooltip:after { +div.messageRightTooltip:after { top: 7px; left: -14px; border-color: transparent black transparent transparent; } -div.messageTimestampTopLeftTooltip:after { +div.messageTopLeftTooltip:after { bottom: -14px; left: 4px; border-color: black transparent transparent transparent; } -div.messageTimestampTopRightTooltip:after { +div.messageTopRightTooltip:after { bottom: -14px; right: 4px; border-color: black transparent transparent transparent; } -div.messageTimestampBottomLeftTooltip:after { +div.messageBottomLeftTooltip:after { top: -14px; left: 4px; border-color: transparent transparent black transparent; } -div.messageTimestampBottomRightTooltip:after { +div.messageBottomRightTooltip:after { top: -14px; right: 4px; border-color: transparent transparent black transparent; } div.messageActionLinks { position: absolute; display: flex; top: 0; bottom: 0; align-items: center; color: gray; -} -div.messageBoxContainer:hover div.messageActionLinks, -div.innerRobotextContainer:hover div.messageActionLinks { - padding: 0 8px; + padding: 0 6px; } div.viewerMessageActionLinks { right: 100%; } div.nonViewerMessageActionLinks { left: 100%; } div.messageActionLinks > div + div { - margin-left: 8px; + margin-left: 4px; } div.messageReplyTooltip { font-size: 14px; } div.messageSidebarTooltip { font-size: 16px; } div.messageTooltipIcon { + padding: 2px 3px; position: relative; } div.messageTooltipIcon:hover { cursor: pointer; } div.textMessage { padding: 6px 12px; white-space: pre-wrap; word-wrap: break-word; width: 100%; box-sizing: border-box; } div.normalTextMessage { font-size: 16px; font-family: sans-serif; } div.emojiOnlyTextMessage { font-size: 32px; font-family: sans-serif; } span.authorName { color: #777777; font-size: 14px; padding: 4px 24px; } div.darkTextMessage { color: white; } div.lightTextMessage { color: black; } div.darkTextMessage a { color: #129AFF; text-decoration: underline; } div.lightTextMessage a { color: #2A5DB0; text-decoration: underline; } div.content { display: flex; flex-shrink: 0; align-items: center; margin-bottom: 5px; box-sizing: border-box; width: 100%; } div.nonViewerContent { align-self: flex-start; justify-content: flex-start; padding-right: 8px; } div.viewerContent { align-self: flex-end; justify-content: flex-end; padding-right: 4px; } div.iconContainer { margin-right: 1px; } div.iconContainer > svg { height: 16px; } div.messageBoxContainer { position: relative; display: flex; max-width: calc(min(68%, 1000px)); margin: 0 4px 0 12px; } div.nonViewerMessageBoxContainer { justify-content: flex-start; } div.viewerMessageBoxContainer { justify-content: flex-end; } div.fixedWidthMessageBoxContainer { width: 68%; } div.messageBox { overflow: hidden; display: flex; flex-wrap: wrap; justify-content: space-between; flex-shrink: 0; max-width: 100%; } div.fixedWidthMessageBox { width: 100%; } div.failedSend { text-transform: uppercase; display: flex; justify-content: flex-end; flex-shrink: 0; font-size: 14px; margin-right: 30px; padding-bottom: 6px; } span.deliveryFailed { padding: 0 3px; color: #555555; } a.retrySend { padding: 0 3px; color: #036AFF; cursor: pointer; } a.multimediaUpload { cursor: pointer; position: relative; } a.multimediaUpload > input[type="file"] { visibility: hidden; position: absolute; top: 0; bottom: 0; left: 0; right: 0; margin: 0; padding: 0; } a.multimediaUpload > svg { padding: 0 6px; font-size: 20px; color: #8A8A8A; cursor: pointer; } div.previews { display: flex; overflow-x: auto; white-space: nowrap; } div.previews > span.multimedia { margin: 10px; } div.previews > span.multimedia > span.multimediaImage > img { max-height: 200px; max-width: 200px; } div.messageBox > div.imageGrid { display: grid; width: 100%; grid-template-columns: repeat(6, 1fr); grid-gap: 5px; } div.messageBox span.multimedia > span.multimediaImage { min-height: initial; min-width: initial; } div.messageBox span.multimedia > span.multimediaImage > img { max-height: 600px; } div.imageGrid > span.multimedia { grid-column-end: span 3; } div.imageGrid > span.multimedia:first-child { margin-top: 0; } div.imageGrid > span.multimedia > span.multimediaImage { flex: 1; } div.imageGrid > span.multimedia > span.multimediaImage:after { content: ""; display: block; padding-bottom: calc(min(600px, 100%)); } div.imageGrid > span.multimedia > span.multimediaImage > img { position: absolute; width: 100%; height: 100%; object-fit: cover; } div.imageGrid > span.multimedia:nth-last-child(n+3):first-child, div.imageGrid > span.multimedia:nth-last-child(n+3):first-child ~ * { grid-column-end: span 2; } div.imageGrid > span.multimedia:nth-last-child(n+4):first-child, div.imageGrid > span.multimedia:nth-last-child(n+4):first-child ~ * { grid-column-end: span 3; } div.imageGrid > span.multimedia:nth-last-child(n+5):first-child, div.imageGrid > span.multimedia:nth-last-child(n+5):first-child ~ * { grid-column-end: span 2; } div.inlineSidebarContent { flex-direction: row; display: flex; margin: 0 40px 0 20px; cursor: pointer; } div.inlineSidebar { flex-direction: row; display: flex; align-items: center; } div.inlineSidebarName { padding-Top: 1px; color: #666666; font-size: 16px; padding-left: 4px; padding-right: 2px; } div.unread { font-weight: bold; } div.centerContainer { justify-content: center; } div.sidebarMarginBottom { margin-bottom: 8px; } div.sidebarMarginTop { margin-top: 4px; margin-bottom: -8px; } svg.inlineSidebarIcon { color: #666666; } -.menuSidebarContent { - display: none; +div.menuSidebarContent { position: absolute; - top: 100%; - right: 0; z-index: 1; width: max-content; border-radius: 5px; - margin-top: 2px; - margin-right: -3px; overflow: visible; - box-shadow: unset; } -.menuSidebarContentVisible { - display: block; -} -.menuSidebarContent ul { +div.menuSidebarContent ul { list-style: none; } -.menuSidebarContent li:not(:last-child) { +div.menuSidebarContent li:not(:last-child) { border-bottom: 1px solid #DDDDDD; } -.menuSidebarContent:after { +div.menuSidebarContent:after { content: ""; position: absolute; border-width: 7px; border-style: solid; } -.menuSidebarContent button { +div.menuSidebarContent button { border: none; cursor: pointer; padding: 5px; font-size: 14px; outline: none; border-radius: 5px; background-color: black; color: white; text-decoration: underline; } -.menuSidebarContent button:hover { +div.menuSidebarContent button:hover { background-color: black; color: #129AFF; } -.menuSidebarNonViewerContent { - left: 0; - margin-left: -3px; -} -.menuSidebarContent:before { + +div.menuSidebarExtraAreaTop:before { height: 15px; width: 55px; content: ""; position: absolute; - top: -15px; + bottom: -15px; +} +div.menuSidebarExtraAreaTopRight:before { + right: 0; +} +div.menuSidebarExtraAreaTopLeft:before { + left: 0; +} +div.menuSidebarExtraArea:before { + height: 30px; + width: 20px; + content: ""; + position: absolute; +} +div.menuSidebarExtraAreaRight:before { + left: -20px; +} +div.menuSidebarExtraAreaLeft:before { + right: -20px; +} + +div.menuSidebarTopRightTooltip { + bottom: 100%; + margin-bottom: 1px; right: 0; } -.menuSidebarNonViewerContent:before { +div.menuSidebarTopLeftTooltip { + bottom: 100%; + margin-bottom: 1px; left: 0; } +div.menuSidebarLeftTooltip { + top: 50%; + right: 100%; + margin-right: 7px; +} +div.menuSidebarRightTooltip { + top: 50%; + left: 100%; + margin-left: 7px; +} diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 338e12ea5..798274d56 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,458 +1,481 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import * as React from 'react'; import { useDrop } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, } from 'lib/actions/message-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { messageKey } from 'lib/shared/message-utils'; import { useWatchThread, useCurrentThreadInfo } from 'lib/shared/thread-utils'; import type { FetchMessageInfosPayload } from 'lib/types/message-types'; import { type ThreadInfo } 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 ChatInputBar from './chat-input-bar.react'; import css from './chat-message-list.css'; import { MessageListContext } from './message-list-types'; -import type { - OnMessagePositionInfo, - MessagePositionInfo, -} from './message-position-types'; import MessageTimestampTooltip from './message-timestamp-tooltip.react'; import Message from './message.react'; +import type { + OnMessagePositionWithContainerInfo, + MessagePositionInfo, +} from './position-types'; type BaseProps = {| +setModal: (modal: ?React.Node) => void, |}; type PassedProps = {| ...BaseProps, // Redux state +activeChatThreadID: ?string, +threadInfo: ?ThreadInfo, +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, // withInputState +inputState: ?InputState, |}; type ReactDnDProps = {| isActive: boolean, connectDropTarget: (node: React.Node) => React.Node, |}; type Props = {| ...PassedProps, ...ReactDnDProps, |}; type State = {| - +mouseOverMessagePosition: ?OnMessagePositionInfo, + +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, |}; type Snapshot = {| +scrollTop: number, +scrollHeight: number, |}; class ChatMessageList extends React.PureComponent { state: State = { mouseOverMessagePosition: null, }; 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: State, 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, setModal } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; setMouseOverMessagePosition = (messagePositionInfo: MessagePositionInfo) => { if (!this.messageContainer) { return; } if (messagePositionInfo.type === 'off') { this.setState({ mouseOverMessagePosition: null }); return; } - const containerTop = this.messageContainer.getBoundingClientRect().top; + const { + top: containerTop, + bottom: containerBottom, + left: containerLeft, + right: containerRight, + height: containerHeight, + width: containerWidth, + } = this.messageContainer.getBoundingClientRect(); const mouseOverMessagePosition = { ...messagePositionInfo, messagePosition: { ...messagePositionInfo.messagePosition, top: messagePositionInfo.messagePosition.top - containerTop, bottom: messagePositionInfo.messagePosition.bottom - containerTop, + left: messagePositionInfo.messagePosition.left - containerLeft, + right: messagePositionInfo.messagePosition.right - containerLeft, + }, + containerPosition: { + top: containerTop, + bottom: containerBottom, + left: containerLeft, + right: containerRight, + height: containerHeight, + width: containerWidth, }, }; this.setState({ mouseOverMessagePosition }); }; render() { const { messageListData, threadInfo, inputState, connectDropTarget, isActive, setModal, } = this.props; if (!messageListData) { return
; } invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); const containerStyle = classNames({ [css.container]: true, [css.activeContainer]: isActive, }); - const tooltip = ( - - ); + let tooltip; + if (this.state.mouseOverMessagePosition) { + const messagePositionInfo = this.state.mouseOverMessagePosition; + tooltip = ( + + ); + } const messageContainerStyle = classNames({ [css.messageContainer]: true, [css.mirroredMessageContainer]: !this.props.supportsReverseFlex, }); return connectDropTarget(
-
- {messages} +
+
+ {messages} +
{tooltip}
, ); } containerRef = (container: ?HTMLDivElement) => { if (container) { container.addEventListener('paste', this.onPaste); } this.container = container; }; onPaste = (e: ClipboardEvent) => { const { inputState } = this.props; if (!inputState) { return; } const { clipboardData } = e; if (!clipboardData) { return; } const { files } = clipboardData; if (files.length === 0) { return; } e.preventDefault(); inputState.appendFiles([...files]); }; 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; } if (this.state.mouseOverMessagePosition) { this.setState({ mouseOverMessagePosition: null }); } this.possiblyLoadMoreMessages(); }; possiblyLoadMoreMessages() { if (!this.messageContainer) { return; } const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; if ( this.props.startReached || Math.abs(scrollTop) + clientHeight + 55 < scrollHeight ) { return; } const oldestMessageServerID = this.oldestMessageServerID(); if (!oldestMessageServerID) { return; } if (this.loadingFromScroll) { return; } this.loadingFromScroll = true; const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } 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); export default React.memo(function ConnectedChatMessageList( props: BaseProps, ) { 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 activeChatThreadID = useSelector( (state) => state.navInfo.activeChatThreadID, ); const baseThreadInfo = useSelector((state) => { const activeID = state.navInfo.activeChatThreadID; if (!activeID) { return null; } return threadInfoSelector(state)[activeID] ?? state.navInfo.pendingThread; }); const sourceMessageID = useSelector((state) => state.navInfo.sourceMessageID); const threadInfo = useCurrentThreadInfo({ baseThreadInfo, searching: false, userInfoInputArray: [], sourceMessageID, }); const messageListData = useMessageListData({ sourceMessageID, searching: false, userInfoInputArray: [], threadInfo, }); const startReached = useSelector((state) => { const activeID = state.navInfo.activeChatThreadID; if (!activeID) { return null; } if (state.navInfo.pendingThread) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const inputState = React.useContext(InputStateContext); const [dndProps, connectDropTarget] = useDrop({ accept: NativeTypes.FILE, drop: (item) => { const { files } = item; if (inputState && files.length > 0) { inputState.appendFiles(files); } }, collect: (monitor) => ({ isActive: monitor.isOver() && monitor.canDrop(), }), }); const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadInfo?.id); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); useWatchThread(threadInfo); return ( ); }); diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index 58cf54eff..a291ea6e6 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,244 +1,261 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { Circle as CircleIcon, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon, } from 'react-feather'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import { assertComposableMessageType } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { type InputState, InputStateContext } from '../input/input-state'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react'; import { InlineSidebar } from './inline-sidebar.react'; +import MessageReplyTooltip from './message-reply-tooltip.react'; import { - type OnMessagePositionInfo, + type OnMessagePositionWithContainerInfo, type MessagePositionInfo, -} from './message-position-types'; -import MessageReplyTooltip from './message-reply-tooltip.react'; -import SidebarTooltip from './sidebar-tooltip.react'; +} from './position-types'; +import MessageActionTooltip from './sidebar-tooltip.react'; +import { tooltipPositions } from './tooltip-utils'; + +const availableTooltipPositionsForViewerMessage = [ + tooltipPositions.TOP_RIGHT, + tooltipPositions.LEFT, +]; +const availableTooltipPositionsForNonViewerMessage = [ + tooltipPositions.TOP_LEFT, + tooltipPositions.RIGHT, +]; type BaseProps = {| +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +sendFailed: boolean, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, - +mouseOverMessagePosition?: ?OnMessagePositionInfo, + +mouseOverMessagePosition?: ?OnMessagePositionWithContainerInfo, +canReply: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius: number, |}; type BaseConfig = React.Config; type Props = {| ...BaseProps, // Redux state +sidebarExistsOrCanBeCreated: boolean, // withInputState +inputState: ?InputState, |}; class ComposedMessage extends React.PureComponent { static defaultProps = { borderRadius: 8, }; render() { assertComposableMessageType(this.props.item.messageInfo.type); const { borderRadius, item, threadInfo } = this.props; const { id, creator } = item.messageInfo; const threadColor = threadInfo.color; const { isViewer } = creator; const contentClassName = classNames({ [css.content]: true, [css.viewerContent]: isViewer, [css.nonViewerContent]: !isViewer, }); const messageBoxContainerClassName = classNames({ [css.messageBoxContainer]: true, [css.viewerMessageBoxContainer]: isViewer, [css.nonViewerMessageBoxContainer]: !isViewer, [css.fixedWidthMessageBoxContainer]: this.props.fixedWidth, }); const messageBoxClassName = classNames({ [css.messageBox]: true, [css.fixedWidthMessageBox]: this.props.fixedWidth, }); const messageBoxStyle = { borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius, borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius, }; let authorName = null; if (!isViewer && item.startsCluster) { authorName = ( {stringForUser(creator)} ); } let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconSpan; let deliveryIconColor = threadColor; if (id !== null && id !== undefined) { deliveryIconSpan = ; } else if (this.props.sendFailed) { deliveryIconSpan = ; deliveryIconColor = 'FF0000'; failedSendInfo = ; } else { deliveryIconSpan = ; } deliveryIcon = (
{deliveryIconSpan}
); } let replyTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && this.props.canReply ) { const { inputState } = this.props; invariant(inputState, 'inputState should be set in ComposedMessage'); replyTooltip = ( ); } - const positioning = isViewer ? 'right' : 'left'; - let sidebarTooltip; + let messageActionTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && this.props.sidebarExistsOrCanBeCreated ) { - sidebarTooltip = ( - ); } let viewerActionLinks, nonViewerActionLinks; - if (isViewer) { + if (isViewer && (replyTooltip || messageActionTooltip)) { viewerActionLinks = (
- {sidebarTooltip} + {messageActionTooltip} {replyTooltip}
); - } else { + } else if (replyTooltip || messageActionTooltip) { nonViewerActionLinks = (
{replyTooltip} - {sidebarTooltip} + {messageActionTooltip}
); } let inlineSidebar = null; if (item.threadCreatedFromMessage) { + const positioning = isViewer ? 'right' : 'left'; inlineSidebar = (
); } return ( {authorName}
{viewerActionLinks}
{this.props.children}
{nonViewerActionLinks}
{deliveryIcon}
{failedSendInfo} {inlineSidebar}
); } onMouseEnter = (event: SyntheticEvent) => { const { item } = this.props; const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; this.props.setMouseOverMessagePosition({ type: 'on', item, messagePosition, }); }; onMouseLeave = () => { const { item } = this.props; this.props.setMouseOverMessagePosition({ type: 'off', item }); }; } export default React.memo(function ConnectedComposedMessage( props: BaseConfig, ) { const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( props.threadInfo, props.item, ); const inputState = React.useContext(InputStateContext); return ( ); }); diff --git a/web/chat/message-position-types.js b/web/chat/message-position-types.js deleted file mode 100644 index 3b760a25d..000000000 --- a/web/chat/message-position-types.js +++ /dev/null @@ -1,23 +0,0 @@ -// @flow - -import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; - -export type OnMessagePositionInfo = {| - +type: 'on', - +item: ChatMessageInfoItem, - +messagePosition: {| - +top: number, - +bottom: number, - +left: number, - +right: number, - +height: number, - +width: number, - |}, -|}; - -export type MessagePositionInfo = - | OnMessagePositionInfo - | {| - +type: 'off', - +item: ChatMessageInfoItem, - |}; diff --git a/web/chat/message-reply-tooltip.react.js b/web/chat/message-reply-tooltip.react.js index 29a59aca8..d51f53077 100644 --- a/web/chat/message-reply-tooltip.react.js +++ b/web/chat/message-reply-tooltip.react.js @@ -1,46 +1,46 @@ // @flow import { faReply } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { createMessageReply } from 'lib/shared/message-utils'; import type { InputState } from '../input/input-state'; import css from './chat-message-list.css'; -import type { OnMessagePositionInfo } from './message-position-types'; +import type { OnMessagePositionWithContainerInfo } from './position-types'; type Props = {| - +messagePositionInfo: OnMessagePositionInfo, + +messagePositionInfo: OnMessagePositionWithContainerInfo, +onReplyClick: () => void, +inputState: InputState, |}; function MessageReplyTooltip(props: Props) { const { inputState, onReplyClick, messagePositionInfo } = props; const { addReply } = inputState; const { item } = messagePositionInfo; const replyClicked = React.useCallback(() => { invariant(item.messageInfo.text, 'text should be set in message clicked'); addReply(createMessageReply(item.messageInfo.text)); onReplyClick(); }, [addReply, item, onReplyClick]); const { isViewer } = item.messageInfo.creator; const replyTooltipClassName = classNames({ [css.messageReplyTooltip]: true, [css.tooltipRightPadding]: isViewer, [css.tooltipLeftPadding]: !isViewer, }); return (
); } export default MessageReplyTooltip; diff --git a/web/chat/message-timestamp-tooltip.react.js b/web/chat/message-timestamp-tooltip.react.js index 86c8d8a8b..4bdbfddf1 100644 --- a/web/chat/message-timestamp-tooltip.react.js +++ b/web/chat/message-timestamp-tooltip.react.js @@ -1,108 +1,146 @@ // @flow import classNames from 'classnames'; -import invariant from 'invariant'; import * as React from 'react'; import { isComposableMessageType } from 'lib/types/message-types'; import { longAbsoluteDate } from 'lib/utils/date-utils'; -import { calculateTextWidth } from '../utils/text-utils'; import css from './chat-message-list.css'; -import type { OnMessagePositionInfo } from './message-position-types'; +import type { OnMessagePositionWithContainerInfo } from './position-types'; +import { + type TooltipPosition, + tooltipPositions, + findTooltipPosition, + sizeOfTooltipArrow, +} from './tooltip-utils'; + +const availablePositionsForComposedViewerMessage = [ + tooltipPositions.BOTTOM_RIGHT, +]; +const availablePositionsForNonComposedOrNonViewerMessage = [ + tooltipPositions.LEFT, +]; type Props = {| - messagePositionInfo: ?OnMessagePositionInfo, - timeZone: ?string, + +messagePositionInfo: OnMessagePositionWithContainerInfo, + +timeZone: ?string, |}; function MessageTimestampTooltip(props: Props) { - if (!props.messagePositionInfo) { - return null; - } - const { item, messagePosition } = props.messagePositionInfo; - const text = longAbsoluteDate(item.messageInfo.time, props.timeZone); + const { messagePositionInfo, timeZone } = props; + const { time, creator, type } = messagePositionInfo.item.messageInfo; - const font = - '14px -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", ' + - '"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", ' + - '"Helvetica Neue", sans-serif'; - const textWidth = calculateTextWidth(text, font); - const width = textWidth + 10; // 10px padding - const sizeOfArrow = 10; // 7px arrow + 3px extra - const widthWithArrow = width + sizeOfArrow; - const height = 27; // 17px line-height + 10px padding - const heightWithArrow = height + sizeOfArrow; + const text = React.useMemo(() => longAbsoluteDate(time, timeZone), [ + time, + timeZone, + ]); + const availableTooltipPositions = React.useMemo(() => { + const { isViewer } = creator; + const isComposed = isComposableMessageType(type); + return isComposed && isViewer + ? availablePositionsForComposedViewerMessage + : availablePositionsForNonComposedOrNonViewerMessage; + }, [creator, type]); - const { isViewer } = item.messageInfo.creator; - const isComposed = isComposableMessageType(item.messageInfo.type); - let align = isComposed && isViewer ? 'right' : 'left'; - if (align === 'right') { - if (messagePosition.top < 0) { - align = 'bottom-right'; - } else if (messagePosition.right + width > window.innerWidth) { - align = 'top-right'; - } - } else if (align === 'left') { - if (messagePosition.top < 0) { - align = 'bottom-left'; - } else if (messagePosition.left - width < 0) { - align = 'top-left'; - } - } + const { messagePosition, containerPosition } = messagePositionInfo; + const pointingToInfo = React.useMemo(() => { + return { + containerPosition, + itemPosition: messagePosition, + }; + }, [messagePosition, containerPosition]); + + const tooltipPosition = React.useMemo( + () => + findTooltipPosition({ + pointingToInfo, + text, + availablePositions: availableTooltipPositions, + layoutPosition: 'absolute', + }), + [availableTooltipPositions, pointingToInfo, text], + ); + const { style, className } = React.useMemo( + () => getTimestampTooltipStyle(messagePositionInfo, tooltipPosition), + [messagePositionInfo, tooltipPosition], + ); + + return ( +
+ {text} +
+ ); +} + +function getTimestampTooltipStyle( + messagePositionInfo: OnMessagePositionWithContainerInfo, + tooltipPosition: TooltipPosition, +) { + const { messagePosition, containerPosition } = messagePositionInfo; + const { height: containerHeight, width: containerWidth } = containerPosition; let style, className; - if (align === 'left') { + if (tooltipPosition === tooltipPositions.LEFT) { const centerOfMessage = messagePosition.top + messagePosition.height / 2; - const topOfTooltip = centerOfMessage - height / 2; + const tooltipPointing = Math.max( + Math.min(centerOfMessage, containerHeight), + 0, + ); style = { - left: messagePosition.left - widthWithArrow, - top: topOfTooltip, + right: containerWidth - messagePosition.left + sizeOfTooltipArrow, + top: tooltipPointing, }; - className = css.messageTimestampLeftTooltip; - } else if (align === 'right') { + className = css.messageLeftTooltip; + } else if (tooltipPosition === tooltipPositions.RIGHT) { const centerOfMessage = messagePosition.top + messagePosition.height / 2; - const topOfTooltip = centerOfMessage - height / 2; + const tooltipPointing = Math.max( + Math.min(centerOfMessage, containerHeight), + 0, + ); style = { - // 10 = 7px arrow + 3px extra - left: messagePosition.left + messagePosition.width + 10, - top: topOfTooltip, + left: messagePosition.right + sizeOfTooltipArrow, + top: tooltipPointing, }; - className = css.messageTimestampRightTooltip; - } else if (align === 'top-left') { + className = css.messageRightTooltip; + } else if (tooltipPosition === tooltipPositions.TOP_LEFT) { + const tooltipPointing = Math.min( + containerHeight - messagePosition.top, + containerHeight, + ); style = { left: messagePosition.left, - top: messagePosition.top - heightWithArrow, + bottom: tooltipPointing + sizeOfTooltipArrow, }; - className = css.messageTimestampTopLeftTooltip; - } else if (align === 'top-right') { + className = css.messageTopLeftTooltip; + } else if (tooltipPosition === tooltipPositions.TOP_RIGHT) { + const tooltipPointing = Math.min( + containerHeight - messagePosition.top, + containerHeight, + ); style = { - left: messagePosition.right - width, - top: messagePosition.top - heightWithArrow, + right: containerWidth - messagePosition.right, + bottom: tooltipPointing + sizeOfTooltipArrow, }; - className = css.messageTimestampTopRightTooltip; - } else if (align === 'bottom-left') { + className = css.messageTopRightTooltip; + } else if (tooltipPosition === tooltipPositions.BOTTOM_LEFT) { + const tooltipPointing = Math.min(messagePosition.bottom, containerHeight); style = { left: messagePosition.left, - top: messagePosition.top + messagePosition.height + sizeOfArrow, + top: tooltipPointing + sizeOfTooltipArrow, }; - className = css.messageTimestampBottomLeftTooltip; - } else if (align === 'bottom-right') { + className = css.messageBottomLeftTooltip; + } else if (tooltipPosition === tooltipPositions.BOTTOM_RIGHT) { + const tooltipPointing = Math.min(messagePosition.bottom, containerHeight); style = { - left: messagePosition.right - width, - top: messagePosition.top + messagePosition.height + sizeOfArrow, + right: containerWidth - messagePosition.right, + top: tooltipPointing + sizeOfTooltipArrow, }; - className = css.messageTimestampBottomRightTooltip; + className = css.messageBottomRightTooltip; } - invariant(style, 'should be set'); - - return ( -
- {text} -
- ); + return { style, className }; } export default MessageTimestampTooltip; diff --git a/web/chat/message.react.js b/web/chat/message.react.js index c74f21071..0f7f64e74 100644 --- a/web/chat/message.react.js +++ b/web/chat/message.react.js @@ -1,85 +1,85 @@ // @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 { - type OnMessagePositionInfo, + type OnMessagePositionWithContainerInfo, type MessagePositionInfo, -} from './message-position-types'; -import MultimediaMessage from './multimedia-message.react'; +} from './position-types'; import RobotextMessage from './robotext-message.react'; import TextMessage from './text-message.react'; type Props = {| item: ChatMessageInfoItem, threadInfo: ThreadInfo, setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, - mouseOverMessagePosition: ?OnMessagePositionInfo, + mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, setModal: (modal: ?React.Node) => void, timeZone: ?string, |}; class Message extends React.PureComponent { render() { const { item, timeZone } = this.props; let conversationHeader = null; if (item.startsConversation) { conversationHeader = (
{longAbsoluteDate(item.messageInfo.time, timeZone)}
); } 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/chat/multimedia-message.react.js b/web/chat/multimedia-message.react.js index 82416f592..6b6bfb4c8 100644 --- a/web/chat/multimedia-message.react.js +++ b/web/chat/multimedia-message.react.js @@ -1,93 +1,93 @@ // @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 { type InputState, InputStateContext } from '../input/input-state'; import Multimedia from '../media/multimedia.react'; import css from './chat-message-list.css'; import ComposedMessage from './composed-message.react'; +import sendFailed from './multimedia-message-send-failed'; import type { MessagePositionInfo, - OnMessagePositionInfo, -} from './message-position-types'; -import sendFailed from './multimedia-message-send-failed'; + OnMessagePositionWithContainerInfo, +} from './position-types'; type BaseProps = {| +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, - +mouseOverMessagePosition: ?OnMessagePositionInfo, + +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, +setModal: (modal: ?React.Node) => void, |}; type Props = {| ...BaseProps, // withInputState +inputState: ?InputState, |}; class MultimediaMessage extends React.PureComponent { render() { const { item, setModal, inputState } = this.props; invariant( item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA, 'MultimediaMessage should only be used for multimedia messages', ); const { localID, media } = item.messageInfo; invariant(inputState, 'inputState should be set in MultimediaMessage'); const pendingUploads = localID ? inputState.assignedUploads[localID] : null; const multimedia = []; for (const singleMedia of media) { const pendingUpload = pendingUploads ? pendingUploads.find((upload) => upload.localID === singleMedia.id) : null; multimedia.push( , ); } invariant(multimedia.length > 0, 'should be at least one multimedia...'); const content = multimedia.length > 1 ? (
{multimedia}
) : ( multimedia ); return ( 1} borderRadius={16} > {content} ); } } export default React.memo(function ConnectedMultimediaMessage( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); return ; }); diff --git a/web/chat/position-types.js b/web/chat/position-types.js new file mode 100644 index 000000000..6d8d8d7c3 --- /dev/null +++ b/web/chat/position-types.js @@ -0,0 +1,37 @@ +// @flow + +import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; + +export type OnMessagePositionWithContainerInfo = {| + +type: 'on', + +item: ChatMessageInfoItem, + +messagePosition: PositionInfo, + +containerPosition: PositionInfo, +|}; + +export type OnMessagePositionInfo = {| + +type: 'on', + +item: ChatMessageInfoItem, + +messagePosition: PositionInfo, +|}; + +export type MessagePositionInfo = + | OnMessagePositionInfo + | {| + +type: 'off', + +item: ChatMessageInfoItem, + |}; + +export type ItemAndContainerPositionInfo = {| + +itemPosition: PositionInfo, + +containerPosition: PositionInfo, +|}; + +export type PositionInfo = {| + +top: number, + +bottom: number, + +left: number, + +right: number, + +width: number, + +height: number, +|}; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index c9492ba11..d9760077c 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,204 +1,221 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { splitRobotext, parseRobotextEntity } from 'lib/shared/message-utils'; import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; import { updateNavInfoActionType } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import css from './chat-message-list.css'; import { InlineSidebar } from './inline-sidebar.react'; import type { MessagePositionInfo, - OnMessagePositionInfo, -} from './message-position-types'; -import SidebarTooltip from './sidebar-tooltip.react'; + OnMessagePositionWithContainerInfo, +} from './position-types'; +import MessageActionTooltip from './sidebar-tooltip.react'; +import { tooltipPositions } from './tooltip-utils'; + +const availableTooltipPositionsForRobotext = [ + tooltipPositions.TOP_RIGHT, + tooltipPositions.RIGHT, + tooltipPositions.LEFT, +]; type BaseProps = {| +item: RobotextChatMessageInfoItem, +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, - +mouseOverMessagePosition: ?OnMessagePositionInfo, + +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, |}; type Props = {| ...BaseProps, // Redux state +sidebarExistsOrCanBeCreated: boolean, |}; class RobotextMessage extends React.PureComponent { render() { let inlineSidebar; if (this.props.item.threadCreatedFromMessage) { inlineSidebar = (
); } const { item, threadInfo, sidebarExistsOrCanBeCreated } = this.props; const { id } = item.messageInfo; - let sidebarTooltip; + let messageActionTooltip; if ( this.props.mouseOverMessagePosition && this.props.mouseOverMessagePosition.item.messageInfo.id === id && sidebarExistsOrCanBeCreated ) { - sidebarTooltip = ( - ); } + let messageActionLinks; + if (messageActionTooltip) { + messageActionLinks = ( +
+ {messageActionTooltip} +
+ ); + } + return (
{this.linkedRobotext()} -
- {sidebarTooltip} -
+ {messageActionLinks}
{inlineSidebar}
); } linkedRobotext() { const { item } = this.props; const { robotext } = item; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (const splitPart of robotextParts) { if (splitPart === '') { continue; } if (splitPart.charAt(0) !== '<') { const key = `text${keyIndex++}`; textParts.push( {decodeURI(splitPart)} , ); continue; } const { rawText, entityType, id } = parseRobotextEntity(splitPart); if (entityType === 't' && id !== item.messageInfo.threadID) { textParts.push(); } else if (entityType === 'c') { textParts.push(); } else { textParts.push(rawText); } } return textParts; } onMouseEnter = (event: SyntheticEvent) => { const { item } = this.props; const rect = event.currentTarget.getBoundingClientRect(); const { top, bottom, left, right, height, width } = rect; const messagePosition = { top, bottom, left, right, height, width }; this.props.setMouseOverMessagePosition({ type: 'on', item, messagePosition, }); }; onMouseLeave = () => { const { item } = this.props; this.props.setMouseOverMessagePosition({ type: 'off', item }); }; } type BaseInnerThreadEntityProps = {| +id: string, +name: string, |}; type InnerThreadEntityProps = {| ...BaseInnerThreadEntityProps, +threadInfo: ThreadInfo, +dispatch: Dispatch, |}; class InnerThreadEntity extends React.PureComponent { render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: id, }, }); }; } const ThreadEntity = React.memo( function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) { const { id } = props; const threadInfo = useSelector((state) => threadInfoSelector(state)[id]); const dispatch = useDispatch(); return ( ); }, ); function ColorEntity(props: {| color: string |}) { const colorStyle = { color: props.color }; return {props.color}; } export default React.memo(function ConnectedRobotextMessage( props: BaseProps, ) { const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( props.threadInfo, props.item, ); return ( ); }); diff --git a/web/chat/sidebar-tooltip.react.js b/web/chat/sidebar-tooltip.react.js index eea0b12e5..a3af480b1 100644 --- a/web/chat/sidebar-tooltip.react.js +++ b/web/chat/sidebar-tooltip.react.js @@ -1,141 +1,287 @@ // @flow import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; +import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { type ComposableMessageInfo, type RobotextMessageInfo, } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { useOnClickThread, useOnClickPendingSidebar, } from '../selectors/nav-selectors'; import css from './chat-message-list.css'; +import type { + ItemAndContainerPositionInfo, + PositionInfo, +} from './position-types'; +import { + findTooltipPosition, + tooltipPositions, + type TooltipPosition, +} from './tooltip-utils'; + +const ellipsisIconExcessVerticalWhitespace = 10; type Props = {| +onLeave: () => void, +onButtonClick: (event: SyntheticEvent) => void, - +messagePosition: 'left' | 'center' | 'right', +buttonText: string, + +containerPosition: PositionInfo, + +availableTooltipPositions: $ReadOnlyArray, |}; function SidebarTooltipButton(props: Props) { - const { onLeave, onButtonClick, messagePosition, buttonText } = props; + const { + onLeave, + onButtonClick, + buttonText, + containerPosition, + availableTooltipPositions, + } = props; const [tooltipVisible, setTooltipVisible] = React.useState(false); + const [pointingTo, setPointingTo] = React.useState(); - const toggleMenu = React.useCallback(() => { - setTooltipVisible(!tooltipVisible); - }, [tooltipVisible]); + const toggleMenu = React.useCallback( + (event: SyntheticEvent) => { + setTooltipVisible(!tooltipVisible); + if (tooltipVisible) { + return; + } + const rect = event.currentTarget.getBoundingClientRect(); + const { top, bottom, left, right, width, height } = rect; + + const dotsPosition: ItemAndContainerPositionInfo = { + containerPosition, + itemPosition: { + top: + top - containerPosition.top + ellipsisIconExcessVerticalWhitespace, + bottom: + bottom - + containerPosition.top - + ellipsisIconExcessVerticalWhitespace, + left: left - containerPosition.left, + right: right - containerPosition.left, + width, + height: height - ellipsisIconExcessVerticalWhitespace * 2, + }, + }; + setPointingTo(dotsPosition); + }, + [containerPosition, tooltipVisible], + ); const toggleSidebar = React.useCallback( (event: SyntheticEvent) => { onButtonClick(event); onLeave(); }, [onLeave, onButtonClick], ); const hideMenu = React.useCallback(() => { setTooltipVisible(false); }, []); - const sidebarMenuClassName = classNames({ - [css.menuSidebarContent]: true, - [css.menuSidebarContentVisible]: tooltipVisible, - [css.menuSidebarNonViewerContent]: messagePosition === 'left', - [css.messageTimestampBottomRightTooltip]: messagePosition !== 'left', - [css.messageTimestampBottomLeftTooltip]: messagePosition === 'left', - }); + const tooltipPosition = React.useMemo(() => { + if (!pointingTo) { + return null; + } + return findTooltipPosition({ + pointingToInfo: pointingTo, + text: buttonText, + availablePositions: availableTooltipPositions, + layoutPosition: 'relative', + }); + }, [availableTooltipPositions, pointingTo, buttonText]); + + const sidebarTooltip = React.useMemo(() => { + if (!tooltipVisible || !tooltipPosition) { + return null; + } + return ( + + ); + }, [buttonText, toggleSidebar, tooltipPosition, tooltipVisible]); return (
-
-
    -
  • - -
  • -
-
+ {sidebarTooltip}
); } +type TooltipButtonProps = {| + +onButtonClick: (event: SyntheticEvent) => void, + +buttonText: string, + +tooltipPosition: TooltipPosition, +|}; +function TooltipButton(props: TooltipButtonProps) { + const { onButtonClick, buttonText, tooltipPosition } = props; + const sidebarStyle = React.useMemo( + () => getSidebarTooltipStyle(tooltipPosition), + [tooltipPosition], + ); + + const sidebarMenuClassName = React.useMemo( + () => classNames(css.menuSidebarContent, sidebarStyle), + [sidebarStyle], + ); + + return ( +
+
    +
  • + +
  • +
+
+ ); +} + +const openSidebarText = 'Go to sidebar'; type OpenSidebarProps = {| +threadCreatedFromMessage: ThreadInfo, +onLeave: () => void, - +messagePosition: 'left' | 'center' | 'right', + +containerPosition: PositionInfo, + +availableTooltipPositions: $ReadOnlyArray, |}; function OpenSidebar(props: OpenSidebarProps) { - const onButtonClick = useOnClickThread(props.threadCreatedFromMessage.id); + const { + threadCreatedFromMessage, + onLeave, + containerPosition, + availableTooltipPositions, + } = props; + const onButtonClick = useOnClickThread(threadCreatedFromMessage.id); + return ( ); } +const createSidebarText = 'Create sidebar'; type CreateSidebarProps = {| +threadInfo: ThreadInfo, +messageInfo: ComposableMessageInfo | RobotextMessageInfo, +onLeave: () => void, - +messagePosition: 'left' | 'center' | 'right', + +containerPosition: PositionInfo, + +availableTooltipPositions: $ReadOnlyArray, |}; function CreateSidebar(props: CreateSidebarProps) { - const onButtonClick = useOnClickPendingSidebar( - props.messageInfo, - props.threadInfo, - ); + const { + threadInfo, + messageInfo, + containerPosition, + availableTooltipPositions, + } = props; + const onButtonClick = useOnClickPendingSidebar(messageInfo, threadInfo); + return ( ); } -type SidebarTooltipProps = {| +type MessageActionTooltipProps = {| +threadInfo: ThreadInfo, +item: ChatMessageInfoItem, +onLeave: () => void, - +messagePosition: 'left' | 'center' | 'right', + +containerPosition: PositionInfo, + +availableTooltipPositions: $ReadOnlyArray, |}; -function SidebarTooltip(props: SidebarTooltipProps) { - const { threadInfo, item, onLeave, messagePosition } = props; +function MessageActionTooltip(props: MessageActionTooltipProps) { + const { + threadInfo, + item, + onLeave, + containerPosition, + availableTooltipPositions, + } = props; if (item.threadCreatedFromMessage) { return ( ); } else { return ( ); } } -export default SidebarTooltip; +function getSidebarTooltipStyle(tooltipPosition: TooltipPosition): string { + let className; + if (tooltipPosition === tooltipPositions.TOP_RIGHT) { + className = classNames( + css.menuSidebarTopRightTooltip, + css.messageTopRightTooltip, + css.menuSidebarExtraAreaTop, + css.menuSidebarExtraAreaTopRight, + ); + } else if (tooltipPosition === tooltipPositions.TOP_LEFT) { + className = classNames( + css.menuSidebarTopLeftTooltip, + css.messageTopLeftTooltip, + css.menuSidebarExtraAreaTop, + css.menuSidebarExtraAreaTopLeft, + ); + } else if (tooltipPosition === tooltipPositions.RIGHT) { + className = classNames( + css.menuSidebarRightTooltip, + css.messageRightTooltip, + css.menuSidebarExtraArea, + css.menuSidebarExtraAreaRight, + ); + } else if (tooltipPosition === tooltipPositions.LEFT) { + className = classNames( + css.menuSidebarLeftTooltip, + css.messageLeftTooltip, + css.menuSidebarExtraArea, + css.menuSidebarExtraAreaLeft, + ); + } + + invariant(className, `${tooltipPosition} is not valid for sidebar tooltip`); + return className; +} + +export default MessageActionTooltip; diff --git a/web/chat/text-message.react.js b/web/chat/text-message.react.js index 33523edbb..f00ecd230 100644 --- a/web/chat/text-message.react.js +++ b/web/chat/text-message.react.js @@ -1,83 +1,83 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { onlyEmojiRegex } from 'lib/shared/emojis'; import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; import Markdown from '../markdown/markdown.react'; import css from './chat-message-list.css'; import ComposedMessage from './composed-message.react'; import { MessageListContext } from './message-list-types'; import type { MessagePositionInfo, - OnMessagePositionInfo, -} from './message-position-types'; + OnMessagePositionWithContainerInfo, +} from './position-types'; import textMessageSendFailed from './text-message-send-failed'; type Props = {| +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, - +mouseOverMessagePosition: ?OnMessagePositionInfo, + +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, |}; function TextMessage(props: Props) { invariant( props.item.messageInfo.type === messageTypes.TEXT, 'TextMessage should only be used for messageTypes.TEXT', ); const { text, creator: { isViewer }, } = props.item.messageInfo; const messageStyle = {}; let darkColor = false; if (isViewer) { const threadColor = props.threadInfo.color; darkColor = colorIsDark(threadColor); messageStyle.backgroundColor = `#${threadColor}`; } else { messageStyle.backgroundColor = 'rgba(221,221,221,0.73)'; } const onlyEmoji = onlyEmojiRegex.test(text); const messageClassName = classNames({ [css.textMessage]: true, [css.normalTextMessage]: !onlyEmoji, [css.emojiOnlyTextMessage]: onlyEmoji, [css.darkTextMessage]: darkColor, [css.lightTextMessage]: !darkColor, }); const messageListContext = React.useContext(MessageListContext); invariant(messageListContext, 'DummyTextNode should have MessageListContext'); const rules = messageListContext.getTextMessageMarkdownRules(darkColor); const canReply = threadHasPermission( props.threadInfo, threadPermissions.VOICED, ); return (
{text}
); } export default TextMessage; diff --git a/web/chat/tooltip-utils.js b/web/chat/tooltip-utils.js new file mode 100644 index 000000000..c6a36e123 --- /dev/null +++ b/web/chat/tooltip-utils.js @@ -0,0 +1,132 @@ +// @flow + +import { calculateTextWidth } from '../utils/text-utils'; +import type { ItemAndContainerPositionInfo } from './position-types'; + +export const tooltipPositions = Object.freeze({ + LEFT: 'left', + RIGHT: 'right', + BOTTOM_LEFT: 'bottom-left', + BOTTOM_RIGHT: 'bottom-right', + TOP_LEFT: 'top-left', + TOP_RIGHT: 'top-right', +}); +export type TooltipPosition = $Values; + +const sizeOfTooltipArrow = 10; // 7px arrow + 3px extra +const tooltipHeight = 27; // 17px line-height + 10px padding +const heightWithArrow = tooltipHeight + sizeOfTooltipArrow; +const tooltipInnerPadding = 10; + +const font = + '14px -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", ' + + '"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", ' + + '"Helvetica Neue", sans-serif'; + +type FindTooltipPositionArgs = {| + +pointingToInfo: ItemAndContainerPositionInfo, + +text: string, + +availablePositions: $ReadOnlyArray, + +layoutPosition: 'relative' | 'absolute', +|}; +function findTooltipPosition({ + pointingToInfo, + text, + availablePositions, + layoutPosition, +}: FindTooltipPositionArgs) { + const { itemPosition: pointingTo, containerPosition } = pointingToInfo; + const { + height: containerHeight, + top: containerTop, + width: containerWidth, + left: containerLeft, + } = containerPosition; + + const textWidth = calculateTextWidth(text, font); + const width = textWidth + tooltipInnerPadding + sizeOfTooltipArrow; + + const absolutePositionedTooltip = layoutPosition === 'absolute'; + + let canBeDisplayedInLeftPosition, + canBeDisplayedInRightPosition, + canBeDisplayedInTopPosition, + canBeDisplayedInBottomPosition; + if (absolutePositionedTooltip) { + const pointingCenter = pointingTo.top + pointingTo.height / 2; + const currentTop = Math.max(pointingTo.top, 0); + const currentBottom = Math.min(pointingTo.bottom, containerHeight); + const currentPointing = Math.max( + Math.min(pointingCenter, containerHeight), + 0, + ); + const canBeDisplayedSideways = + currentPointing - tooltipHeight / 2 + containerTop >= 0 && + currentPointing + tooltipHeight / 2 + containerTop <= window.innerHeight; + + canBeDisplayedInLeftPosition = + pointingTo.left - width + containerLeft >= 0 && canBeDisplayedSideways; + canBeDisplayedInRightPosition = + pointingTo.right + width + containerLeft <= window.innerWidth && + canBeDisplayedSideways; + canBeDisplayedInTopPosition = + currentTop - heightWithArrow + containerTop >= 0; + canBeDisplayedInBottomPosition = + currentBottom + heightWithArrow + containerTop <= window.innerHeight; + } else { + const canBeDisplayedSideways = + pointingTo.top - (tooltipHeight - pointingTo.height) / 2 >= 0 && + pointingTo.bottom + (tooltipHeight - pointingTo.height) / 2 <= + containerHeight; + canBeDisplayedInLeftPosition = + pointingTo.left - width >= 0 && canBeDisplayedSideways; + canBeDisplayedInRightPosition = + pointingTo.right + width <= containerWidth && canBeDisplayedSideways; + canBeDisplayedInTopPosition = pointingTo.top - heightWithArrow >= 0; + canBeDisplayedInBottomPosition = + pointingTo.bottom + heightWithArrow <= containerHeight; + } + + for (const tooltipPosition of availablePositions) { + if ( + tooltipPosition === tooltipPositions.RIGHT && + canBeDisplayedInRightPosition + ) { + return tooltipPosition; + } else if ( + tooltipPosition === tooltipPositions.BOTTOM_RIGHT && + canBeDisplayedInBottomPosition + ) { + return tooltipPosition; + } else if ( + tooltipPosition === tooltipPositions.LEFT && + canBeDisplayedInLeftPosition + ) { + return tooltipPosition; + } else if ( + tooltipPosition === tooltipPositions.BOTTOM_LEFT && + canBeDisplayedInBottomPosition + ) { + return tooltipPosition; + } else if ( + tooltipPosition === tooltipPositions.TOP_LEFT && + canBeDisplayedInTopPosition + ) { + return tooltipPosition; + } else if ( + tooltipPosition === tooltipPositions.TOP_RIGHT && + canBeDisplayedInTopPosition + ) { + return tooltipPosition; + } + } + return availablePositions[availablePositions.length - 1]; +} + +export { + findTooltipPosition, + sizeOfTooltipArrow, + tooltipHeight, + heightWithArrow, + tooltipInnerPadding, +}; diff --git a/web/style.css b/web/style.css index 7d639090a..2b2298e40 100644 --- a/web/style.css +++ b/web/style.css @@ -1,644 +1,643 @@ * { padding: 0; margin: 0; -ms-overflow-style: -ms-autohiding-scrollbar; } html { height: 100%; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; background-image: url(../images/background.png); background-size: 3000px 2000px; background-attachment: fixed; height: 100%; overflow: hidden; } a { text-decoration: none; color: #2A5DB0; } img, iframe { display: block; } input[type='text'], input[type='password'], textarea { -webkit-appearance: none; -moz-appearance: none; -webkit-border-radius: 0; border: 1px solid #DDDDDD; border-radius: 1px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } input[type='submit'] { -webkit-appearance: none; -moz-appearance: none; -webkit-border-radius: 0; } input[type='submit']::-moz-focus-inner { border: 0; padding: 0; } :global(#react-root) { display: flex; flex-direction: column; height: 100%; } header.header { background-image: url(../images/background.png); background-size: 3000px 2000px; background-attachment: fixed; z-index: 1; } div.main-header { font-family: 'Anaheim', sans-serif; height: 62px; } div.main-header > h1 { position: absolute; padding-top: 5px; padding-left: 16px; color: white; font-size: 38px; } ul.nav-bar { position: absolute; padding-left: 180px; } ul.nav-bar > li { display: inline-block; list-style-type: none; font-family: 'Open Sans', sans-serif; font-size: 18px; font-weight: 600; cursor: pointer; text-transform: uppercase; } ul.nav-bar > li.current-tab { background-image: url(../images/background.png); background-size: 3000px 2000px; background-attachment: fixed; cursor: default; } ul.nav-bar > li > div { padding: 3px 20px 3px 20px; } ul.nav-bar > li > div > a { color: #FFFFFF; } ul.nav-bar > li.current-tab > div > a { color: #444444; } ul.nav-bar > li.current-tab > div { background-color: rgba(255,255,255,0.84); padding: 18px 20px 19px 20px; height: 25px; } svg.nav-bar-icon { padding-right: 8px; font-size: 20px; } div.chatBadge { display: inline-block; box-sizing: border-box; width: 25px; height: 25px; margin-left: 8px; color: white; background-color: red; border-radius: 13px; font-size: 18px; text-align: center; } div.main-content-container { position: relative; height: calc(100% - 62px); } div.main-content { background-color: rgba(255,255,255,0.84); height: 100%; - overflow-x: hidden; } div.upper-right { position: absolute; top: 0; right: 0; padding: 15px 16px; } span.loading-indicator-loading { display: inline-block; } @keyframes loading-indicator-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } span.loading-indicator-loading { display: inline-block; } span.loading-indicator-loading-medium:after { content: " "; display: block; width: 15px; height: 15px; border-radius: 50%; border: 3px solid #fff; border-color: #fff transparent #fff transparent; animation-name: loading-indicator-loading; animation-duration: 1.2s; animation-iteration-count: infinite; animation-timing-function: linear; } span.loading-indicator-loading-large:after { content: " "; display: block; width: 25px; height: 25px; border-radius: 50%; border: 3px solid #fff; border-color: #fff transparent #fff transparent; animation-name: loading-indicator-loading; animation-duration: 1.2s; animation-iteration-count: infinite; animation-timing-function: linear; } span.loading-indicator-loading-small:after { content: " "; display: block; width: 9px; height: 9px; border-radius: 50%; border: 2px solid #fff; border-color: #fff transparent #fff transparent; animation-name: loading-indicator-loading; animation-duration: 1.2s; animation-iteration-count: infinite; animation-timing-function: linear; } span.loading-indicator-black:after { border-color: #000 transparent #000 transparent; } span.loading-indicator-error { font-weight: bold; color: white; line-height: 0; } span.loading-indicator-error-black { font-weight: bold; color: red; line-height: 0; } div.account-bar { background-color: #F8F8F8; border: 1px solid #C8C8C8; border-radius: 5px; font-family: 'Open Sans', sans-serif; font-weight: 600; padding: 0 6px; cursor: pointer; float: right; } div.account-button { padding: 3px 4px 3px 8px; } div.account-button > span { font-size: 16px; } div.account-menu { outline: none; } div.account-menu > div { border-top: 1px solid #C8C8C8; padding: 3px 4px 3px 8px; } span.username { display: inline-block; color: #2A5DB0; cursor: pointer; text-overflow: ellipsis; overflow: hidden; max-width: 200px; vertical-align: bottom; } svg.account-caret { padding-left: 6px; fill: #2A5DB0; cursor: pointer; height: 10px; width: 10px; } div.modal-overlay { position: fixed; left: 0; top: 0; bottom: 0; z-index: 4; width: 100%; background-color: rgba(0,0,0,0.4); display: flex; flex-direction: column; align-items: center; overflow: auto; } div.resizable-modal-overlay { min-height: 60px; } div.small-modal-overlay { padding-top: 100px; } div.large-modal-overlay { padding-top: 50px; } div.modal-container { background-image: url(../images/background.png); background-size: 3000px 2000px; max-width: 330px; border-radius: 15px; display: flex; min-height: 0; max-height: 500px; } div.large-modal-container { max-width: 500px; } div.modal { position: relative; box-shadow: 0 4px 20px rgba(0, 0, 0, .3), 0 0 0 1px rgba(0, 0, 0, .1); background-color: rgba(255,255,255,0.61); border-radius: 15px; flex: 1; display: flex; flex-direction: column; width: 330px; } div.large-modal-container div.modal { width: 500px; } div.fixed-height-modal { height: 100%; } span.modal-close { float: right; font-size: 32px; font-weight: 300; line-height: 30px; } span.modal-close:hover { color: black; cursor: pointer; } div.modal-header { padding: 8px 15px; font-family: 'Open Sans', sans-serif; } div.modal-header > h2 { font-size: 22px; font-weight: 300; } div.modal-body { padding: 6px 6px; width: 100%; box-sizing: border-box; background-color: white; border-bottom-left-radius: 15px; border-bottom-right-radius: 15px; flex: 1; display: flex; flex-direction: column; } div.resized-modal-body { min-height: 250px; } div.modal-body p { padding: 1px 3px 4px 3px; font-size: 14px; text-align: center; } div.modal-body p.form-pre-footer { padding-top: 5px; font-size: 12px; font-style: italic; } div.modal-body input, div.modal-body textarea { margin: 3px; } div.modal-body input[type='text'], div.modal-body input[type='password'], div.modal-body textarea { font-size: 14px; padding: 1px; width: 175px; } div.large-modal-container div.modal-body input[type='text'], div.large-modal-container div.modal-body input[type='password'], div.large-modal-container div.modal-body textarea { width: 275px; } div.modal-body input[type='submit'] { padding: 3px 9px; font-size: 12px; margin-right: 3px; border-radius: 3px; border: 1px solid #C8C8C8; background-color: #F8F8F8; font-family: 'Open Sans', sans-serif; font-weight: 600; color: #444444; margin-top: 1px; cursor: pointer; } div.modal-body input[type='submit']:hover { text-decoration: underline; } div.modal-body input[type='submit']:disabled { cursor: initial; text-decoration: none; color: #999999; } div.modal-body div.form-title { display: inline-block; text-align: right; padding-right: 5px; padding-top: 5px; font-size: 14px; font-family: 'Open Sans', sans-serif; font-weight: 600; vertical-align: top; width: 110px; } div.large-modal-container div.modal-body div.form-title { width: 140px; } div.modal-body div.form-content { display: inline-block; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; font-size: 15px; } div.modal-body div.form-subtitle { font-size: 12px; padding-left: 4px; font-style: italic; } div.form-enum-selector { display: inline-block; padding-bottom: 4px; } div.form-enum-selector > div.form-enum-container { padding-top: 5px; } div.form-enum-selector > div.form-enum-container > input { vertical-align: top; margin-top: 4px; } div.form-enum-selector div.form-enum-option { font-weight: bold; display: inline-block; font-size: 15px; font-family: 'Open Sans', sans-serif; font-weight: 600; padding-left: 3px; } div.form-enum-selector span.form-enum-description { display: block; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; font-weight: normal; font-size: 13px; max-width: 260px; color: gray; } div.color-title { margin-top: 4px; } div.modal-body div.form-footer { display: flex; flex-direction: row-reverse; justify-content: space-between; align-items: start; margin-top: 5px; border-top: 2px solid #EFEFEF; min-height: 26px; padding: 7px 4px 4px 0; } div.modal-body div.form-footer div.modal-form-error { font-size: 12px; color: red; font-style: italic; padding-left: 6px; align-self: center; } div.modal-body div.form-footer div.modal-form-error ol { padding-left: 20px; } div.form-text { display: flex; align-items: baseline; } div.form-text > div.form-title { vertical-align: initial; flex-shrink: 0; } div.form-text > div.form-content { margin-left: 3px; margin-bottom: 3px; word-break: break-word; } div.form-text > div.form-float-title { float: left; text-align: right; padding-right: 5px; font-size: 14px; font-family: 'Open Sans', sans-serif; font-weight: 600; width: 110px; } div.form-text > div.form-float-content { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; padding: 1px 20px 3px 4px; margin-top: 5px; } .verified-status-true { color: green; } .verified-status-false { color: red; } .hidden { display: none; } .italic { font-style: italic; } div.edit-thread-color-container { margin-top: -5px; } div.form-textarea-container { margin-top: 1px; } div.edit-thread-privacy-container { margin-bottom: 6px; } div.edit-thread-account-password { border-top: 2px solid #EFEFEF; padding-top: 4px; margin-top: 2px; } div.user-settings-current-password { border-top: 2px solid #EFEFEF; padding-top: 4px; margin-top: 5px; } div.modal-body p.confirm-account-password { padding: 3px 0px 5px 0px; font-style: italic; font-size: 13px; color: #777777; } ul.tab-panel { background-color: #F4F4F4; padding-left: 10px; padding-top: 5px; } ul.tab-panel > li { display: inline-block; list-style-type: none; font-family: 'Open Sans', sans-serif; font-size: 13px; font-weight: 600; cursor: pointer; padding: 3px 10px 3px 10px; } ul.tab-panel > li.current-tab { background-color: white; border-radius: 5px 5px 0 0; cursor: default; } ul.tab-panel > li > a { color: #555555; } ul.tab-panel > li.current-tab > a { color: #123a7b; cursor: default; } ul.tab-panel > li.delete-tab > a { color: #ff0000 !important; } div.new-thread-privacy-container { margin-bottom: 3px; margin-top: -6px; } span.page-loading { margin-top: 5px; margin-right: 12px; float: left; } span.page-error { margin: 15px; font-size: 42px; float: left; color: red; } div.color-picker-container { outline: none; position: relative; } div.color-picker-button { margin: 6px 3px; overflow: hidden; cursor: pointer; padding: 4px; display: inline-block; border: solid 1px darkgray; background: #eee; color: #333; vertical-align: middle; border-radius: 3px; } div.color-picker-preview { width: 25px; height: 16px; border: solid 1px #222; margin-right: 5px; float: left; z-index: 0; } div.color-picker-down-symbol { padding: 1px 0; height: 16px; line-height: 16px; float: left; font-size: 10px; } div.color-picker-selector { position: absolute; left: 4px; top: 34px; } div.intro-modal { padding: 10px 21px 12px 21px; border-radius: 5px; border: 1px solid #C8C8C8; background-color: #F8F8F8; width: 310px; position: fixed; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; margin-top: 100px; box-shadow: 2px 3px 6px 0px rgba(0, 0, 0, 0.1); } @media only screen and (-webkit-min-device-pixel-ratio: 2.0), only screen and (min--moz-device-pixel-ratio: 2.0), only screen and (-o-min-device-pixel-ratio: 2.0/1), only screen and (min-device-pixel-ratio: 2.0), only screen and (min-resolution: 320dpi), only screen and (min-resolution: 2.0dppx) { header.header, header.main-header, div.splash-header-container, div.splash-top-container, div.splash-bottom, div.modal-container, ul.nav-bar > li.current-tab, div.calendar-filters-container { background-image: url(../images/background@2x.png); } } @media (hover: none) { div.splash-header-container, div.splash-top-container, div.splash-bottom { background-attachment: initial; } }