diff --git a/web/app.react.js b/web/app.react.js index 39d41d185..e72dc8b03 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,248 +1,241 @@ // @flow import '@fontsource/inter'; import '@fontsource/inter/500.css'; import '@fontsource/inter/600.css'; import '@fontsource/ibm-plex-sans'; import '@fontsource/ibm-plex-sans/500.css'; import '@fontsource/ibm-plex-sans/600.css'; import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { mostRecentReadThreadSelector } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { registerConfig } from 'lib/utils/config'; import Calendar from './calendar/calendar.react'; import Chat from './chat/chat.react'; import InputStateContainer from './input/input-state-container.react'; import LoadingIndicator from './loading-indicator.react'; +import { ModalProvider, useModalContext } from './modals/modal-provider.react'; import DisconnectedBar from './redux/disconnected-bar'; import DisconnectedBarVisibilityHandler from './redux/disconnected-bar-visibility-handler'; import FocusHandler from './redux/focus-handler.react'; import { useSelector } from './redux/redux-utils'; import VisibilityHandler from './redux/visibility-handler.react'; import history from './router-history'; import LeftLayoutAside from './sidebar/left-layout-aside.react'; import Splash from './splash/splash.react'; import './typography.css'; import css from './style.css'; import { type NavInfo, updateNavInfoActionType } from './types/nav-types'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils'; // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, // We use httponly cookies on web to protect against XSS attacks, so we have // no access to the cookies from JavaScript setCookieOnRequest: false, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: 'web' }, }); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: NavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +mostRecentReadThread: ?string, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, -}; -type State = { +modal: ?React.Node, }; -class App extends React.PureComponent { - state: State = { - modal: null, - }; - +class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const newNavInfo = navInfoFromURL(pathname, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } } render() { let content; if (this.props.loggedIn) { content = this.renderMainContent(); } else { - content = ( - - ); + content = ; } return ( {content} - {this.state.modal} + {this.props.modal} ); } renderMainContent() { let mainContent; if (this.props.navInfo.tab === 'calendar') { - mainContent = ( - - ); + mainContent = ; } else if (this.props.navInfo.tab === 'chat') { - mainContent = ; + mainContent = ; } return (

Comm

- +
{mainContent}
- +
); } - - setModal = (modal: ?React.Node) => { - this.setState({ modal }); - }; - - clearModal() { - this.setModal(null); - } } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const mostRecentReadThread = useSelector(mostRecentReadThreadSelector); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const dispatch = useDispatch(); + const modalContext = useModalContext(); return ( ); }, ); -export default ConnectedApp; +function AppWithProvider(props: BaseProps): React.Node { + return ( + + + + ); +} + +export default AppWithProvider; diff --git a/web/calendar/calendar.react.js b/web/calendar/calendar.react.js index 717e013e5..38343dea8 100644 --- a/web/calendar/calendar.react.js +++ b/web/calendar/calendar.react.js @@ -1,290 +1,288 @@ // @flow import { faFilter } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import dateFormat from 'dateformat'; import invariant from 'invariant'; import * as React from 'react'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions'; import { currentDaysToEntries } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { type EntryInfo, type CalendarQuery, type CalendarQueryUpdateResult, type CalendarQueryUpdateStartingPayload, } from 'lib/types/entry-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import { getDate, dateString, startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils'; import { useSelector } from '../redux/redux-utils'; import { yearAssertingSelector, monthAssertingSelector, webCalendarQuery, } from '../selectors/nav-selectors'; import SWMansionIcon from '../SWMansionIcon.react'; import type { NavInfo } from '../types/nav-types'; import { canonicalURLFromReduxState } from '../url-utils'; import css from './calendar.css'; import Day from './day.react'; import FilterPanel from './filter-panel.react'; type BaseProps = { - +setModal: (modal: ?React.Node) => void, +url: string, }; type Props = { ...BaseProps, +year: number, +month: number, +daysToEntries: { +[dayString: string]: EntryInfo[] }, +navInfo: NavInfo, +currentCalendarQuery: () => CalendarQuery, +loggedIn: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, }; type State = { filterPanelOpen: boolean, }; class Calendar extends React.PureComponent { state: State = { filterPanelOpen: false, }; getDate( dayOfMonth: number, monthInput: ?number = undefined, yearInput: ?number = undefined, ) { return getDate( yearInput ? yearInput : this.props.year, monthInput ? monthInput : this.props.month, dayOfMonth, ); } prevMonthDates() { const { year, month } = this.props; const lastMonthDate = getDate(year, month - 1, 1); const prevYear = lastMonthDate.getFullYear(); const prevMonth = lastMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(prevYear, prevMonth), endDate: endDateForYearAndMonth(prevYear, prevMonth), }; } nextMonthDates() { const { year, month } = this.props; const nextMonthDate = getDate(year, month + 1, 1); const nextYear = nextMonthDate.getFullYear(); const nextMonth = nextMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(nextYear, nextMonth), endDate: endDateForYearAndMonth(nextYear, nextMonth), }; } render() { const { year, month } = this.props; const monthName = dateFormat(getDate(year, month, 1), 'mmmm'); const prevURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.prevMonthDates() }, this.props.url, this.props.loggedIn, ); const nextURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.nextMonthDates() }, this.props.url, this.props.loggedIn, ); const lastDayOfMonth = this.getDate(0, this.props.month + 1); const totalDaysInMonth = lastDayOfMonth.getDate(); const firstDayToPrint = 1 - this.getDate(1).getDay(); const lastDayToPrint = totalDaysInMonth + 6 - lastDayOfMonth.getDay(); const rows = []; let columns = []; let week = 1; let tabIndex = 1; for ( let curDayOfMonth = firstDayToPrint; curDayOfMonth <= lastDayToPrint; curDayOfMonth++ ) { if (curDayOfMonth < 1 || curDayOfMonth > totalDaysInMonth) { columns.push(); } else { const dayString = dateString( this.props.year, this.props.month, curDayOfMonth, ); const entries = this.props.daysToEntries[dayString]; invariant( entries, 'the currentDaysToEntries selector should make sure all dayStrings ' + `in the current range have entries, but ${dayString} did not`, ); columns.push( , ); tabIndex += entries.length; } if (columns.length === 7) { rows.push({columns}); columns = []; } } let filterPanel = null; let calendarContentStyle = null; let filterButtonStyle = null; if (this.state.filterPanelOpen) { - filterPanel = ; + filterPanel = ; calendarContentStyle = { marginLeft: '300px' }; filterButtonStyle = { backgroundColor: 'rgba(0,0,0,0.67)' }; } return (
{filterPanel}
Filters
{rows}
Sunday Monday Tuesday Wednesday Thursday Friday Saturday
); } toggleFilters = (event: SyntheticEvent) => { event.preventDefault(); this.setState({ filterPanelOpen: !this.state.filterPanelOpen }); }; onClickPrevURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.prevMonthDates(), }; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(newCalendarQuery, true), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; onClickNextURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.nextMonthDates(), }; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(newCalendarQuery, true), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; } const ConnectedCalendar: React.ComponentType = React.memo( function ConnectedCalendar(props) { const year = useSelector(yearAssertingSelector); const month = useSelector(monthAssertingSelector); const daysToEntries = useSelector(currentDaysToEntries); const navInfo = useSelector(state => state.navInfo); const currentCalendarQuery = useSelector(webCalendarQuery); const loggedIn = useSelector(isLoggedIn); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }, ); export default ConnectedCalendar; diff --git a/web/calendar/day.react.js b/web/calendar/day.react.js index 064b01e03..a5da408fc 100644 --- a/web/calendar/day.react.js +++ b/web/calendar/day.react.js @@ -1,293 +1,282 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _some from 'lodash/fp/some'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions'; import { onScreenThreadInfos as onScreenThreadInfosSelector } from 'lib/selectors/thread-selectors'; import { entryKey } from 'lib/shared/entry-utils'; import type { EntryInfo } from 'lib/types/entry-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { dateString, dateFromString, currentDateInTimeZone, } from 'lib/utils/date-utils'; import LogInFirstModal from '../modals/account/log-in-first-modal.react'; import HistoryModal from '../modals/history/history-modal.react'; +import { useModalContext } from '../modals/modal-provider.react'; import { useSelector } from '../redux/redux-utils'; import { htmlTargetFromEvent } from '../vector-utils'; import { AddVector, HistoryVector } from '../vectors.react'; import css from './calendar.css'; import type { InnerEntry } from './entry.react'; import Entry from './entry.react'; import ThreadPicker from './thread-picker.react'; type BaseProps = { +dayString: string, +entryInfos: $ReadOnlyArray, - +setModal: (modal: ?React.Node) => void, +startingTabIndex: number, }; type Props = { ...BaseProps, +onScreenThreadInfos: $ReadOnlyArray, +viewerID: ?string, +loggedIn: boolean, +nextLocalID: number, +timeZone: ?string, +dispatch: Dispatch, + +setModal: (modal: ?React.Node) => void, }; type State = { +pickerOpen: boolean, +hovered: boolean, }; class Day extends React.PureComponent { state: State = { pickerOpen: false, hovered: false, }; entryContainer: ?HTMLDivElement; entryContainerSpacer: ?HTMLDivElement; actionLinks: ?HTMLDivElement; entries: Map = new Map(); static getDerivedStateFromProps(props: Props) { if (props.onScreenThreadInfos.length === 0) { return { pickerOpen: false }; } return null; } componentDidUpdate(prevProps: Props) { if (this.props.entryInfos.length > prevProps.entryInfos.length) { invariant(this.entryContainer, 'entryContainer ref not set'); this.entryContainer.scrollTop = this.entryContainer.scrollHeight; } } render() { const now = currentDateInTimeZone(this.props.timeZone); const isToday = dateString(now) === this.props.dayString; const tdClasses = classNames(css.day, { [css.currentDay]: isToday }); let actionLinks = null; const hovered = this.state.hovered; if (hovered) { const actionLinksClassName = `${css.actionLinks} ${css.dayActionLinks}`; actionLinks = ( ); } const entries = this.props.entryInfos .filter(entryInfo => _some(['id', entryInfo.threadID])(this.props.onScreenThreadInfos), ) .map((entryInfo, i) => { const key = entryKey(entryInfo); return ( ); }); let threadPicker = null; if (this.state.pickerOpen) { invariant( this.props.onScreenThreadInfos.length > 0, 'onScreenThreadInfos should exist if pickerOpen', ); threadPicker = ( ); } const entryContainerClasses = classNames(css.entryContainer, { [css.focusedEntryContainer]: hovered, }); const date = dateFromString(this.props.dayString); return (

{date.getDate()}

{entries}
{actionLinks} {threadPicker} ); } actionLinksRef = (actionLinks: ?HTMLDivElement) => { this.actionLinks = actionLinks; }; entryContainerRef = (entryContainer: ?HTMLDivElement) => { this.entryContainer = entryContainer; }; entryContainerSpacerRef = (entryContainerSpacer: ?HTMLDivElement) => { this.entryContainerSpacer = entryContainerSpacer; }; entryRef = (key: string, entry: InnerEntry) => { this.entries.set(key, entry); }; closePicker = () => { this.setState({ pickerOpen: false }); }; onMouseEnter = () => { this.setState({ hovered: true }); }; onMouseLeave = () => { this.setState({ hovered: false }); }; onClick = (event: SyntheticEvent) => { const target = htmlTargetFromEvent(event); invariant( this.entryContainer instanceof HTMLDivElement, "entryContainer isn't div", ); invariant( this.entryContainerSpacer instanceof HTMLDivElement, "entryContainerSpacer isn't div", ); if ( target === this.entryContainer || target === this.entryContainerSpacer || (this.actionLinks && target === this.actionLinks) ) { this.onAddEntry(event); } }; onAddEntry = (event: SyntheticEvent<*>) => { event.preventDefault(); invariant( this.props.onScreenThreadInfos.length > 0, "onAddEntry shouldn't be clicked if no onScreenThreadInfos", ); if (this.props.onScreenThreadInfos.length === 1) { this.createNewEntry(this.props.onScreenThreadInfos[0].id); } else if (this.props.onScreenThreadInfos.length > 1) { this.setState({ pickerOpen: true }); } }; createNewEntry = (threadID: string) => { if (!this.props.loggedIn) { - this.props.setModal( - , - ); + this.props.setModal(); return; } const viewerID = this.props.viewerID; invariant(viewerID, 'should have viewerID in order to create thread'); this.props.dispatch({ type: createLocalEntryActionType, payload: createLocalEntry( threadID, this.props.nextLocalID, this.props.dayString, viewerID, ), }); }; onHistory = (event: SyntheticEvent) => { event.preventDefault(); this.props.setModal( - , + , ); }; focusOnFirstEntryNewerThan = (time: number) => { const entryInfo = this.props.entryInfos.find( candidate => candidate.creationTime > time, ); if (entryInfo) { const entry = this.entries.get(entryKey(entryInfo)); invariant(entry, 'entry for entryinfo should be defined'); entry.focus(); } }; - - clearModal = () => { - this.props.setModal(null); - }; } const ConnectedDay: React.ComponentType = React.memo( function ConnectedDay(props) { const onScreenThreadInfos = useSelector(onScreenThreadInfosSelector); const viewerID = useSelector(state => state.currentUserInfo?.id); const loggedIn = useSelector( state => !!(state.currentUserInfo && !state.currentUserInfo.anonymous && true), ); const nextLocalID = useSelector(state => state.nextLocalID); const timeZone = useSelector(state => state.timeZone); const dispatch = useDispatch(); + const modalContext = useModalContext(); return ( ); }, ); export default ConnectedDay; diff --git a/web/calendar/entry.react.js b/web/calendar/entry.react.js index 529f09f96..d369b2283 100644 --- a/web/calendar/entry.react.js +++ b/web/calendar/entry.react.js @@ -1,512 +1,500 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createEntryActionTypes, createEntry, saveEntryActionTypes, saveEntry, deleteEntryActionTypes, deleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { entryKey } from 'lib/shared/entry-utils'; import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils'; import type { Shape } from 'lib/types/core'; import { type EntryInfo, type CreateEntryInfo, type SaveEntryInfo, type SaveEntryResult, type SaveEntryPayload, type CreateEntryPayload, type DeleteEntryInfo, type DeleteEntryResult, type CalendarQuery, } from 'lib/types/entry-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { threadPermissions } from 'lib/types/thread-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { dateString } from 'lib/utils/date-utils'; import { ServerError } from 'lib/utils/errors'; import LoadingIndicator from '../loading-indicator.react'; import LogInFirstModal from '../modals/account/log-in-first-modal.react'; import ConcurrentModificationModal from '../modals/concurrent-modification-modal.react'; import HistoryModal from '../modals/history/history-modal.react'; +import { useModalContext } from '../modals/modal-provider.react'; import { useSelector } from '../redux/redux-utils'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors'; import { HistoryVector, DeleteVector } from '../vectors.react'; import css from './calendar.css'; type BaseProps = { +innerRef: (key: string, me: Entry) => void, +entryInfo: EntryInfo, +focusOnFirstEntryNewerThan: (time: number) => void, - +setModal: (modal: ?React.Node) => void, +tabIndex: number, }; type Props = { ...BaseProps, +threadInfo: ThreadInfo, +loggedIn: boolean, +calendarQuery: () => CalendarQuery, +online: boolean, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, + +setModal: (modal: ?React.Node) => void, + +clearModal: () => void, }; type State = { +focused: boolean, +loadingStatus: LoadingStatus, +text: string, }; class Entry extends React.PureComponent { textarea: ?HTMLTextAreaElement; creating: boolean; needsUpdateAfterCreation: boolean; needsDeleteAfterCreation: boolean; nextSaveAttemptIndex: number; mounted: boolean; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { focused: false, loadingStatus: 'inactive', text: props.entryInfo.text, }; this.creating = false; this.needsUpdateAfterCreation = false; this.needsDeleteAfterCreation = false; this.nextSaveAttemptIndex = 0; } guardedSetState(input: Shape) { if (this.mounted) { this.setState(input); } } componentDidMount() { this.mounted = true; this.props.innerRef(entryKey(this.props.entryInfo), this); this.updateHeight(); // Whenever a new Entry is created, focus on it if (!this.props.entryInfo.id) { this.focus(); } } componentDidUpdate(prevProps: Props) { if ( !this.state.focused && this.props.entryInfo.text !== this.state.text && this.props.entryInfo.text !== prevProps.entryInfo.text ) { this.setState({ text: this.props.entryInfo.text }); this.currentlySaving = null; } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } } focus() { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.focus(); } onMouseDown: (event: SyntheticEvent) => void = event => { if (this.state.focused && event.target !== this.textarea) { // Don't lose focus when some non-textarea part is clicked event.preventDefault(); } }; componentWillUnmount() { this.mounted = false; } updateHeight: () => void = () => { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.style.height = 'auto'; this.textarea.style.height = this.textarea.scrollHeight + 'px'; }; render(): React.Node { let actionLinks = null; if (this.state.focused) { let historyButton = null; if (this.props.entryInfo.id) { historyButton = ( History ); } const rightActionLinksClassName = `${css.rightActionLinks} ${css.actionLinksText}`; actionLinks = (
Delete {historyButton} {this.props.threadInfo.uiName}
); } const darkColor = colorIsDark(this.props.threadInfo.color); const entryClasses = classNames({ [css.entry]: true, [css.darkEntry]: darkColor, [css.focusedEntry]: this.state.focused, }); const style = { backgroundColor: '#' + this.props.threadInfo.color }; const loadingIndicatorColor = darkColor ? 'white' : 'black'; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return (
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/sidebar/community-picker.react.js b/web/sidebar/community-picker.react.js index b911794b1..f9af4792d 100644 --- a/web/sidebar/community-picker.react.js +++ b/web/sidebar/community-picker.react.js @@ -1,30 +1,29 @@ // @flow import * as React from 'react'; import Button from '../components/button.react'; import UserSettingsModal from '../modals/account/user-settings-modal.react.js'; +import { useModalContext } from '../modals/modal-provider.react'; import SWMansionIcon from '../SWMansionIcon.react'; import css from './community-picker.css'; -type Props = { +setModal: (modal: ?React.Node) => void }; - -function CommunityPicker(props: Props): React.Node { - const { setModal } = props; +function CommunityPicker(): React.Node { + const { setModal } = useModalContext(); const setModalToUserSettings = React.useCallback(() => { - setModal(); + setModal(); }, [setModal]); return (
); } export default CommunityPicker; diff --git a/web/sidebar/left-layout-aside.react.js b/web/sidebar/left-layout-aside.react.js index 4f347ddf4..790c7ba9c 100644 --- a/web/sidebar/left-layout-aside.react.js +++ b/web/sidebar/left-layout-aside.react.js @@ -1,22 +1,18 @@ // @flow import * as React from 'react'; import AppSwitcher from './app-switcher.react'; import CommunityPicker from './community-picker.react'; import css from './left-layout-aside.css'; -type Props = { - +setModal: (modal: ?React.Node) => void, -}; -function LeftLayoutAside(props: Props): React.Node { - const { setModal } = props; +function LeftLayoutAside(): React.Node { return ( ); } export default LeftLayoutAside; diff --git a/web/splash/splash.react.js b/web/splash/splash.react.js index 9530390da..548ba6c2d 100644 --- a/web/splash/splash.react.js +++ b/web/splash/splash.react.js @@ -1,280 +1,282 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { requestAccessActionTypes, requestAccess, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { validEmailRegex } from 'lib/shared/account-utils'; import type { AccessRequest } from 'lib/types/account-types'; import { type DeviceType, assertDeviceType } from 'lib/types/device-types'; import { type LoadingStatus } from 'lib/types/loading-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import LoadingIndicator from '../loading-indicator.react'; import LogInModal from '../modals/account/log-in-modal.react'; +import { useModalContext } from '../modals/modal-provider.react'; import { useSelector } from '../redux/redux-utils'; import css from './splash.css'; const defaultRequestAccessScrollHeight = 390; -type BaseProps = { - +setModal: (modal: ?React.Node) => void, - +currentModal: ?React.Node, -}; type Props = { - ...BaseProps, +loadingStatus: LoadingStatus, +dispatchActionPromise: DispatchActionPromise, +requestAccess: (accessRequest: AccessRequest) => Promise, + +setModal: (modal: React.Node) => void, + +modal: ?React.Node, }; type State = { +platform: DeviceType, +email: string, +error: ?string, +success: ?string, }; class Splash extends React.PureComponent { emailInput: ?HTMLInputElement; bottomContainer: ?HTMLDivElement; state: State = { platform: 'ios', email: '', error: null, success: null, }; componentDidMount() { if ('scrollRestoration' in history) { history.scrollRestoration = 'manual'; } } render() { let androidWarning = null; if (this.state.platform === 'android') { androidWarning = (

Make sure this is the email you use to log in to the Google Play Store!

); } let error = null; if (this.state.error) { error =

{this.state.error}

; } let success = null; if (this.state.success) { success = (

{this.state.success}

); } let submitButtonContent = 'Submit'; if (this.props.loadingStatus === 'loading') { submitButtonContent = ( ); } return (

Comm is a chat app with an integrated calendar.

We make it incredibly easy to plan events with your friends.

We're currently alpha testing the first version of our app.

If you'd like to try it out, please let us know!

{androidWarning}
{error} {success}
- {this.props.currentModal} + {this.props.modal} ); } bottomContainerRef = (bottomContainer: ?HTMLDivElement) => { this.bottomContainer = bottomContainer; }; emailInputRef = (emailInput: ?HTMLInputElement) => { this.emailInput = emailInput; }; onChangeEmail = (event: SyntheticEvent) => { this.setState({ email: event.currentTarget.value }); }; onChangePlatform = (event: SyntheticEvent) => { this.setState({ platform: assertDeviceType(event.currentTarget.value) }); }; onClickLogIn = (event: SyntheticEvent) => { event.preventDefault(); - this.props.setModal(); + this.props.setModal(); }; onClickRequestAccess = (event: SyntheticEvent) => { event.preventDefault(); const { bottomContainer } = this; invariant(bottomContainer, 'bottomContainer should exist'); const formHeight = 180; const contentHeight = 790; const guaranteesSpace = contentHeight - window.innerHeight + formHeight; if (bottomContainer.scrollTop < guaranteesSpace) { bottomContainer.scrollTop = Math.max( defaultRequestAccessScrollHeight, guaranteesSpace, ); } if (this.emailInput) { this.emailInput.focus(); } }; onSubmitRequestAccess = (event: SyntheticEvent) => { event.preventDefault(); if (this.state.email.search(validEmailRegex) === -1) { this.setState({ success: null, error: 'Please enter a valid email!' }); invariant(this.emailInput, 'should be set'); this.emailInput.focus(); return; } this.props.dispatchActionPromise( requestAccessActionTypes, this.requestAccessAction(), ); }; async requestAccessAction() { try { await this.props.requestAccess({ email: this.state.email, platform: this.state.platform, }); this.setState({ success: "Thanks for your interest! We'll let you know as soon as " + "we're able to extend an invite.", error: null, }); } catch (e) { this.setState({ success: null, error: 'Unknown error...' }); throw e; } } } const loadingStatusSelector = createLoadingStatusSelector( requestAccessActionTypes, ); -const ConnectedSplash: React.ComponentType = React.memo( - function ConnectedSplash(props) { + +const ConnectedSplash: React.ComponentType<{}> = React.memo<{}>( + function ConnectedSplash(): React.Node { const loadingStatus = useSelector(loadingStatusSelector); const callRequestAccess = useServerCall(requestAccess); const dispatchActionPromise = useDispatchActionPromise(); + const modalContext = useModalContext(); + return ( ); }, ); export default ConnectedSplash;