diff --git a/web/account/log-in-form.react.js b/web/account/log-in-form.react.js index 5e406c7fe..5869a3427 100644 --- a/web/account/log-in-form.react.js +++ b/web/account/log-in-form.react.js @@ -1,162 +1,167 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import { useModalContext } from 'lib/components/modal-provider.react'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { oldValidUsernameRegex, validEmailRegex, } from 'lib/shared/account-utils'; import { type LogInExtraInfo, type LogInStartingPayload, loginActionSources, } from 'lib/types/account-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import LoadingIndicator from '../loading-indicator.react'; import Input from '../modals/input.react'; import { useSelector } from '../redux/redux-utils'; import { webLogInExtraInfoSelector } from '../selectors/account-selectors'; import css from './log-in-form.css'; const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); function LoginForm(): React.Node { const inputDisabled = useSelector(loadingStatusSelector) === 'loading'; const loginExtraInfo = useSelector(webLogInExtraInfoSelector); const callLogIn = useServerCall(logIn); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); const [username, setUsername] = React.useState(''); const [password, setPassword] = React.useState(''); const [errorMessage, setErrorMessage] = React.useState(''); const usernameInputRef = React.useRef(); React.useEffect(() => { usernameInputRef.current?.focus(); }, []); const onUsernameChange = React.useCallback(e => { invariant(e.target instanceof HTMLInputElement, 'target not input'); setUsername(e.target.value); }, []); const onUsernameBlur = React.useCallback(() => { setUsername(untrimmedUsername => untrimmedUsername.trim()); }, []); const onPasswordChange = React.useCallback(e => { invariant(e.target instanceof HTMLInputElement, 'target not input'); setPassword(e.target.value); }, []); const logInAction = React.useCallback( async (extraInfo: LogInExtraInfo) => { try { const result = await callLogIn({ ...extraInfo, username, password, source: loginActionSources.logInFromWebForm, }); modalContext.popModal(); return result; } catch (e) { setUsername(''); setPassword(''); if (e.message === 'invalid_credentials') { setErrorMessage('incorrect username or password'); } else { setErrorMessage('unknown error'); } usernameInputRef.current?.focus(); throw e; } }, [callLogIn, modalContext, password, username], ); const onSubmit = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (username.search(validEmailRegex) > -1) { setUsername(''); setErrorMessage('usernames only, not emails'); usernameInputRef.current?.focus(); return; } else if (username.search(oldValidUsernameRegex) === -1) { setUsername(''); setErrorMessage('alphanumeric usernames only'); usernameInputRef.current?.focus(); return; } const extraInfo = loginExtraInfo(); dispatchActionPromise( logInActionTypes, logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }, [dispatchActionPromise, logInAction, loginExtraInfo, username], ); const loginButtonContent = React.useMemo(() => { if (inputDisabled) { return ; } return 'Log in'; }, [inputDisabled]); return (
Username
Password
-
{errorMessage}
); } export default LoginForm; diff --git a/web/chat/chat-thread-list.react.js b/web/chat/chat-thread-list.react.js index b2af2a791..0dd48b0d5 100644 --- a/web/chat/chat-thread-list.react.js +++ b/web/chat/chat-thread-list.react.js @@ -1,76 +1,80 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { emptyItemText } from 'lib/shared/thread-utils'; import BackgroundIllustration from '../assets/background-illustration.react'; import Button from '../components/button.react'; import Search from '../components/search.react'; import { useSelector } from '../redux/redux-utils'; import { useOnClickNewThread } from '../selectors/nav-selectors'; import ChatThreadListItem from './chat-thread-list-item.react'; import css from './chat-thread-list.css'; import { ThreadListContext } from './thread-list-provider'; function ChatThreadList(): React.Node { const threadListContext = React.useContext(ThreadListContext); invariant( threadListContext, 'threadListContext should be set in ChatThreadList', ); const { activeTab, threadList, setSearchText, searchText, } = threadListContext; const onClickNewThread = useOnClickNewThread(); const isThreadCreation = useSelector( state => state.navInfo.chatMode === 'create', ); const isBackground = activeTab === 'Background'; const threadComponents: React.Node[] = React.useMemo(() => { const threads = threadList.map(item => ( )); if (threads.length === 0 && isBackground) { threads.push(); } return threads; }, [threadList, isBackground]); return ( <>
{threadComponents}
-
); } function EmptyItem() { return (
{emptyItemText}
); } export default ChatThreadList; diff --git a/web/components/button.css b/web/components/button.css index 0b631348f..679de6cf6 100644 --- a/web/components/button.css +++ b/web/components/button.css @@ -1,72 +1,76 @@ +.plain { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + cursor: pointer; + background: none; + border: none; +} + .btn { --border-width: 1px; --border-radius: 4px; position: relative; - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - border: var(--border-width) solid transparent; font-size: var(--m-font-16); padding: 12px 24px; color: var(--fg); + border: var(--border-width) solid transparent; border-radius: var(--border-radius); - cursor: pointer; } .btn.outline { border: var(--border-width) solid var(--btn-outline-border); } .btn > * { position: relative; } .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%; } .btn.outline::before { top: 0; left: 0; border: none; border-radius: calc(var(--border-radius) - var(--border-width)); } .btn:hover::before { transition-duration: 200ms; transition-property: filter; } .btn:hover:not(:disabled)::before { filter: brightness(0.8); } .btn.outline:hover:not(:disabled)::before { filter: brightness(2); } .btn:disabled { cursor: not-allowed; color: var(--btn-disabled-color); } .btn:not(.outline):disabled::before { background-color: var(--btn-bg-disabled); } .text { + font-size: var(--m-font-16); background: transparent; white-space: nowrap; - padding: 0; - border: none; } diff --git a/web/components/button.react.js b/web/components/button.react.js index 7abf01a0b..13d7529af 100644 --- a/web/components/button.react.js +++ b/web/components/button.react.js @@ -1,81 +1,85 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import css from './button.css'; -export type ButtonVariant = 'filled' | 'outline' | 'text'; +export type ButtonVariant = 'plain' | 'filled' | 'outline' | 'text'; 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, + +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 = 'filled', + variant = 'plain', buttonColor, - type, + type = 'button', disabled = false, className = '', } = props; - const btnCls = classnames(css.btn, css[variant]); + const btnCls = classnames({ + [css.plain]: true, + [css.btn]: variant === 'filled' || variant === 'outline', + [css[variant]]: true, + }); let style = {}; if (buttonColor) { style = buttonColor; } else if (variant === 'outline') { style = buttonThemes.outline; } else if (variant === 'filled') { 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/modals/concurrent-modification-modal.react.js b/web/modals/concurrent-modification-modal.react.js index 870a8051f..b3cf587ae 100644 --- a/web/modals/concurrent-modification-modal.react.js +++ b/web/modals/concurrent-modification-modal.react.js @@ -1,35 +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 './concurrent-modification-modal.css'; import Modal from './modal.react'; type Props = { +onRefresh: () => void, }; export default function ConcurrentModificationModal(props: Props): React.Node { const modalContext = useModalContext(); return (

It looks like somebody is attempting to modify that field at the same time as you! Please refresh the entry and try again.

); } diff --git a/web/modals/threads/members/members-modal.react.js b/web/modals/threads/members/members-modal.react.js index 2ebea95d7..ffbea08c0 100644 --- a/web/modals/threads/members/members-modal.react.js +++ b/web/modals/threads/members/members-modal.react.js @@ -1,141 +1,143 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userStoreSearchIndex } from 'lib/selectors/user-selectors'; import { memberHasAdminPowers, memberIsAdmin, threadHasPermission, } from 'lib/shared/thread-utils'; import { type RelativeMemberInfo, threadPermissions, } from 'lib/types/thread-types'; import Button from '../../../components/button.react'; import Tabs from '../../../components/tabs.react'; import { useSelector } from '../../../redux/redux-utils'; import SearchModal from '../../search-modal.react'; import AddMembersModal from './add-members-modal.react'; import ThreadMembersList from './members-list.react'; import css from './members-modal.css'; type ContentProps = { +searchText: string, +threadID: string, }; function ThreadMembersModalContent(props: ContentProps): React.Node { const { threadID, searchText } = props; const [tab, setTab] = React.useState<'All Members' | 'Admins'>('All Members'); const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const { members: threadMembersNotFiltered } = threadInfo; const userSearchIndex = useSelector(userStoreSearchIndex); const userIDs = React.useMemo( () => userSearchIndex.getSearchResults(searchText), [searchText, userSearchIndex], ); const allMembers = React.useMemo( () => threadMembersNotFiltered.filter( (member: RelativeMemberInfo) => searchText.length === 0 || userIDs.includes(member.id), ), [searchText.length, threadMembersNotFiltered, userIDs], ); const adminMembers = React.useMemo( () => allMembers.filter( (member: RelativeMemberInfo) => memberIsAdmin(member, threadInfo) || memberHasAdminPowers(member), ), [allMembers, threadInfo], ); const allUsersTab = React.useMemo( () => ( ), [allMembers, threadInfo], ); const allAdminsTab = React.useMemo( () => ( ), [adminMembers, threadInfo], ); const { pushModal, popModal } = useModalContext(); const onClickAddMembers = React.useCallback(() => { pushModal(); }, [popModal, pushModal, threadID]); const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); const addMembersButton = React.useMemo(() => { if (!canAddMembers) { return null; } return (
- +
); }, [canAddMembers, onClickAddMembers]); return (
{allUsersTab} {allAdminsTab}
{addMembersButton}
); } type Props = { +threadID: string, +onClose: () => void, }; function ThreadMembersModal(props: Props): React.Node { const { onClose, threadID } = props; const renderModalContent = React.useCallback( (searchText: string) => ( ), [threadID], ); return ( {renderModalContent} ); } export default ThreadMembersModal; diff --git a/web/modals/threads/notifications/notifications-modal.react.js b/web/modals/threads/notifications/notifications-modal.react.js index 034a2a918..392e60acf 100644 --- a/web/modals/threads/notifications/notifications-modal.react.js +++ b/web/modals/threads/notifications/notifications-modal.react.js @@ -1,271 +1,271 @@ // @flow import * as React from 'react'; import { updateSubscription, updateSubscriptionActionTypes, } from 'lib/actions/user-actions'; import { canPromoteSidebar } from 'lib/hooks/promote-sidebar.react'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { threadIsSidebar } from 'lib/shared/thread-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import AllNotifsIllustration from '../../../assets/all-notifs.react.js'; import BadgeNotifsIllustration from '../../../assets/badge-notifs.react.js'; import MutedNotifsIllustration from '../../../assets/muted-notifs.react.js'; import Button from '../../../components/button.react'; import EnumSettingsOption from '../../../components/enum-settings-option.react'; import { useSelector } from '../../../redux/redux-utils'; import Modal from '../../modal.react'; import css from './notifications-modal.css'; type NotificationSettings = 'focused' | 'badge-only' | 'background'; const BANNER_NOTIFS = 'Banner notifs'; const BADGE_COUNT = 'Badge count'; const IN_FOCUSED_TAB = 'Lives in Focused tab'; const IN_BACKGROUND_TAB = 'Lives in Background tab'; const focusedStatements = [ { statement: BANNER_NOTIFS, isStatementValid: true, styleStatementBasedOnValidity: true, }, { statement: BADGE_COUNT, isStatementValid: true, styleStatementBasedOnValidity: true, }, { statement: IN_FOCUSED_TAB, isStatementValid: true, styleStatementBasedOnValidity: true, }, ]; const badgeOnlyStatements = [ { statement: BANNER_NOTIFS, isStatementValid: false, styleStatementBasedOnValidity: true, }, { statement: BADGE_COUNT, isStatementValid: true, styleStatementBasedOnValidity: true, }, { statement: IN_FOCUSED_TAB, isStatementValid: true, styleStatementBasedOnValidity: true, }, ]; const backgroundStatements = [ { statement: BANNER_NOTIFS, isStatementValid: false, styleStatementBasedOnValidity: true, }, { statement: BADGE_COUNT, isStatementValid: false, styleStatementBasedOnValidity: true, }, { statement: IN_BACKGROUND_TAB, isStatementValid: true, styleStatementBasedOnValidity: true, }, ]; type Props = { +threadID: string, +onClose: () => void, }; function NotificationsModal(props: Props): React.Node { const { onClose, threadID } = props; const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const { subscription } = threadInfo.currentUser; const { parentThreadID } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const isSidebar = threadIsSidebar(threadInfo); const initialThreadSetting = React.useMemo(() => { if (!subscription.home) { return 'background'; } if (!subscription.pushNotifs) { return 'badge-only'; } return 'focused'; }, [subscription.home, subscription.pushNotifs]); const [ notificationSettings, setNotificationSettings, ] = React.useState(initialThreadSetting); const onFocusedSelected = React.useCallback( () => setNotificationSettings('focused'), [], ); const onBadgeOnlySelected = React.useCallback( () => setNotificationSettings('badge-only'), [], ); const onBackgroundSelected = React.useCallback( () => setNotificationSettings('background'), [], ); const isFocusedSelected = notificationSettings === 'focused'; const focusedItem = React.useMemo(() => { const icon = ; return ( ); }, [isFocusedSelected, onFocusedSelected]); const isFocusedBadgeOnlySelected = notificationSettings === 'badge-only'; const focusedBadgeOnlyItem = React.useMemo(() => { const icon = ; return ( ); }, [isFocusedBadgeOnlySelected, onBadgeOnlySelected]); const isBackgroundSelected = notificationSettings === 'background'; const backgroundItem = React.useMemo(() => { const icon = ; return ( ); }, [isBackgroundSelected, onBackgroundSelected, isSidebar]); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); const onClickSave = React.useCallback(() => { dispatchActionPromise( updateSubscriptionActionTypes, callUpdateSubscription({ threadID: threadID, updatedFields: { home: notificationSettings !== 'background', pushNotifs: notificationSettings === 'focused', }, }), ); onClose(); }, [ callUpdateSubscription, dispatchActionPromise, notificationSettings, onClose, threadID, ]); const modalName = isSidebar ? 'Thread notifications' : 'Channel notifications'; let modalContent; if (isSidebar && !parentThreadInfo?.currentUser.subscription.home) { modalContent = ( <>

{'It’s not possible to change the notif settings for a thread ' + 'whose parent is in Background. That’s because Comm’s design ' + 'always shows threads underneath their parent in the Inbox, ' + 'which means that if a thread’s parent is in Background, the ' + 'thread must also be there.'}

{canPromoteSidebar(threadInfo, parentThreadInfo) ? 'If you want to change the notif settings for this thread, ' + 'you can either change the notif settings for the parent, ' + 'or you can promote the thread to a channel.' : 'If you want to change the notif settings for this thread, ' + 'you’ll have to change the notif settings for the parent.'}

); } else { let noticeText = null; if (isSidebar) { noticeText = ( <>

{'It’s not possible to move this thread to Background. ' + 'That’s because Comm’s design always shows threads ' + 'underneath their parent in the Inbox, which means ' + 'that if a thread’s parent is in Focused, the thread ' + 'must also be there.'}

{canPromoteSidebar(threadInfo, parentThreadInfo) ? 'If you want to move this thread to Background, ' + 'you can either move the parent to Background, ' + 'or you can promote the thread to a channel.' : 'If you want to move this thread to Background, ' + 'you’ll have to move the parent to Background.'}

); } modalContent = ( <>
{focusedItem} {focusedBadgeOnlyItem} {backgroundItem}
{noticeText} ); } return (
{modalContent}
); } export default NotificationsModal; diff --git a/web/modals/threads/settings/thread-settings-general-tab.react.js b/web/modals/threads/settings/thread-settings-general-tab.react.js index 2e11809b9..30352f9ab 100644 --- a/web/modals/threads/settings/thread-settings-general-tab.react.js +++ b/web/modals/threads/settings/thread-settings-general-tab.react.js @@ -1,198 +1,199 @@ // @flow import * as React from 'react'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { threadHasPermission } from 'lib/shared/thread-utils'; import { type SetState } from 'lib/types/hook-types'; import { type ThreadInfo, type ThreadChanges, threadPermissions, } from 'lib/types/thread-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import { firstLine } from 'lib/utils/string-utils'; import LoadingIndicator from '../../../loading-indicator.react'; import Input from '../../input.react'; import ColorSelector from '../color-selector.react'; import SubmitSection from './submit-section.react'; import css from './thread-settings-general-tab.css'; type ThreadSettingsGeneralTabProps = { +threadSettingsOperationInProgress: boolean, +threadInfo: ThreadInfo, +threadNamePlaceholder: string, +queuedChanges: ThreadChanges, +setQueuedChanges: SetState, +setErrorMessage: SetState, +errorMessage?: ?string, }; function ThreadSettingsGeneralTab( props: ThreadSettingsGeneralTabProps, ): React.Node { const { threadSettingsOperationInProgress, threadInfo, threadNamePlaceholder, queuedChanges, setQueuedChanges, setErrorMessage, errorMessage, } = props; const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); const nameInputRef = React.useRef(); React.useEffect(() => { nameInputRef.current?.focus(); }, [threadSettingsOperationInProgress]); const changeQueued: boolean = React.useMemo( () => Object.values(queuedChanges).some(v => v !== null && v !== undefined), [queuedChanges], ); const onChangeName = React.useCallback( (event: SyntheticEvent) => { const target = event.currentTarget; const newName = firstLine(target.value); setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, name: newName !== threadInfo.name ? newName : undefined, }), ); }, [setQueuedChanges, threadInfo.name], ); const onChangeDescription = React.useCallback( (event: SyntheticEvent) => { const target = event.currentTarget; setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, description: target.value !== threadInfo.description ? target.value : undefined, }), ); }, [setQueuedChanges, threadInfo.description], ); const onChangeColor = React.useCallback( (color: string) => { setQueuedChanges(prevQueuedChanges => Object.freeze({ ...prevQueuedChanges, color: !tinycolor.equals(color, threadInfo.color) ? color : undefined, }), ); }, [setQueuedChanges, threadInfo.color], ); const changeThreadSettingsAction = React.useCallback(async () => { try { setErrorMessage(''); return await callChangeThreadSettings({ threadID: threadInfo.id, changes: queuedChanges, }); } catch (e) { setErrorMessage('unknown_error'); throw e; } finally { setQueuedChanges(Object.freeze({})); } }, [ callChangeThreadSettings, queuedChanges, setErrorMessage, setQueuedChanges, threadInfo.id, ]); const onSubmit = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatchActionPromise( changeThreadSettingsActionTypes, changeThreadSettingsAction(), ); }, [changeThreadSettingsAction, dispatchActionPromise], ); const threadNameInputDisabled = !threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const saveButtonContent = React.useMemo(() => { if (threadSettingsOperationInProgress) { return ; } return 'Save'; }, [threadSettingsOperationInProgress]); return (
Chat name
Description