diff --git a/web/calendar/filter-panel.react.js b/web/calendar/filter-panel.react.js index 47d5cbb86..cd9724e1b 100644 --- a/web/calendar/filter-panel.react.js +++ b/web/calendar/filter-panel.react.js @@ -1,392 +1,391 @@ // @flow import { faCog, faTimesCircle, faChevronUp, faChevronDown, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import Switch from 'react-switch'; import { filteredThreadIDsSelector, includeDeletedSelector, } from 'lib/selectors/calendar-filter-selectors'; import SearchIndex from 'lib/shared/search-index'; import { calendarThreadFilterTypes, type FilterThreadInfo, updateCalendarThreadFilter, clearCalendarThreadFilter, setCalendarDeletedFilter, } from 'lib/types/filter-types'; import type { Dispatch } from 'lib/types/redux-types'; -import type { ThreadInfo } from 'lib/types/thread-types'; import ThreadSettingsModal from '../modals/threads/thread-settings-modal.react'; import { useSelector } from '../redux/redux-utils'; import { webFilterThreadInfos, webFilterThreadSearchIndex, } from '../selectors/calendar-selectors'; import { MagnifyingGlass } from '../vectors.react'; import css from './filter-panel.css'; type BaseProps = {| +setModal: (modal: ?React.Node) => void, |}; type Props = {| ...BaseProps, +filterThreadInfos: () => $ReadOnlyArray, +filterThreadSearchIndex: () => SearchIndex, +filteredThreadIDs: ?Set, +includeDeleted: boolean, +dispatch: Dispatch, |}; type State = {| +query: string, +searchResults: $ReadOnlyArray, +collapsed: boolean, |}; class FilterPanel extends React.PureComponent { state: State = { query: '', searchResults: [], collapsed: false, }; currentlySelected(threadID: string): boolean { if (!this.props.filteredThreadIDs) { return true; } return this.props.filteredThreadIDs.has(threadID); } render() { const filterThreadInfos = this.state.query ? this.state.searchResults : this.props.filterThreadInfos(); let filters = []; if (!this.state.query || filterThreadInfos.length > 0) { filters.push( , ); } else { filters.push(
No results
, ); } if (!this.state.collapsed) { const options = filterThreadInfos.map((filterThreadInfo) => ( )); filters = [...filters, ...options]; } let clearQueryButton = null; if (this.state.query) { clearQueryButton = ( ); } return (
{clearQueryButton}
{filters}
); } onToggle = (threadID: string, value: boolean) => { let newThreadIDs; const selectedThreadIDs = this.props.filteredThreadIDs; if (!selectedThreadIDs && value) { // No thread filter exists and thread is being added return; } else if (!selectedThreadIDs) { // No thread filter exists and thread is being removed newThreadIDs = this.props .filterThreadInfos() .map((filterThreadInfo) => filterThreadInfo.threadInfo.id) .filter((id) => id !== threadID); } else if (selectedThreadIDs.has(threadID) && value) { // Thread filter already includes thread being added return; } else if (selectedThreadIDs.has(threadID)) { // Thread being removed from current thread filter newThreadIDs = [...selectedThreadIDs].filter((id) => id !== threadID); } else if (!value) { // Thread filter doesn't include thread being removed return; } else if ( selectedThreadIDs.size + 1 === this.props.filterThreadInfos().length ) { // Thread filter exists and thread being added is the only one missing newThreadIDs = null; } else { // Thread filter exists and thread is being added newThreadIDs = [...selectedThreadIDs, threadID]; } this.setFilterThreads(newThreadIDs); }; onToggleAll = (value: boolean) => { this.setFilterThreads(value ? null : []); }; onClickOnly = (threadID: string) => { this.setFilterThreads([threadID]); }; setFilterThreads(threadIDs: ?$ReadOnlyArray) { if (!threadIDs) { this.props.dispatch({ type: clearCalendarThreadFilter, }); } else { this.props.dispatch({ type: updateCalendarThreadFilter, payload: { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs, }, }); } } - onClickSettings = (threadInfo: ThreadInfo) => { + onClickSettings = (threadID: string) => { this.props.setModal( - , + , ); }; onChangeQuery = (event: SyntheticEvent) => { const query = event.currentTarget.value; const searchIndex = this.props.filterThreadSearchIndex(); const resultIDs = new Set(searchIndex.getSearchResults(query)); const results = this.props .filterThreadInfos() .filter((filterThreadInfo) => resultIDs.has(filterThreadInfo.threadInfo.id), ); this.setState({ query, searchResults: results, collapsed: false }); }; clearQuery = (event: SyntheticEvent) => { event.preventDefault(); this.setState({ query: '', searchResults: [], collapsed: false }); }; onCollapse = (value: boolean) => { this.setState({ collapsed: value }); }; onChangeIncludeDeleted = (includeDeleted: boolean) => { this.props.dispatch({ type: setCalendarDeletedFilter, payload: { includeDeleted, }, }); }; clearModal = () => { this.props.setModal(null); }; } type ItemProps = {| +filterThreadInfo: FilterThreadInfo, +onToggle: (threadID: string, value: boolean) => void, +onClickOnly: (threadID: string) => void, - +onClickSettings: (threadInfo: ThreadInfo) => void, + +onClickSettings: (threadID: string) => void, +selected: boolean, |}; class Item extends React.PureComponent { render() { const threadInfo = this.props.filterThreadInfo.threadInfo; const beforeCheckStyles = { borderColor: `#${threadInfo.color}` }; let afterCheck = null; if (this.props.selected) { const afterCheckStyles = { backgroundColor: `#${threadInfo.color}` }; afterCheck = (
); } const details = this.props.filterThreadInfo.numVisibleEntries === 1 ? '1 entry' : `${this.props.filterThreadInfo.numVisibleEntries} entries`; return (
); } onChange = (event: SyntheticEvent) => { this.props.onToggle( this.props.filterThreadInfo.threadInfo.id, event.currentTarget.checked, ); }; onClickOnly = (event: SyntheticEvent) => { event.preventDefault(); this.props.onClickOnly(this.props.filterThreadInfo.threadInfo.id); }; onClickSettings = (event: SyntheticEvent) => { event.preventDefault(); - this.props.onClickSettings(this.props.filterThreadInfo.threadInfo); + this.props.onClickSettings(this.props.filterThreadInfo.threadInfo.id); }; } type CategoryProps = {| +numThreads: number, +onToggle: (value: boolean) => void, +collapsed: boolean, +onCollapse: (value: boolean) => void, +selected: boolean, |}; class Category extends React.PureComponent { render() { const beforeCheckStyles = { borderColor: 'white' }; let afterCheck = null; if (this.props.selected) { const afterCheckStyles = { backgroundColor: 'white' }; afterCheck = (
); } const icon = this.props.collapsed ? faChevronUp : faChevronDown; const details = this.props.numThreads === 1 ? '1 thread' : `${this.props.numThreads} threads`; return (
); } onChange = (event: SyntheticEvent) => { this.props.onToggle(event.currentTarget.checked); }; onCollapse = (event: SyntheticEvent) => { event.preventDefault(); this.props.onCollapse(!this.props.collapsed); }; } export default React.memo(function ConnectedFilterPanel( props: BaseProps, ) { const filteredThreadIDs = useSelector(filteredThreadIDsSelector); const filterThreadInfos = useSelector(webFilterThreadInfos); const filterThreadSearchIndex = useSelector(webFilterThreadSearchIndex); const includeDeleted = useSelector(includeDeletedSelector); const dispatch = useDispatch(); return ( ); }); diff --git a/web/modals/threads/color-picker.react.js b/web/modals/threads/color-picker.react.js index 276c16086..0fe9fef4f 100644 --- a/web/modals/threads/color-picker.react.js +++ b/web/modals/threads/color-picker.react.js @@ -1,91 +1,84 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import { ChromePicker } from 'react-color'; import css from '../../style.css'; -type Props = { - id: string, - value: string, - disabled: boolean, - onChange: (hex: string) => void, -}; -type State = { - pickerOpen: boolean, -}; +type Props = {| + +id: string, + +value: string, + +disabled: boolean, + +onChange: (hex: string) => void, +|}; +type State = {| + +pickerOpen: boolean, +|}; type Color = { - hex: string, + +hex: string, + ... }; class ColorPicker extends React.PureComponent { props: Props; state: State; constructor(props: Props) { super(props); this.state = { pickerOpen: false, }; } render() { let picker = null; - if (this.state.pickerOpen) { + if (this.state.pickerOpen && !this.props.disabled) { picker = (
); } const style = { backgroundColor: `#${this.props.value}` }; return (
{picker}
); } onPickerKeyDown = (event: SyntheticKeyboardEvent) => { if (event.keyCode === 27) { // Esc this.setState({ pickerOpen: false }); } }; onChangeColor = (color: Color) => { this.props.onChange(color.hex.substring(1, 7)); }; onClick = () => { this.setState({ pickerOpen: true }); }; onBlur = () => { this.setState({ pickerOpen: false }); }; } -ColorPicker.propTypes = { - id: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - disabled: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, -}; - export default ColorPicker; diff --git a/web/modals/threads/thread-settings-modal.react.js b/web/modals/threads/thread-settings-modal.react.js index 11e4dd266..c5a0f8198 100644 --- a/web/modals/threads/thread-settings-modal.react.js +++ b/web/modals/threads/thread-settings-modal.react.js @@ -1,537 +1,570 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _pickBy from 'lodash/fp/pickBy'; -import PropTypes from 'prop-types'; 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, - threadInfoPropType, threadTypes, assertThreadType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, type LeaveThreadPayload, threadPermissions, type ThreadChanges, } from 'lib/types/thread-types'; -import { type UserInfos, userInfoPropType } from 'lib/types/user-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 { useSelector } from '../../redux/redux-utils'; import css from '../../style.css'; import Modal from '../modal.react'; import ColorPicker from './color-picker.react'; type TabType = 'general' | 'privacy' | 'delete'; -type TabProps = { - name: string, - tabType: TabType, - selected: boolean, - onClick: (tabType: TabType) => void, -}; +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 = {| - +threadInfo: ThreadInfo, + +threadID: string, +onClose: () => void, |}; type Props = {| ...BaseProps, - +inputDisabled: boolean, + +threadInfo: ThreadInfo, + +changeInProgress: boolean, +viewerID: ?string, +userInfos: UserInfos, +dispatchActionPromise: DispatchActionPromise, +deleteThread: ( threadID: string, currentAccountPassword: string, ) => Promise, +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, |}; type State = {| - queuedChanges: ThreadChanges, - errorMessage: string, - accountPassword: string, - currentTabType: TabType, + +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); + } 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 inputDisabled = + this.props.changeInProgress || + !this.hasPermissionForTab( + this.props.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 = [ , ]; if (this.possiblyChangedValue('parentThreadID')) { tabs.push( , ); } - const canDeleteThread = threadHasPermission( + const canDeleteThread = this.hasPermissionForTab( this.props.threadInfo, - threadPermissions.DELETE_THREAD, + '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; } } } -ThreadSettingsModal.propTypes = { - threadInfo: threadInfoPropType.isRequired, - onClose: PropTypes.func.isRequired, - inputDisabled: PropTypes.bool.isRequired, - viewerID: PropTypes.string, - userInfos: PropTypes.objectOf(userInfoPropType).isRequired, - dispatchActionPromise: PropTypes.func.isRequired, - deleteThread: PropTypes.func.isRequired, - changeThreadSettings: PropTypes.func.isRequired, -}; - const deleteThreadLoadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); export default React.memo(function ConnectedThreadSettingsModal( props: BaseProps, ) { - const inputDisabled = useSelector( + 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 = useSelector( + (state) => threadInfoSelector(state)[props.threadID], + ); return ( ); });