diff --git a/web/chat/chat-message-list.css b/web/chat/chat-message-list.css index b8e0c4fb0..64fefd8c8 100644 --- a/web/chat/chat-message-list.css +++ b/web/chat/chat-message-list.css @@ -1,251 +1,253 @@ div.container { margin-left: 400px; height: 100%; background-color: var(--bg); display: flex; flex-direction: column; box-sizing: border-box; } div.activeContainer { border: 2px solid #5989d6; margin-left: 402px; } div.outerMessageContainer { position: relative; height: calc(100vh - 128px); min-height: 0; + display: flex; + flex-direction: column; } div.messageContainer { 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.loading { text-align: center; padding: 12px; } div.conversationHeader { color: var(--chat-timestamp-color); font-size: var(--xs-font-12); line-height: var(--line-height-text); text-align: center; } div.conversationHeader:last-child { padding-top: 6px; } div.messageLeftTooltip:after { top: 7px; right: -14px; border-color: transparent transparent transparent var(--tool-tip-bg); } div.messageRightTooltip:after { top: 7px; left: -14px; border-color: transparent var(--tool-tip-bg) transparent transparent; } div.messageTopLeftTooltip:after { bottom: -14px; left: 4px; border-color: var(--tool-tip-bg) transparent transparent transparent; } div.messageTopRightTooltip:after { bottom: -14px; right: 4px; border-color: var(--tool-tip-bg) transparent transparent transparent; } div.messageBottomLeftTooltip:after { top: -14px; left: 4px; border-color: transparent transparent var(--tool-tip-bg) transparent; } div.messageBottomRightTooltip:after { top: -14px; right: 4px; border-color: transparent transparent var(--tool-tip-bg) transparent; } div.messageActionLinks { position: absolute; display: flex; top: 0; bottom: 0; align-items: center; color: gray; padding: 0 6px; } div.viewerMessageActionLinks { right: 100%; } div.nonViewerMessageActionLinks { left: 100%; } div.messageActionLinks > div + div { margin-left: 4px; } div.messageReplyButton { font-size: 14px; } div.messageActionLinkIcon: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; } div.emojiOnlyTextMessage { font-size: 32px; font-family: emoji; } span.authorName { color: #777777; font-size: 14px; padding: 4px 24px; } div.darkTextMessage { color: white; } div.lightTextMessage { color: black; } 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.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; cursor: pointer; } 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.sidebarMarginBottom { margin-bottom: 8px; } svg.inlineSidebarIcon { color: #666666; } diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 7fbae8854..fdb2f42c9 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,486 +1,494 @@ // @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, useExistingThreadInfoFinder, } 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 MessageTimestampTooltip from './message-timestamp-tooltip.react'; import Message from './message.react'; import type { OnMessagePositionWithContainerInfo, MessagePositionInfo, } from './position-types'; +import RelationshipPrompt from './relationship-prompt/relationship-prompt'; 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: ?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 { 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, } = 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, }); let tooltip; if (this.state.mouseOverMessagePosition) { const messagePositionInfo = this.state.mouseOverMessagePosition; tooltip = ( ); } + let relationshipPrompt; + if (this.props.threadInfo) { + relationshipPrompt = ( + + ); + } const messageContainerStyle = classNames({ [css.messageContainer]: true, [css.mirroredMessageContainer]: !this.props.supportsReverseFlex, }); return connectDropTarget(
+ {relationshipPrompt}
{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); const ConnectedChatMessageList: React.ComponentType = React.memo( function ConnectedChatMessageList(props) { const userAgent = useSelector(state => state.userAgent); const supportsReverseFlex = React.useMemo(() => { const browser = detectBrowser(userAgent); return ( !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81 ); }, [userAgent]); const timeZone = useSelector(state => state.timeZone); const 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 existingThreadInfoFinder = useExistingThreadInfoFinder( baseThreadInfo, ); const threadInfo = React.useMemo( () => existingThreadInfoFinder({ searching: false, userInfoInputArray: [], }), [existingThreadInfoFinder], ); const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); 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 ( ); }, ); export default ConnectedChatMessageList; diff --git a/web/chat/relationship-prompt/relationship-prompt-button-container.js b/web/chat/relationship-prompt/relationship-prompt-button-container.js new file mode 100644 index 000000000..ecbf74276 --- /dev/null +++ b/web/chat/relationship-prompt/relationship-prompt-button-container.js @@ -0,0 +1,14 @@ +// @flow + +import * as React from 'react'; + +import css from './relationship-prompt.css'; + +type Props = { +children: React.Node }; + +function RelationshipPromptButtonContainer(props: Props): React.Node { + const { children } = props; + return
{children}
; +} + +export default RelationshipPromptButtonContainer; diff --git a/web/chat/relationship-prompt/relationship-prompt-button.js b/web/chat/relationship-prompt/relationship-prompt-button.js new file mode 100644 index 000000000..b9fb6aa8f --- /dev/null +++ b/web/chat/relationship-prompt/relationship-prompt-button.js @@ -0,0 +1,34 @@ +// @flow + +import { type IconDefinition } from '@fortawesome/fontawesome-common-types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import * as React from 'react'; + +import css from './relationship-prompt.css'; + +type Props = { + +text: string, + +icon: IconDefinition, + +color?: string, + +textColor?: string, + +onClick: () => void, +}; +function RelationshipPromptButton(props: Props): React.Node { + const { text, icon, color, textColor, onClick } = props; + const buttonStyle = React.useMemo( + () => ({ + backgroundColor: `var(${color ?? '--relationship-button-green'})`, + color: `var(${textColor ?? '--relationship-button-text'})`, + }), + [color, textColor], + ); + + return ( + + ); +} + +export default RelationshipPromptButton; diff --git a/web/chat/relationship-prompt/relationship-prompt.css b/web/chat/relationship-prompt/relationship-prompt.css new file mode 100644 index 000000000..1a4edfa18 --- /dev/null +++ b/web/chat/relationship-prompt/relationship-prompt.css @@ -0,0 +1,21 @@ +div.promptButtonContainer { + display: flex; +} +button.promptButton { + font-size: var(--m-font-16); + font-weight: var(--semi-bold); + cursor: pointer; + margin: 0.5rem; + padding: 1.2rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + flex: 1; + border-radius: 0.4rem; + border: none; +} + +svg.promptIcon { + padding: 0.5rem; +} diff --git a/web/chat/relationship-prompt/relationship-prompt.js b/web/chat/relationship-prompt/relationship-prompt.js new file mode 100644 index 000000000..2ae245ef8 --- /dev/null +++ b/web/chat/relationship-prompt/relationship-prompt.js @@ -0,0 +1,110 @@ +// @flow + +import { + faUserMinus, + faUserPlus, + faUserShield, + faUserSlash, +} from '@fortawesome/free-solid-svg-icons'; +import * as React from 'react'; + +import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt'; +import { userRelationshipStatus } from 'lib/types/relationship-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; + +import RelationshipPromptButton from './relationship-prompt-button'; +import RelationshipPromptButtonContainer from './relationship-prompt-button-container'; + +type Props = { +threadInfo: ThreadInfo }; + +function RelationshipPrompt(props: Props) { + const { threadInfo } = props; + const { + otherUserInfo, + callbacks: { blockUser, unblockUser, friendUser, unfriendUser }, + } = useRelationshipPrompt(threadInfo); + if (!otherUserInfo?.username) { + return null; + } + const relationshipStatus = otherUserInfo.relationshipStatus; + + if (relationshipStatus === userRelationshipStatus.FRIEND) { + return null; + } else if (relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER) { + return ( + + + + ); + } else if ( + relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || + relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER + ) { + return ( + + + + ); + } else if (relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED) { + return ( + + + + + ); + } else if (relationshipStatus === userRelationshipStatus.REQUEST_SENT) { + return ( + + + + ); + } else { + return ( + + + + + ); + } +} + +const MemoizedRelationshipPrompt: React.ComponentType = React.memo( + RelationshipPrompt, +); + +export default MemoizedRelationshipPrompt; diff --git a/web/flow-typed/npm/@fortawesome/fontawesome-common-types_vx.x.x.js b/web/flow-typed/npm/@fortawesome/fontawesome-common-types_vx.x.x.js new file mode 100644 index 000000000..6145a4931 --- /dev/null +++ b/web/flow-typed/npm/@fortawesome/fontawesome-common-types_vx.x.x.js @@ -0,0 +1,11 @@ +declare module '@fortawesome/fontawesome-common-types' { + declare type IconDefinition = { + icon: [ + number, // width + number, // height + string[], // ligatures + string, // unicode + string | string[] // svgPathData + ]; + }; +} diff --git a/web/theme.css b/web/theme.css index 637cd6173..c8b4c9bf6 100644 --- a/web/theme.css +++ b/web/theme.css @@ -1,90 +1,93 @@ :root { /* Never use color values defined here directly in CSS. Add color variables to "Color Theme" below The reason we never use color values defined here directly in CSS is 1. It makes changing themes from light / dark / user generated impossible. 2. Gives the programmer context into the color being used. 3. If our color system changes it's much easier to change color values in one place. Add a color value to the theme below, and then use it in your CSS. naming convention: - bg: background. - fg: foreground. - color: text-color */ --shades-white-100: #ffffff; --shades-white-90: #f5f5f5; --shades-white-80: #ebebeb; --shades-white-70: #e0e0e0; --shades-white-60: #cccccc; --shades-black-100: #0a0a0a; --shades-black-90: #1f1f1f; --shades-black-80: #404040; --shades-black-70: #666666; --shades-black-60: #808080; --violet-dark-100: #7e57c2; --violet-dark-80: #6d49ab; --violet-dark-60: #563894; --violet-dark-40: #44297a; --violet-dark-20: #331f5c; --violet-light-100: #ae94db; --violet-light-80: #b9a4df; --violet-light-60: #d3c6ec; --violet-light-40: #e8e0f5; --violet-light-20: #f3f0fa; --success-light-10: #d5f6e3; --success-light-50: #6cdf9c; --success-primary: #00c853; --success-dark-50: #029841; --success-dark-90: #034920; --error-light-10: #feebe6; --error-light-50: #f9947b; --error-primary: #f53100; --error-dark-50: #b62602; --error-dark-90: #4f1203; --bg: var(--shades-black-100); --fg: var(--shades-white-100); --color-disabled: var(--shades-black-60); --text-input-bg: var(--shades-black-80); --text-input-color: var(--shades-white-60); --text-input-placeholder: var(--shades-white-60); --border: var(--shades-black-80); --error: var(--error-primary); --success: var(--success-dark-50); /* Color Theme */ --btn-bg-primary: var(--violet-dark-100); --chat-bg: var(--violet-dark-80); --chat-confirmation-icon: var(--violet-dark-100); --keyserver-selection: var(--violet-dark-60); --thread-selection: var(--violet-light-80); --selected-thread-bg: var(--shades-black-90); --chat-timestamp-color: var(--shades-black-60); --tool-tip-bg: var(--shades-black-80); --tool-tip-color: var(--shades-black-60); --border-color: var(--shades-black-60); --calendar-chevron: var(--shades-black-60); --calendar-day-bg: var(--shades-black-60); --calendar-day-selected-color: var(--violet-dark-80); --community-bg: var(--shades-black-90); --unread-bg: var(--error-primary); --settings-btn-bg: var(--violet-dark-100); --modal-bg: var(--shades-black-90); --join-bg: var(--shades-black-90); --help-color: var(--shades-black-60); --modal-bg: var(--shades-black-90); --breadcrumb-color: var(--shades-black-60); --breadcrumb-color-unread: var(--shades-white-60); --join-bg: var(--shades-black-90); --btn-secondary-border: var(--shades-black-60); --thread-color-read: var(--shades-black-60); --thread-from-color-read: var(--shades-black-80); --thread-last-message-color-read: var(--shades-black-60); + --relationship-button-green: var(--success-dark-50); + --relationship-button-red: var(--error-primary); + --relationship-button-text: var(--fg); }