diff --git a/web/components/button.css b/web/components/button.css index 894227c1b..9b8381915 100644 --- a/web/components/button.css +++ b/web/components/button.css @@ -1,81 +1,71 @@ .btn { + --border-width: 1px; + --border-radius: 4px; + + position: relative; display: flex; align-items: center; justify-content: center; - background: var(--settings-btn-bg); - border: 1px solid transparent; + border: var(--border-width) solid transparent; font-size: var(--m-font-16); padding: 12px 24px; - border-radius: 4px; - cursor: pointer; -} - -.btn:hover { - transition-duration: 200ms; - transition-property: background, color; -} - -.btn:disabled { - cursor: not-allowed; - color: var(--btn-disabled-color); -} - -.primary { - background: var(--btn-bg-primary); color: var(--fg); + border-radius: var(--border-radius); + cursor: pointer; } -.primary:hover { - background: var(--btn-bg-primary-hover); -} - -.primary:disabled { - background: var(--btn-bg-primary-disabled); -} - -.secondary { - background: var(--btn-bg-secondary); - color: var(--fg); - border-color: var(--btn-secondary-border); +.btn.outline { + border: var(--border-width) solid var(--btn-outline-border); } -.secondary:hover { - background: var(--btn-bg-secondary-hover); +.btn > * { + position: relative; } -.secondary:disabled { - background: var(--btn-bg-secondary-disabled); +.btn::before { + content: ''; + border: inherit; + border-radius: inherit; + background: inherit; + position: absolute; + top: calc(-1 * var(--border-width)); + left: calc(-1 * var(--border-width)); + width: 100%; + height: 100%; } -.success { - background: var(--btn-bg-success); - color: var(--fg); +.btn.outline::before { + top: 0; + left: 0; + border: none; + border-radius: calc(var(--border-radius) - var(--border-width)); } -.success:hover { - background: var(--btn-bg-success-hover); +.btn:hover::before { + transition-duration: 200ms; + transition-property: filter; } -.success:disabled { - background: var(--btn-bg-success-disabled); +.btn:hover:not(:disabled)::before { + filter: brightness(0.8); } -.danger { - background: var(--btn-bg-danger); - color: var(--fg); +.btn.outline:hover:not(:disabled)::before { + filter: brightness(2); } -.danger:hover { - background: var(--btn-bg-danger-hover); +.btn:disabled { + cursor: not-allowed; + color: var(--btn-disabled-color); } -.danger:disabled { - background: var(--btn-bg-danger-disabled); +.btn:not(.outline):disabled::before { + background-color: var(--btn-bg-disabled); } .round { width: 30px; height: 30px; border-radius: 50%; padding: 0; } diff --git a/web/components/button.react.js b/web/components/button.react.js index 20d6e89d3..1c7dbdafb 100644 --- a/web/components/button.react.js +++ b/web/components/button.react.js @@ -1,47 +1,81 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import css from './button.css'; -export type ButtonVariant = - | 'primary' - | 'secondary' - | 'success' - | 'danger' - | 'round'; +export type ButtonVariant = 'filled' | 'outline' | 'round'; +export type ButtonColor = { + +backgroundColor?: string, + +color?: string, +}; + +export const buttonThemes: { [string]: ButtonColor } = { + standard: { + backgroundColor: 'var(--btn-bg-filled)', + }, + danger: { + backgroundColor: 'var(--btn-bg-danger)', + }, + success: { + backgroundColor: 'var(--btn-bg-success)', + }, + outline: { + backgroundColor: 'var(--btn-bg-outline)', + }, +}; export type ButtonProps = { +onClick: (event: SyntheticEvent) => mixed, +children: React.Node, +variant?: ButtonVariant, + +buttonColor?: ButtonColor, +type?: string, +disabled?: boolean, +className?: string, }; function Button(props: ButtonProps): React.Node { const { onClick, children, - variant = 'primary', + variant = 'filled', + buttonColor, type, disabled = false, className = '', } = props; + const btnCls = classnames(css.btn, css[variant]); + let style; + if (buttonColor) { + style = buttonColor; + } else if (variant === 'outline') { + style = buttonThemes.outline; + } else { + style = buttonThemes.standard; + } + + const wrappedChildren = React.Children.map(children, child => { + if (typeof child === 'string' || typeof child === 'number') { + return {child}; + } + return child; + }); + return ( ); } export default Button; diff --git a/web/components/stepper.react.js b/web/components/stepper.react.js index 684a5e972..733c3d1fb 100644 --- a/web/components/stepper.react.js +++ b/web/components/stepper.react.js @@ -1,108 +1,108 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import LoadingIndicator from '../loading-indicator.react'; import Button from './button.react'; import css from './stepper.css'; export type ButtonProps = { +content: React.Node, +disabled?: boolean, +loading?: boolean, +onClick: () => void, }; type ButtonType = 'prev' | 'next'; type ActionButtonProps = { +buttonProps: ButtonProps, +type: ButtonType, }; function ActionButton(props: ActionButtonProps) { const { buttonProps, type } = props; const { content, loading, disabled, onClick } = buttonProps; const buttonContent = loading ? ( <>
{content}
) : ( content ); return ( ); } type ItemProps = { +content: React.Node, +name: string, +errorMessage?: string, +prevProps?: ButtonProps, +nextProps?: ButtonProps, }; function StepperItem(props: ItemProps): React.Node { const { content, errorMessage, prevProps, nextProps } = props; const prevButton = React.useMemo( () => prevProps ? : null, [prevProps], ); const nextButton = React.useMemo( () => nextProps ? : null, [nextProps], ); return ( <>
{content}
{errorMessage}
{prevButton} {nextButton}
); } type ContainerProps = { +activeStep: string, +className?: string, +children: React.ChildrenArray>, }; function StepperContainer(props: ContainerProps): React.Node { const { children, activeStep, className = '' } = props; const index = new Map( React.Children.toArray(children).map(child => [child.props.name, child]), ); const activeComponent = index.get(activeStep); const styles = classnames(css.stepperContainer, className); return
{activeComponent}
; } const Stepper = { Container: StepperContainer, Item: StepperItem, }; export default Stepper; diff --git a/web/modals/alert.react.js b/web/modals/alert.react.js index d63286aa0..c889d9472 100644 --- a/web/modals/alert.react.js +++ b/web/modals/alert.react.js @@ -1,36 +1,36 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react'; import Button from '../components/button.react'; import css from './alert.css'; import Modal from './modal.react'; type AlertProps = { +title: string, +children: string, }; function Alert(props: AlertProps): React.Node { const { title, children } = props; const { popModal } = useModalContext(); return (

{children}

); } export default Alert; diff --git a/web/modals/chat/sidebar-promote-modal.react.js b/web/modals/chat/sidebar-promote-modal.react.js index c6bd9b33f..12bac6cdc 100644 --- a/web/modals/chat/sidebar-promote-modal.react.js +++ b/web/modals/chat/sidebar-promote-modal.react.js @@ -1,49 +1,54 @@ // @flow import * as React from 'react'; import type { ThreadInfo } from 'lib/types/thread-types'; -import Button from '../../components/button.react'; +import Button, { buttonThemes } from '../../components/button.react'; import Modal from '../modal.react'; import css from './sidebar-promote-modal.css'; type Props = { +onClose: () => void, +onConfirm: () => void, +threadInfo: ThreadInfo, }; function SidebarPromoteModal(props: Props): React.Node { const { threadInfo, onClose, onConfirm } = props; const { uiName } = threadInfo; const handleConfirm = React.useCallback(() => { onConfirm(); onClose(); }, [onClose, onConfirm]); return (

{`Are you sure you want to promote "${uiName}"?`}

Promoting a thread to a channel cannot be undone.

- -
); } export default SidebarPromoteModal; diff --git a/web/modals/threads/cant-leave-thread-modal.react.js b/web/modals/threads/cant-leave-thread-modal.react.js index c48d3efbb..c526932aa 100644 --- a/web/modals/threads/cant-leave-thread-modal.react.js +++ b/web/modals/threads/cant-leave-thread-modal.react.js @@ -1,33 +1,33 @@ // @flow import * as React from 'react'; import Button from '../../components/button.react.js'; import Modal from '../modal.react'; import css from './cant-leave-thread-modal.css'; type Props = { +onClose: () => void, }; function CantLeaveThreadModal(props: Props): React.Node { return (

You are the only admin left of this chat. Please promote somebody else to admin before leaving.

); } export default CantLeaveThreadModal; diff --git a/web/modals/threads/confirm-leave-thread-modal.react.js b/web/modals/threads/confirm-leave-thread-modal.react.js index 0887e92d6..8cdf01a7a 100644 --- a/web/modals/threads/confirm-leave-thread-modal.react.js +++ b/web/modals/threads/confirm-leave-thread-modal.react.js @@ -1,47 +1,52 @@ // @flow import * as React from 'react'; import { type ThreadInfo } from 'lib/types/thread-types'; -import Button from '../../components/button.react'; +import Button, { buttonThemes } from '../../components/button.react'; import Modal from '../modal.react'; import css from './confirm-leave-thread-modal.css'; type Props = { +threadInfo: ThreadInfo, +onClose: () => void, +onConfirm: () => void, }; function ConfirmLeaveThreadModal(props: Props): React.Node { const { threadInfo, onClose, onConfirm } = props; const { uiName } = threadInfo; return (

{'Are you sure you want to leave "'} {uiName} {'"?'}

- -
); } export default ConfirmLeaveThreadModal; diff --git a/web/modals/threads/members/add-members-modal.react.js b/web/modals/threads/members/add-members-modal.react.js index 1844f1326..f0ace0f9d 100644 --- a/web/modals/threads/members/add-members-modal.react.js +++ b/web/modals/threads/members/add-members-modal.react.js @@ -1,196 +1,196 @@ // @flow import * as React from 'react'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userSearchIndexForPotentialMembers, userInfoSelectorForPotentialMembers, } from 'lib/selectors/user-selectors'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { threadActualMembers } from 'lib/shared/thread-utils'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import Button from '../../../components/button.react'; import Label from '../../../components/label.react'; import { useSelector } from '../../../redux/redux-utils'; import SearchModal from '../../search-modal.react'; import AddMembersListContent from './add-members-list-content.react'; import css from './members-modal.css'; type ContentProps = { +searchText: string, +threadID: string, +onClose: () => void, }; function AddMembersModalContent(props: ContentProps): React.Node { const { searchText, threadID, onClose } = props; const [pendingUsersToAdd, setPendingUsersToAdd] = React.useState< $ReadOnlySet, >(new Set()); const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const { parentThreadID, community } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const excludeUserIDs = React.useMemo( () => threadActualMembers(threadInfo.members).concat( Array.from(pendingUsersToAdd), ), [pendingUsersToAdd, threadInfo.members], ); const userSearchResults = React.useMemo( () => getPotentialMemberItems( searchText, otherUserInfos, userSearchIndex, excludeUserIDs, parentThreadInfo, communityThreadInfo, threadInfo.type, ), [ communityThreadInfo, excludeUserIDs, otherUserInfos, parentThreadInfo, searchText, threadInfo.type, userSearchIndex, ], ); const onSwitchUser = React.useCallback( userID => setPendingUsersToAdd(users => { const newUsers = new Set(users); if (newUsers.has(userID)) { newUsers.delete(userID); } else { newUsers.add(userID); } return newUsers; }), [], ); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); const addUsers = React.useCallback(() => { dispatchActionPromise( changeThreadSettingsActionTypes, callChangeThreadSettings({ threadID, changes: { newMemberIDs: Array.from(pendingUsersToAdd) }, }), ); onClose(); }, [ callChangeThreadSettings, dispatchActionPromise, onClose, pendingUsersToAdd, threadID, ]); const pendingUsersWithNames = React.useMemo( () => Array.from(pendingUsersToAdd) .map(userID => [userID, otherUserInfos[userID].username]) .sort((a, b) => a[1].localeCompare(b[1])), [otherUserInfos, pendingUsersToAdd], ); const labelItems = React.useMemo(() => { if (!pendingUsersWithNames.length) { return null; } return (
{pendingUsersWithNames.map(([userID, username]) => ( ))}
); }, [onSwitchUser, pendingUsersWithNames]); return (
{labelItems}
-
); } type Props = { +threadID: string, +onClose: () => void, }; function AddMembersModal(props: Props): React.Node { const { threadID, onClose } = props; const addMembersModalContent = React.useCallback( (searchText: string) => ( ), [onClose, threadID], ); return ( {addMembersModalContent} ); } export default AddMembersModal; diff --git a/web/modals/threads/settings/thread-settings-delete-tab.react.js b/web/modals/threads/settings/thread-settings-delete-tab.react.js index c420e9c8f..2dac2765b 100644 --- a/web/modals/threads/settings/thread-settings-delete-tab.react.js +++ b/web/modals/threads/settings/thread-settings-delete-tab.react.js @@ -1,132 +1,134 @@ // @flow import * as React from 'react'; import { deleteThreadActionTypes, deleteThread, } from 'lib/actions/thread-actions'; import { useModalContext } from 'lib/components/modal-provider.react'; import { type SetState } from 'lib/types/hook-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; +import { buttonThemes } from '../../../components/button.react'; import SWMansionIcon from '../../../SWMansionIcon.react'; import Input from '../../input.react'; import SubmitSection from './submit-section.react'; import css from './thread-settings-delete-tab.css'; type ThreadSettingsDeleteTabProps = { +threadSettingsOperationInProgress: boolean, +threadInfo: ThreadInfo, +setErrorMessage: SetState, +errorMessage?: ?string, }; function ThreadSettingsDeleteTab( props: ThreadSettingsDeleteTabProps, ): React.Node { const { threadSettingsOperationInProgress, threadInfo, setErrorMessage, errorMessage, } = props; const modalContext = useModalContext(); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useServerCall(deleteThread); const accountPasswordInputRef = React.useRef(); const [accountPassword, setAccountPassword] = React.useState(''); const onChangeAccountPassword = React.useCallback( (event: SyntheticEvent) => { const target = event.currentTarget; setAccountPassword(target.value); }, [], ); const deleteThreadAction = React.useCallback(async () => { try { setErrorMessage(''); const response = await callDeleteThread(threadInfo.id, accountPassword); modalContext.popModal(); return response; } catch (e) { setErrorMessage( e.message === 'invalid_credentials' ? 'wrong password' : 'unknown error', ); setAccountPassword(''); accountPasswordInputRef.current?.focus(); throw e; } }, [ accountPassword, callDeleteThread, modalContext, setAccountPassword, setErrorMessage, threadInfo.id, ]); const onDelete = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatchActionPromise(deleteThreadActionTypes, deleteThreadAction()); }, [deleteThreadAction, dispatchActionPromise], ); return (

Your chat will be permanently deleted. There is no way to reverse this.

Please enter your account password to confirm your identity.

Account password
Delete
); } export default ThreadSettingsDeleteTab; diff --git a/web/modals/threads/settings/thread-settings-relationship-button.react.js b/web/modals/threads/settings/thread-settings-relationship-button.react.js index 5d3bd491c..12b2c4132 100644 --- a/web/modals/threads/settings/thread-settings-relationship-button.react.js +++ b/web/modals/threads/settings/thread-settings-relationship-button.react.js @@ -1,139 +1,144 @@ // @flow import { faUserMinus, faUserPlus, faUserShield, faUserSlash, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import invariant from 'invariant'; import * as React from 'react'; import { updateRelationships, updateRelationshipsActionTypes, } from 'lib/actions/relationship-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { getRelationshipActionText, getRelationshipDispatchAction, } from 'lib/shared/relationship-utils'; import type { SetState } from 'lib/types/hook-types'; import { relationshipButtons, type RelationshipButton, } from 'lib/types/relationship-types'; import type { UserInfo } from 'lib/types/user-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; -import Button from '../../../components/button.react'; +import Button, { buttonThemes } from '../../../components/button.react'; import { useSelector } from '../../../redux/redux-utils'; import css from './thread-settings-relationship-tab.css'; const loadingStatusSelector = createLoadingStatusSelector( updateRelationshipsActionTypes, ); type ButtonProps = { +relationshipButton: RelationshipButton, +otherUserInfo: UserInfo, +setErrorMessage?: SetState, }; function ThreadSettingsRelationshipButton(props: ButtonProps): React.Node { const { relationshipButton, otherUserInfo, setErrorMessage } = props; const disabled = useSelector(loadingStatusSelector) === 'loading'; const { username } = otherUserInfo; invariant(username, 'Other username should be specified'); - let variant = 'primary'; + let color; if (relationshipButton === relationshipButtons.FRIEND) { - variant = 'success'; + color = buttonThemes.success; } else if (relationshipButton === relationshipButtons.UNFRIEND) { - variant = 'danger'; + color = buttonThemes.danger; } else if (relationshipButton === relationshipButtons.BLOCK) { - variant = 'danger'; + color = buttonThemes.danger; } else if (relationshipButton === relationshipButtons.UNBLOCK) { - variant = 'success'; + color = buttonThemes.success; } else if (relationshipButton === relationshipButtons.ACCEPT) { - variant = 'success'; + color = buttonThemes.success; } else if (relationshipButton === relationshipButtons.REJECT) { - variant = 'danger'; + color = buttonThemes.danger; } else if (relationshipButton === relationshipButtons.WITHDRAW) { - variant = 'danger'; + color = buttonThemes.danger; } const { text, action } = React.useMemo(() => { return { text: getRelationshipActionText(relationshipButton, username), action: getRelationshipDispatchAction(relationshipButton), }; }, [relationshipButton, username]); const icon = React.useMemo(() => { let buttonIcon = null; if (relationshipButton === relationshipButtons.FRIEND) { buttonIcon = faUserPlus; } else if (relationshipButton === relationshipButtons.UNFRIEND) { buttonIcon = faUserMinus; } else if (relationshipButton === relationshipButtons.BLOCK) { buttonIcon = faUserShield; } else if (relationshipButton === relationshipButtons.UNBLOCK) { buttonIcon = faUserShield; } else if (relationshipButton === relationshipButtons.ACCEPT) { buttonIcon = faUserPlus; } else if (relationshipButton === relationshipButtons.REJECT) { buttonIcon = faUserSlash; } else if (relationshipButton === relationshipButtons.WITHDRAW) { buttonIcon = faUserMinus; } if (buttonIcon) { return ( ); } }, [relationshipButton]); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateRelationships = useServerCall(updateRelationships); const updateRelationshipsActionPromise = React.useCallback(async () => { try { setErrorMessage?.(''); return await callUpdateRelationships({ action, userIDs: [otherUserInfo.id], }); } catch (e) { setErrorMessage?.('Error updating relationship'); throw e; } }, [action, callUpdateRelationships, otherUserInfo.id, setErrorMessage]); const onClick = React.useCallback(() => { dispatchActionPromise( updateRelationshipsActionTypes, updateRelationshipsActionPromise(), ); }, [dispatchActionPromise, updateRelationshipsActionPromise]); return ( - ); } export default ThreadSettingsRelationshipButton; diff --git a/web/settings/account-delete-modal.react.js b/web/settings/account-delete-modal.react.js index be921c5c2..4fda42060 100644 --- a/web/settings/account-delete-modal.react.js +++ b/web/settings/account-delete-modal.react.js @@ -1,183 +1,184 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { deleteAccount, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import { useModalContext } from 'lib/components/modal-provider.react'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LogOutResult } from 'lib/types/account-types'; import type { PreRequestUserState } from 'lib/types/session-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; -import Button from '../components/button.react'; +import Button, { buttonThemes } from '../components/button.react'; import Input from '../modals/input.react'; import Modal from '../modals/modal.react'; import { useSelector } from '../redux/redux-utils'; import SWMansionIcon from '../SWMansionIcon.react.js'; import css from './account-delete-modal.css'; type Props = { +preRequestUserState: PreRequestUserState, +inputDisabled: boolean, +dispatchActionPromise: DispatchActionPromise, +deleteAccount: ( password: string, preRequestUserState: PreRequestUserState, ) => Promise, +popModal: () => void, }; type State = { +currentPassword: string, +errorMessage: string, }; class AccountDeleteModal extends React.PureComponent { currentPasswordInput: ?HTMLInputElement; constructor(props: Props) { super(props); this.state = { currentPassword: '', errorMessage: '', }; } componentDidMount() { invariant(this.currentPasswordInput, 'newPasswordInput ref unset'); this.currentPasswordInput.focus(); } render() { const { inputDisabled } = this.props; let errorMsg; if (this.state.errorMessage) { errorMsg = (
{this.state.errorMessage}
); } return (

Your account will be permanently deleted. There is no way to reverse this.

Please enter your account password to confirm your identity.

Account password

{errorMsg}
); } currentPasswordInputRef = (currentPasswordInput: ?HTMLInputElement) => { this.currentPasswordInput = currentPasswordInput; }; onChangeCurrentPassword = (event: SyntheticEvent) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); this.setState({ currentPassword: target.value }); }; onDelete = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatchActionPromise( deleteAccountActionTypes, this.deleteAction(), ); }; async deleteAction() { try { const response = await this.props.deleteAccount( this.state.currentPassword, this.props.preRequestUserState, ); this.props.popModal(); return response; } catch (e) { const errorMessage = e.message === 'invalid_credentials' ? 'wrong password' : 'unknown error'; this.setState( { currentPassword: '', errorMessage: errorMessage, }, () => { invariant( this.currentPasswordInput, 'currentPasswordInput ref unset', ); this.currentPasswordInput.focus(); }, ); throw e; } } } const deleteAccountLoadingStatusSelector = createLoadingStatusSelector( deleteAccountActionTypes, ); const ConnectedAccountDeleteModal: React.ComponentType<{}> = React.memo<{}>( function ConnectedAccountDeleteModal(): React.Node { const preRequestUserState = useSelector(preRequestUserStateSelector); const inputDisabled = useSelector( state => deleteAccountLoadingStatusSelector(state) === 'loading', ); const callDeleteAccount = useServerCall(deleteAccount); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); return ( ); }, ); export default ConnectedAccountDeleteModal; diff --git a/web/settings/danger-zone.react.js b/web/settings/danger-zone.react.js index 6485a69d1..a315bf9b4 100644 --- a/web/settings/danger-zone.react.js +++ b/web/settings/danger-zone.react.js @@ -1,37 +1,38 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react'; -import Button from '../components/button.react.js'; +import Button, { buttonThemes } from '../components/button.react'; import AccountDeleteModal from './account-delete-modal.react'; import css from './danger-zone.css'; function DangerZone(): React.Node { const { pushModal } = useModalContext(); const onDeleteAccountClick = React.useCallback( () => pushModal(), [pushModal], ); return (

Danger Zone

Delete Account

Your account will be permanently deleted. There is no way to reverse this.

); } export default DangerZone; diff --git a/web/settings/password-change-modal.js b/web/settings/password-change-modal.js index 3e0fb931a..6cf65be5e 100644 --- a/web/settings/password-change-modal.js +++ b/web/settings/password-change-modal.js @@ -1,261 +1,261 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { changeUserPasswordActionTypes, changeUserPassword, } from 'lib/actions/user-actions'; import { useModalContext } from 'lib/components/modal-provider.react'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { type PasswordUpdate, type CurrentUserInfo, } from 'lib/types/user-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import Input from '../modals/input.react'; import Modal from '../modals/modal.react'; import { useSelector } from '../redux/redux-utils'; import css from './password-change-modal.css'; type Props = { +currentUserInfo: ?CurrentUserInfo, +inputDisabled: boolean, +dispatchActionPromise: DispatchActionPromise, +changeUserPassword: (passwordUpdate: PasswordUpdate) => Promise, +popModal: () => void, }; type State = { +newPassword: string, +confirmNewPassword: string, +currentPassword: string, +errorMessage: string, }; class PasswordChangeModal extends React.PureComponent { newPasswordInput: ?HTMLInputElement; currentPasswordInput: ?HTMLInputElement; constructor(props: Props) { super(props); this.state = { newPassword: '', confirmNewPassword: '', currentPassword: '', errorMessage: '', }; } componentDidMount() { invariant(this.newPasswordInput, 'newPasswordInput ref unset'); this.newPasswordInput.focus(); } get username() { return this.props.currentUserInfo && !this.props.currentUserInfo.anonymous ? this.props.currentUserInfo.username : undefined; } render() { let errorMsg; if (this.state.errorMessage) { errorMsg = (
{this.state.errorMessage}
); } const { inputDisabled } = this.props; return (

{'Logged in as '} {this.username}

{errorMsg}
); } newPasswordInputRef = (newPasswordInput: ?HTMLInputElement) => { this.newPasswordInput = newPasswordInput; }; currentPasswordInputRef = (currentPasswordInput: ?HTMLInputElement) => { this.currentPasswordInput = currentPasswordInput; }; onChangeNewPassword = (event: SyntheticEvent) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); this.setState({ newPassword: target.value }); }; onChangeConfirmNewPassword = (event: SyntheticEvent) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); this.setState({ confirmNewPassword: target.value }); }; onChangeCurrentPassword = (event: SyntheticEvent) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); this.setState({ currentPassword: target.value }); }; onSubmit = (event: SyntheticEvent) => { event.preventDefault(); if (this.state.newPassword === '') { this.setState( { newPassword: '', confirmNewPassword: '', errorMessage: 'empty password', }, () => { invariant(this.newPasswordInput, 'newPasswordInput ref unset'); this.newPasswordInput.focus(); }, ); } else if (this.state.newPassword !== this.state.confirmNewPassword) { this.setState( { newPassword: '', confirmNewPassword: '', errorMessage: 'passwords don’t match', }, () => { invariant(this.newPasswordInput, 'newPasswordInput ref unset'); this.newPasswordInput.focus(); }, ); return; } this.props.dispatchActionPromise( changeUserPasswordActionTypes, this.changeUserSettingsAction(), ); }; async changeUserSettingsAction() { try { await this.props.changeUserPassword({ updatedFields: { password: this.state.newPassword, }, currentPassword: this.state.currentPassword, }); this.props.popModal(); } catch (e) { if (e.message === 'invalid_credentials') { this.setState( { currentPassword: '', errorMessage: 'wrong current password', }, () => { invariant( this.currentPasswordInput, 'currentPasswordInput ref unset', ); this.currentPasswordInput.focus(); }, ); } else { this.setState( { newPassword: '', confirmNewPassword: '', currentPassword: '', errorMessage: 'unknown error', }, () => { invariant(this.newPasswordInput, 'newPasswordInput ref unset'); this.newPasswordInput.focus(); }, ); } throw e; } } } const changeUserPasswordLoadingStatusSelector = createLoadingStatusSelector( changeUserPasswordActionTypes, ); const ConnectedPasswordChangeModal: React.ComponentType<{}> = React.memo<{}>( function ConnectedPasswordChangeModal(): React.Node { const currentUserInfo = useSelector(state => state.currentUserInfo); const inputDisabled = useSelector( state => changeUserPasswordLoadingStatusSelector(state) === 'loading', ); const callChangeUserPassword = useServerCall(changeUserPassword); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); return ( ); }, ); export default ConnectedPasswordChangeModal; diff --git a/web/settings/relationship/add-users-list-modal.react.js b/web/settings/relationship/add-users-list-modal.react.js index 03ef6b637..48d550890 100644 --- a/web/settings/relationship/add-users-list-modal.react.js +++ b/web/settings/relationship/add-users-list-modal.react.js @@ -1,65 +1,65 @@ // @flow import * as React from 'react'; import type { UserRelationshipStatus, RelationshipAction, } from 'lib/types/relationship-types.js'; -import type { ButtonVariant } from '../../components/button.react.js'; +import type { ButtonColor } from '../../components/button.react.js'; import SearchModal from '../../modals/search-modal.react'; import AddUsersList from './add-users-list.react.js'; type Props = { +closeModal: () => void, +name: string, +excludedStatuses: $ReadOnlySet, +confirmButtonContent: React.Node, - +confirmButtonVariant?: ButtonVariant, + +confirmButtonColor?: ButtonColor, +relationshipAction: RelationshipAction, }; function AddUsersListModal(props: Props): React.Node { const { closeModal, name, excludedStatuses, confirmButtonContent, - confirmButtonVariant = 'primary', + confirmButtonColor, relationshipAction, } = props; const addUsersListChildGenerator = React.useCallback( (searchText: string) => ( ), [ excludedStatuses, confirmButtonContent, - confirmButtonVariant, + confirmButtonColor, relationshipAction, closeModal, ], ); return ( {addUsersListChildGenerator} ); } export default AddUsersListModal; diff --git a/web/settings/relationship/add-users-list.react.js b/web/settings/relationship/add-users-list.react.js index 5d12513c4..d67335110 100644 --- a/web/settings/relationship/add-users-list.react.js +++ b/web/settings/relationship/add-users-list.react.js @@ -1,250 +1,251 @@ // @flow import * as React from 'react'; import { updateRelationships, updateRelationshipsActionTypes, } from 'lib/actions/relationship-actions.js'; import { searchUsers } from 'lib/actions/user-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { userStoreSearchIndex as userStoreSearchIndexSelector } from 'lib/selectors/user-selectors.js'; import type { UserRelationshipStatus, RelationshipAction, } from 'lib/types/relationship-types.js'; import type { GlobalAccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import Button from '../../components/button.react.js'; -import type { ButtonVariant } from '../../components/button.react.js'; +import type { ButtonColor } from '../../components/button.react.js'; import Label from '../../components/label.react.js'; import LoadingIndicator from '../../loading-indicator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import AddUsersListItem from './add-users-list-item.react.js'; import css from './add-users-list.css'; const loadingStatusSelector = createLoadingStatusSelector( updateRelationshipsActionTypes, ); type Props = { +searchText: string, +excludedStatuses?: $ReadOnlySet, +closeModal: () => void, +confirmButtonContent: React.Node, - +confirmButtonVariant: ButtonVariant, + +confirmButtonColor?: ButtonColor, +relationshipAction: RelationshipAction, }; function AddUsersList(props: Props): React.Node { const { searchText, excludedStatuses = new Set(), closeModal, confirmButtonContent, - confirmButtonVariant, + confirmButtonColor, relationshipAction, } = props; const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); const [userStoreSearchResults, setUserStoreSearchResults] = React.useState< $ReadOnlySet, >(new Set(userStoreSearchIndex.getSearchResults(searchText))); React.useEffect(() => { setUserStoreSearchResults( new Set(userStoreSearchIndex.getSearchResults(searchText)), ); }, [searchText, userStoreSearchIndex]); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const callSearchUsers = useServerCall(searchUsers); React.useEffect(() => { (async () => { if (searchText.length === 0) { setServerSearchResults([]); } else { const { userInfos } = await callSearchUsers(searchText); setServerSearchResults(userInfos); } })(); }, [callSearchUsers, searchText]); const searchTextPresent = searchText.length > 0; const userInfos = useSelector(state => state.userStore.userInfos); const mergedUserInfos = React.useMemo(() => { const mergedInfos = {}; for (const userInfo of serverSearchResults) { mergedInfos[userInfo.id] = userInfo; } const userStoreUserIDs = searchTextPresent ? userStoreSearchResults : Object.keys(userInfos); for (const id of userStoreUserIDs) { const { username, relationshipStatus } = userInfos[id]; if (username) { mergedInfos[id] = { id, username, relationshipStatus }; } } return mergedInfos; }, [ searchTextPresent, serverSearchResults, userInfos, userStoreSearchResults, ]); const sortedUsers = React.useMemo( () => Object.keys(mergedUserInfos) .map(userID => mergedUserInfos[userID]) .filter(user => !excludedStatuses.has(user.relationshipStatus)) .sort((user1, user2) => user1.username.localeCompare(user2.username)), [excludedStatuses, mergedUserInfos], ); const [pendingUsersToAdd, setPendingUsersToAdd] = React.useState< $ReadOnlyArray, >([]); const selectUser = React.useCallback( (userID: string) => { setPendingUsersToAdd(pendingUsers => { const username = mergedUserInfos[userID]?.username; if (!username || pendingUsers.some(user => user.id === userID)) { return pendingUsers; } const newPendingUser = { id: userID, username, }; let targetIndex = 0; while ( targetIndex < pendingUsers.length && newPendingUser.username.localeCompare( pendingUsers[targetIndex].username, ) > 0 ) { targetIndex++; } return [ ...pendingUsers.slice(0, targetIndex), newPendingUser, ...pendingUsers.slice(targetIndex), ]; }); }, [mergedUserInfos], ); const deselectUser = React.useCallback( (userID: string) => setPendingUsersToAdd(pendingUsers => pendingUsers.filter(userInfo => userInfo.id !== userID), ), [], ); const pendingUserIDs = React.useMemo( () => new Set(pendingUsersToAdd.map(userInfo => userInfo.id)), [pendingUsersToAdd], ); const userTags = React.useMemo(() => { if (pendingUsersToAdd.length === 0) { return null; } const tags = pendingUsersToAdd.map(userInfo => ( )); return
{tags}
; }, [deselectUser, pendingUsersToAdd]); const filteredUsers = React.useMemo( () => sortedUsers.filter(userInfo => !pendingUserIDs.has(userInfo.id)), [pendingUserIDs, sortedUsers], ); const userRows = React.useMemo( () => filteredUsers.map(userInfo => ( )), [filteredUsers, selectUser], ); const [errorMessage, setErrorMessage] = React.useState(''); const callUpdateRelationships = useServerCall(updateRelationships); const dispatchActionPromise = useDispatchActionPromise(); const updateRelationshipsPromiseCreator = React.useCallback(async () => { try { setErrorMessage(''); const result = await callUpdateRelationships({ action: relationshipAction, userIDs: Array.from(pendingUserIDs), }); closeModal(); return result; } catch (e) { setErrorMessage('unknown error'); throw e; } }, [callUpdateRelationships, closeModal, pendingUserIDs, relationshipAction]); const confirmSelection = React.useCallback( () => dispatchActionPromise( updateRelationshipsActionTypes, updateRelationshipsPromiseCreator(), ), [dispatchActionPromise, updateRelationshipsPromiseCreator], ); const loadingStatus = useSelector(loadingStatusSelector); let buttonContent = confirmButtonContent; if (loadingStatus === 'loading') { buttonContent = ( <>
{confirmButtonContent}
); } let errors; if (errorMessage) { errors =
{errorMessage}
; } return (
{userTags}
{userRows}
{errors}
-
); } export default AddUsersList; diff --git a/web/settings/relationship/block-users-modal.react.js b/web/settings/relationship/block-users-modal.react.js index bcafac1dc..39808936c 100644 --- a/web/settings/relationship/block-users-modal.react.js +++ b/web/settings/relationship/block-users-modal.react.js @@ -1,45 +1,46 @@ // @flow import { faUserShield } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as React from 'react'; import { relationshipActions, userRelationshipStatus, } from 'lib/types/relationship-types.js'; +import { buttonThemes } from '../../components/button.react'; import AddUsersListModal from './add-users-list-modal.react.js'; const excludedStatuses = new Set([ userRelationshipStatus.BOTH_BLOCKED, userRelationshipStatus.BLOCKED_BY_VIEWER, ]); type Props = { +onClose: () => void, }; function BlockUsersModal(props: Props): React.Node { const { onClose } = props; const buttonContent = (
{' Block Users'}
); return ( ); } export default BlockUsersModal; diff --git a/web/settings/relationship/user-list-row.css b/web/settings/relationship/user-list-row.css index aa433e8d4..27a9bdd4c 100644 --- a/web/settings/relationship/user-list-row.css +++ b/web/settings/relationship/user-list-row.css @@ -1,39 +1,39 @@ .container { display: flex; justify-content: space-between; padding: 16px; color: var(--relationship-modal-color); font-size: var(--l-font-18); line-height: var(--line-height-display); } .usernameContainer { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .buttons { display: flex; flex-direction: row; align-items: center; gap: 8px; } .button { font-size: var(--s-font-14); line-height: var(--line-height-text); - color: var(--btn-bg-primary); + color: var(--btn-bg-filled); cursor: pointer; background: none; border: none; white-space: nowrap; } .destructive { color: var(--btn-bg-danger); } .edit_menu { position: relative; } diff --git a/web/theme.css b/web/theme.css index fb4f71b67..73d95aef5 100644 --- a/web/theme.css +++ b/web/theme.css @@ -1,190 +1,183 @@ :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); - --btn-bg-primary-hover: var(--violet-dark-80); - --btn-bg-primary-disabled: var(--shades-black-80); - --btn-bg-secondary: var(--shades-black-90); - --btn-bg-secondary-hover: var(--shades-black-80); - --btn-bg-secondary-disabled: var(--shades-black-90); + --btn-bg-filled: var(--violet-dark-100); + --btn-bg-outline: var(--shades-black-90); --btn-bg-success: var(--success-dark-50); - --btn-bg-success-hover: var(--success-dark-90); - --btn-bg-success-disabled: var(--shades-black-80); --btn-bg-danger: var(--error-primary); - --btn-bg-danger-hover: var(--error-dark-50); - --btn-bg-danger-disabled: var(--shades-black-80); + --btn-bg-disabled: var(--shades-black-80); --btn-disabled-color: var(--shades-black-60); --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); --thread-hover-bg: var(--shades-black-80); --thread-active-bg: var(--shades-black-80); --chat-timestamp-color: var(--shades-black-60); --tool-tip-bg: var(--shades-black-80); --tool-tip-color: var(--shades-white-60); --border-color: var(--shades-black-80); --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); --community-settings-selected: var(--violet-dark-60); --unread-bg: var(--error-primary); --settings-btn-bg: var(--violet-dark-100); --modal-bg: var(--shades-black-90); --modal-fg: var(--shades-white-60); --join-bg: var(--shades-black-90); --help-color: var(--shades-black-60); --breadcrumb-color: var(--shades-white-60); --breadcrumb-color-unread: var(--shades-white-60); - --btn-secondary-border: var(--shades-black-60); + --btn-outline-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); --disconnected-bar-alert-bg: var(--error-dark-50); --disconnected-bar-alert-color: var(--shades-white-100); --disconnected-bar-connecting-bg: var(--shades-white-70); --disconnected-bar-connecting-color: var(--shades-black-100); --permission-color: var(--shades-white-60); --thread-top-bar-color: var(--shades-white-100); --thread-top-bar-menu-color: var(--shades-white-70); --thread-ancestor-keyserver-border: var(--shades-black-70); --thread-ancestor-color-light: var(--shades-white-70); --thread-ancestor-color-dark: var(--shades-black-100); --thread-ancestor-separator-color: var(--shades-white-60); --text-message-default-background: var(--shades-black-80); --message-action-tooltip-bg: var(--shades-black-90); --menu-bg: var(--shades-black-90); --menu-bg-light: var(--shades-black-80); --menu-separator-color: var(--shades-black-80); --menu-color: var(--shades-black-60); --menu-color-light: var(--shades-white-60); --menu-color-hover: var(--shades-white-100); --menu-color-dangerous: var(--error-primary); --menu-color-dangerous-hover: var(--error-light-50); --app-list-icon-read-only-color: var(--shades-black-60); --app-list-icon-enabled-color: var(--success-primary); --app-list-icon-disabled-color: var(--shades-white-80); --account-settings-label: var(--shades-black-60); --account-button-color: var(--violet-dark-100); --chat-thread-list-color-active: var(--shades-white-60); --chat-thread-list-menu-color: var(--shades-white-60); --chat-thread-list-menu-bg: var(--shades-black-80); --chat-thread-list-menu-active-color: var(--shades-white-60); --chat-thread-list-menu-active-bg: var(--shades-black-90); --search-clear-color: var(--shades-white-100); --search-clear-bg: var(--shades-black-70); --search-input-color: var(--shades-white-100); --search-input-placeholder: var(--shades-black-60); --search-icon-color: var(--shades-black-60); --tabs-header-active-color: var(--shades-white-100); --tabs-header-active-border: var(--violet-light-100); --tabs-header-background-color: var(--shades-black-60); --tabs-header-background-border: var(--shades-black-80); --tabs-header-background-color-hover: var(--shades-white-80); --tabs-header-background-border-hover: var(--shades-black-70); --members-modal-member-text: var(--shades-black-60); --members-modal-member-text-hover: var(--shades-white-100); --label-default-bg: var(--violet-dark-80); --label-default-color: var(--shades-white-80); --subchannels-modal-color: var(--shades-black-60); --subchannels-modal-color-hover: var(--shades-white-100); --color-selector-active-bg: var(--shades-black-80); --relationship-modal-color: var(--shades-black-60); --arrow-extension-color: var(--shades-black-60); --modal-close-color: var(--shades-black-60); --modal-close-color-hover: var(--shades-white-100); --add-members-group-header-color: var(--shades-black-60); --add-members-item-color: var(--shades-black-60); --add-members-item-color-hover: var(--shades-white-100); --add-members-item-disabled-color: var(--shades-black-80); --add-members-item-disabled-color-hover: var(--shades-black-60); --add-members-remove-pending-color: var(--error-primary); --add-members-remove-pending-color-hover: var(--error-light-50); --radio-border: var(--shades-black-70); --radio-color: var(--shades-white-60); --notification-settings-option-selected-bg: var(--shades-black-80); --notification-settings-option-title-color: var(--shades-white-90); --notification-settings-option-color: var(--shades-white-60); --notification-settings-option-invalid-color: var(--shades-black-80); --notification-settings-option-invalid-selected-color: var(--shades-black-60); --danger-zone-subheading-color: var(--shades-white-60); --danger-zone-explanation-color: var(--shades-black-60); --thread-creation-search-container-bg: var(--shades-black-90); --thread-creation-close-search-color: var(--shades-black-60); --thread-creation-search-item-bg-hover: var(--shades-black-80); --thread-creation-search-item-info-color: var(--shades-black-60); --chat-message-list-active-border: #5989d6; --sidebars-modal-color: var(--shades-black-60); --sidebars-modal-color-hover: var(--shades-white-100); --inline-sidebar-bg: var(--shades-black-70); --inline-sidebar-bg-hover: var(--shades-black-80); --inline-sidebar-color: var(--fg); --compose-subchannel-header-fg: var(--shades-black-60); --compose-subchannel-header-bg: var(--shades-black-80); --compose-subchannel-label-color: var(--shades-black-60); --compose-subchannel-mark-color: var(--violet-light-100); --enum-option-icon-color: var(--violet-dark-100); }