diff --git a/web/modals/threads/thread-settings-modal.css b/web/modals/threads/thread-settings-modal.css index eae24cd1e..e6449424e 100644 --- a/web/modals/threads/thread-settings-modal.css +++ b/web/modals/threads/thread-settings-modal.css @@ -1,143 +1,202 @@ div.modal-body { padding: 6px 6px; width: 100%; box-sizing: border-box; background-color: var(--modal-bg); border-bottom-left-radius: 15px; border-bottom-right-radius: 15px; flex: 1; display: flex; flex-direction: column; } div.resized-modal-body { min-height: 250px; } div.modal-body p { padding: 1px 3px 4px 3px; font-size: 14px; text-align: center; } div.modal-body p.form-pre-footer { padding-top: 5px; font-size: 12px; font-style: italic; } div.modal-body textarea { margin: 3px; } div.modal-body textarea { font-size: 14px; padding: 1px; width: 175px; } div.large-modal-container div.modal-body textarea { width: 275px; } div.modal-body p.confirm-account-password { margin-bottom: 4px; color: var(--fg); } div.modal-body div.form-footer { display: flex; flex-direction: row-reverse; justify-content: space-between; padding-top: 8px; } div.modal-body div.form-footer div.modal-form-error { font-size: 12px; color: red; font-style: italic; padding-left: 6px; align-self: center; } div.modal-body div.form-footer div.modal-form-error ol { padding-left: 20px; } div.modal-body div.form-title { display: inline-block; text-align: right; padding-right: 5px; padding-top: 5px; font-size: 14px; font-weight: 600; vertical-align: top; width: 110px; color: var(--fg); } div.large-modal-container div.modal-body div.form-title { width: 140px; } div.modal-body div.form-content { display: inline-block; font-family: var(--font-stack); color: var(--fg); } div.modal-body div.form-content input { margin-bottom: 4px; } div.modal-body div.form-subtitle { font-size: 12px; padding-left: 4px; font-style: italic; } div.form-text { display: flex; align-items: baseline; } div.form-text > div.form-title { vertical-align: initial; flex-shrink: 0; } div.form-text > div.form-content { margin-left: 3px; margin-bottom: 3px; word-break: break-word; } div.form-text > div.form-float-title { float: left; text-align: right; padding-right: 5px; font-size: 14px; font-weight: 600; width: 110px; } div.form-text > div.form-float-content { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; padding: 1px 20px 3px 4px; margin-top: 5px; } .italic { font-style: italic; color: var(--fg); } ul.tab-panel { background-color: var(--modal-bg); padding-left: 10px; padding-top: 5px; } ul.tab-panel > li { display: inline-block; list-style-type: none; font-size: 13px; font-weight: 600; cursor: pointer; padding: 3px 10px 3px 10px; } ul.tab-panel > li > a { color: #555555; } ul.tab-panel > li.delete-tab > a { color: #ff0000 !important; } div.user-settings-current-password { padding-top: 4px; margin-top: 5px; } + +div.form-textarea-container { + margin-top: 1px; +} + +div.edit-thread-color-container { + margin-top: -5px; +} + +div.color-title { + margin-top: 4px; +} + +div.edit-thread-privacy-container { + margin-bottom: 6px; +} + +div.form-enum-selector { + display: inline-block; + padding-bottom: 4px; +} +div.form-enum-selector > div.form-enum-container { + padding-top: 5px; +} +div.form-enum-selector > div.form-enum-container > input { + vertical-align: top; + margin-top: 4px; +} +div.form-enum-selector div.form-enum-option { + display: inline-block; + font-size: 15px; + font-weight: 600; + padding-left: 3px; +} +div.form-enum-selector span.form-enum-description { + display: block; + font-family: var(--font-stack); + font-weight: normal; + font-size: 13px; + max-width: 260px; + color: gray; +} + +div.form-enum-selector > div.form-enum-container { + padding-top: 5px; +} +div.form-enum-selector > div.form-enum-container > input { + vertical-align: top; + margin-top: 4px; +} +.italic { + font-style: italic; +} + +div.edit-thread-account-password { + border-top: 2px solid #efefef; + padding-top: 4px; + margin-top: 2px; +} diff --git a/web/modals/threads/thread-settings-modal.react.js b/web/modals/threads/thread-settings-modal.react.js index 9c6dd264a..1f80a84c6 100644 --- a/web/modals/threads/thread-settings-modal.react.js +++ b/web/modals/threads/thread-settings-modal.react.js @@ -1,599 +1,599 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _pickBy from 'lodash/fp/pickBy'; import * as React from 'react'; import { deleteThreadActionTypes, deleteThread, changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { threadHasPermission, threadTypeDescriptions, robotextName, } from 'lib/shared/thread-utils'; import { type ThreadInfo, threadTypes, assertThreadType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, type LeaveThreadPayload, threadPermissions, type ThreadChanges, } from 'lib/types/thread-types'; import type { UserInfos } from 'lib/types/user-types'; import { useDispatchActionPromise, useServerCall, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { firstLine } from 'lib/utils/string-utils'; import Button from '../../components/button.react'; import { useModalContext } from '../../modals/modal-provider.react'; import { useSelector } from '../../redux/redux-utils'; -import css from '../../style.css'; import Modal from '../modal.react'; import ColorPicker from './color-picker.react'; +import css from './thread-settings-modal.css'; const { COMMUNITY_OPEN_SUBTHREAD, COMMUNITY_SECRET_SUBTHREAD } = threadTypes; type TabType = 'general' | 'privacy' | 'delete'; type TabProps = { +name: string, +tabType: TabType, +selected: boolean, +onClick: (tabType: TabType) => void, }; class Tab extends React.PureComponent { render() { const classNamesForTab = classNames({ [css['current-tab']]: this.props.selected, [css['delete-tab']]: this.props.selected && this.props.tabType === 'delete', }); return (
  • {this.props.name}
  • ); } onClick = () => { return this.props.onClick(this.props.tabType); }; } type BaseProps = { +threadID: string, }; type Props = { ...BaseProps, +threadInfo: ThreadInfo, +changeInProgress: boolean, +viewerID: ?string, +userInfos: UserInfos, +dispatchActionPromise: DispatchActionPromise, +deleteThread: ( threadID: string, currentAccountPassword: string, ) => Promise, +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, +onClose: () => void, }; type State = { +queuedChanges: ThreadChanges, +errorMessage: string, +accountPassword: string, +currentTabType: TabType, }; class ThreadSettingsModal extends React.PureComponent { nameInput: ?HTMLInputElement; newThreadPasswordInput: ?HTMLInputElement; accountPasswordInput: ?HTMLInputElement; constructor(props: Props) { super(props); this.state = { queuedChanges: Object.freeze({}), errorMessage: '', accountPassword: '', currentTabType: 'general', }; } componentDidMount() { invariant(this.nameInput, 'nameInput ref unset'); this.nameInput.focus(); } componentDidUpdate(prevProps: Props) { if (this.state.currentTabType !== 'delete') { return; } const permissionForDeleteTab = this.hasPermissionForTab( this.props.threadInfo, 'delete', ); const prevPermissionForDeleteTab = this.hasPermissionForTab( prevProps.threadInfo, 'delete', ); if (!permissionForDeleteTab && prevPermissionForDeleteTab) { this.setTab('general'); } } hasPermissionForTab(threadInfo: ThreadInfo, tab: TabType) { if (tab === 'general') { return threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); } else if (tab === 'privacy') { return threadHasPermission( threadInfo, threadPermissions.EDIT_PERMISSIONS, ); } else if (tab === 'delete') { return threadHasPermission(threadInfo, threadPermissions.DELETE_THREAD); } invariant(false, `invalid tab ${tab}`); } possiblyChangedValue(key: string) { const valueChanged = this.state.queuedChanges[key] !== null && this.state.queuedChanges[key] !== undefined; return valueChanged ? this.state.queuedChanges[key] : this.props.threadInfo[key]; } namePlaceholder() { return robotextName( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ); } changeQueued() { return ( Object.keys( _pickBy( value => value !== null && value !== undefined, // the lodash/fp libdef coerces the returned object's properties to the // same type, which means it only works for object-as-maps $FlowFixMe )(this.state.queuedChanges), ).length > 0 ); } render() { const { threadInfo } = this.props; const inputDisabled = this.props.changeInProgress || !this.hasPermissionForTab(threadInfo, this.state.currentTabType); let mainContent = null; if (this.state.currentTabType === 'general') { mainContent = (
    Thread name
    Description
    Color
    ); } else if (this.state.currentTabType === 'privacy') { mainContent = (
    Thread type
    ); } else if (this.state.currentTabType === 'delete') { mainContent = ( <>

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

    Please enter your account password to confirm your identity

    Account password
    ); } let buttons = null; if (this.state.currentTabType === 'delete') { buttons = ( ); } else { buttons = ( ); } const tabs = [ , ]; // This UI needs to be updated to handle sidebars but we haven't gotten // there yet. We'll probably end up ripping it out anyways, so for now we // are just hiding the privacy tab for any thread that was created as a // sidebar const canSeePrivacyTab = this.possiblyChangedValue('parentThreadID') && threadInfo.sourceMessageID && (threadInfo.type === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadInfo.type === threadTypes.COMMUNITY_SECRET_SUBTHREAD); if (canSeePrivacyTab) { tabs.push( , ); } const canDeleteThread = this.hasPermissionForTab(threadInfo, 'delete'); if (canDeleteThread) { tabs.push( , ); } return (
      {tabs}
    {mainContent}
    {buttons}
    {this.state.errorMessage}
    ); } setTab = (tabType: TabType) => { this.setState({ currentTabType: tabType }); }; nameInputRef = (nameInput: ?HTMLInputElement) => { this.nameInput = nameInput; }; newThreadPasswordInputRef = (newThreadPasswordInput: ?HTMLInputElement) => { this.newThreadPasswordInput = newThreadPasswordInput; }; accountPasswordInputRef = (accountPasswordInput: ?HTMLInputElement) => { this.accountPasswordInput = accountPasswordInput; }; onChangeName = (event: SyntheticEvent) => { const target = event.currentTarget; const newValue = target.value !== this.props.threadInfo.name ? target.value : undefined; this.setState((prevState: State) => ({ ...prevState, queuedChanges: { ...prevState.queuedChanges, name: firstLine(newValue), }, })); }; onChangeDescription = (event: SyntheticEvent) => { const target = event.currentTarget; const newValue = target.value !== this.props.threadInfo.description ? target.value : undefined; this.setState((prevState: State) => ({ ...prevState, queuedChanges: { ...prevState.queuedChanges, description: newValue, }, })); }; onChangeColor = (color: string) => { const newValue = color !== this.props.threadInfo.color ? color : undefined; this.setState((prevState: State) => ({ ...prevState, queuedChanges: { ...prevState.queuedChanges, color: newValue, }, })); }; onChangeThreadType = (event: SyntheticEvent) => { const uiValue = assertThreadType(parseInt(event.currentTarget.value, 10)); const newValue = uiValue !== this.props.threadInfo.type ? uiValue : undefined; this.setState((prevState: State) => ({ ...prevState, queuedChanges: { ...prevState.queuedChanges, type: newValue, }, })); }; onChangeAccountPassword = (event: SyntheticEvent) => { const target = event.currentTarget; this.setState({ accountPassword: target.value }); }; onSubmit = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.changeThreadSettingsAction(), ); }; async changeThreadSettingsAction() { try { const response = await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: this.state.queuedChanges, }); this.props.onClose(); return response; } catch (e) { this.setState( prevState => ({ ...prevState, queuedChanges: Object.freeze({}), accountPassword: '', errorMessage: 'unknown error', currentTabType: 'general', }), () => { invariant(this.nameInput, 'nameInput ref unset'); this.nameInput.focus(); }, ); throw e; } } onDelete = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThreadAction(), ); }; async deleteThreadAction() { try { const response = await this.props.deleteThread( this.props.threadInfo.id, this.state.accountPassword, ); this.props.onClose(); return response; } catch (e) { const errorMessage = e.message === 'invalid_credentials' ? 'wrong password' : 'unknown error'; this.setState( { accountPassword: '', errorMessage: errorMessage, }, () => { invariant( this.accountPasswordInput, 'accountPasswordInput ref unset', ); this.accountPasswordInput.focus(); }, ); throw e; } } } const deleteThreadLoadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); const ConnectedThreadSettingsModal: React.ComponentType = React.memo( function ConnectedThreadSettingsModal(props) { const changeInProgress = useSelector( state => deleteThreadLoadingStatusSelector(state) === 'loading' || changeThreadSettingsLoadingStatusSelector(state) === 'loading', ); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const userInfos = useSelector(state => state.userStore.userInfos); const callDeleteThread = useServerCall(deleteThread); const callChangeThreadSettings = useServerCall(changeThreadSettings); const dispatchActionPromise = useDispatchActionPromise(); const threadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[props.threadID], ); const modalContext = useModalContext(); if (!threadInfo) { return (

    You no longer have permission to view this thread

    ); } return ( ); }, ); export default ConnectedThreadSettingsModal; diff --git a/web/style.css b/web/style.css index 385f0f5d1..b3d475ad1 100644 --- a/web/style.css +++ b/web/style.css @@ -1,294 +1,283 @@ *, *:before, *:after { padding: 0; margin: 0; -ms-overflow-style: -ms-autohiding-scrollbar; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } html { height: 100%; font-size: 62.5%; } body { font-family: var(--font-stack); background: var(--bg); height: 100%; overflow: hidden; font-size: 1.6rem; } a { text-decoration: none; color: #2a5db0; cursor: pointer; } button { cursor: pointer; } img, iframe { display: block; } input[type='text'], input[type='password'], textarea { -webkit-appearance: none; -moz-appearance: none; -webkit-border-radius: 0; border: 1px solid #dddddd; border-radius: 1px; font-family: var(--font-stack); } button svg { vertical-align: top; } :global(#react-root) { display: flex; flex-direction: column; height: 100%; } div.layout { height: 100vh; display: grid; grid-template-columns: 244px repeat(12, 1fr); grid-template-rows: 65px calc(100vh - 65px); grid-template-areas: 'nav nav nav nav nav nav nav nav nav nav nav nav nav' 'sBar app app app app app app app app app app app app'; } header.header { background: var(--bg); z-index: 1; grid-area: nav; } div.main-header { height: 64px; background: var(--bg); display: flex; align-items: center; border-bottom: 1px solid var(--border-color); } div.main-header > h1 { color: var(--fg); padding-left: 92px; font-family: var(--font-logo); line-height: var(--line-height-text); font-weight: var(--semi-bold); font-size: var(--logo-font-22); } div.main-content-container { position: relative; grid-area: app; } div.main-content { height: 100%; } div.upper-right { position: absolute; top: 0; right: 0; padding: 15px 16px; } span.loading-indicator-loading { display: inline-block; } @keyframes loading-indicator-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } span.loading-indicator-loading { display: inline-block; } span.loading-indicator-loading-medium:after { content: ' '; display: block; width: 15px; height: 15px; border-radius: 50%; border: 3px solid #fff; border-color: #fff transparent #fff transparent; animation-name: loading-indicator-loading; animation-duration: 1.2s; animation-iteration-count: infinite; animation-timing-function: linear; } span.loading-indicator-loading-large:after { content: ' '; display: block; width: 25px; height: 25px; border-radius: 50%; border: 3px solid #fff; border-color: #fff transparent #fff transparent; animation-name: loading-indicator-loading; animation-duration: 1.2s; animation-iteration-count: infinite; animation-timing-function: linear; } span.loading-indicator-loading-small:after { content: ' '; display: block; width: 9px; height: 9px; border-radius: 50%; border: 2px solid #fff; border-color: #fff transparent #fff transparent; animation-name: loading-indicator-loading; animation-duration: 1.2s; animation-iteration-count: infinite; animation-timing-function: linear; } span.loading-indicator-black:after { border-color: #000 transparent #000 transparent; } span.loading-indicator-error { font-weight: bold; color: white; line-height: 0; } span.loading-indicator-error-black { font-weight: bold; color: red; line-height: 0; } div.form-enum-selector { display: inline-block; padding-bottom: 4px; } div.form-enum-selector > div.form-enum-container { padding-top: 5px; } div.form-enum-selector > div.form-enum-container > input { vertical-align: top; margin-top: 4px; } div.form-enum-selector div.form-enum-option { font-weight: bold; display: inline-block; font-size: 15px; font-weight: 600; padding-left: 3px; } div.form-enum-selector span.form-enum-description { display: block; font-family: var(--font-stack); font-weight: normal; font-size: 13px; max-width: 260px; color: gray; } div.color-title { margin-top: 4px; } .hidden { display: none; } .italic { font-style: italic; } -div.edit-thread-color-container { - margin-top: -5px; -} div.form-textarea-container { margin-top: 1px; } -div.edit-thread-privacy-container { - margin-bottom: 6px; -} -div.edit-thread-account-password { - border-top: 2px solid #efefef; - padding-top: 4px; - margin-top: 2px; -} div.new-thread-privacy-container { margin-bottom: 3px; margin-top: -6px; } span.page-loading { margin-top: 5px; margin-right: 12px; float: left; } span.page-error { margin: 15px; font-size: 42px; float: left; color: red; } div.color-picker-container { outline: none; position: relative; } div.color-picker-button { margin: 6px 3px; overflow: hidden; cursor: pointer; padding: 4px; display: inline-block; border: solid 1px darkgray; background: #eee; color: #333; vertical-align: middle; border-radius: 3px; } div.color-picker-preview { width: 25px; height: 16px; border: solid 1px #222; margin-right: 5px; float: left; z-index: 0; } div.color-picker-down-symbol { padding: 1px 0; height: 16px; line-height: 16px; float: left; font-size: 10px; } div.color-picker-selector { position: absolute; left: 4px; top: 34px; } @media only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (min--moz-device-pixel-ratio: 2), only screen and (-o-min-device-pixel-ratio: 2/1), only screen and (min-device-pixel-ratio: 2), only screen and (min-resolution: 320dpi), only screen and (min-resolution: 2dppx) { header.header, header.main-header, div.splash-header-container, div.splash-top-container, div.splash-bottom, div.calendar-filters-container { background: var(--bg); } } @media (hover: none) { div.splash-header-container, div.splash-top-container, div.splash-bottom { background-attachment: initial; } }