diff --git a/lib/components/modal-overlay.react.js b/lib/components/modal-overlay.react.js
index 78be45769..4344d8730 100644
--- a/lib/components/modal-overlay.react.js
+++ b/lib/components/modal-overlay.react.js
@@ -1,63 +1,63 @@
// @flow
import * as React from 'react';
import css from './modal-overlay.css';
type ModalOverlayProps = {
+onClose: () => void,
+children?: React.Node,
};
function ModalOverlay(props: ModalOverlayProps): React.Node {
const { children, onClose } = props;
const overlayRef = React.useRef();
const firstClickRef = React.useRef(null);
React.useLayoutEffect(() => {
if (overlayRef.current) {
overlayRef.current.focus();
}
}, []);
const onBackgroundMouseDown = React.useCallback(event => {
firstClickRef.current = event.target;
}, []);
const onBackgroundMouseUp = React.useCallback(
event => {
if (
event.target === overlayRef.current &&
firstClickRef.current === overlayRef.current
) {
onClose();
}
},
[onClose],
);
const onKeyDown = React.useCallback(
event => {
- if (event.keyCode === 27) {
+ if (event.key === 'Escape') {
onClose();
}
},
[onClose],
);
return (
{children}
);
}
export default ModalOverlay;
diff --git a/web/calendar/entry.react.js b/web/calendar/entry.react.js
index ee44b4204..677bd22a2 100644
--- a/web/calendar/entry.react.js
+++ b/web/calendar/entry.react.js
@@ -1,500 +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 { useModalContext } from 'lib/components/modal-provider.react';
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 { 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,
+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,
+pushModal: (modal: React.Node) => void,
+popModal: () => 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 (
{actionLinks}
);
}
textareaRef: (textarea: ?HTMLTextAreaElement) => void = textarea => {
this.textarea = textarea;
};
onFocus: () => void = () => {
if (!this.state.focused) {
this.setState({ focused: true });
}
};
onBlur: () => void = () => {
this.setState({ focused: false });
if (this.state.text.trim() === '') {
this.delete();
} else if (this.props.entryInfo.text !== this.state.text) {
this.save();
}
};
delete() {
this.dispatchDelete(this.props.entryInfo.id, false);
}
save() {
this.dispatchSave(this.props.entryInfo.id, this.state.text);
}
onChange: (event: SyntheticEvent) => void = event => {
if (!this.props.loggedIn) {
this.props.pushModal( );
return;
}
const target = event.target;
invariant(target instanceof HTMLTextAreaElement, 'target not textarea');
this.setState({ text: target.value }, this.updateHeight);
};
onKeyDown: (
event: SyntheticKeyboardEvent,
) => void = event => {
- if (event.keyCode === 27) {
+ if (event.key === 'Escape') {
invariant(
this.textarea instanceof HTMLTextAreaElement,
'textarea ref not set',
);
this.textarea.blur();
}
};
dispatchSave(serverID: ?string, newText: string) {
if (this.currentlySaving === newText) {
return;
}
this.currentlySaving = newText;
if (newText.trim() === '') {
// We don't save the empty string, since as soon as the element loses
// focus it'll get deleted
return;
}
if (!serverID) {
if (this.creating) {
// We need the first save call to return so we know the ID of the entry
// we're updating, so we'll need to handle this save later
this.needsUpdateAfterCreation = true;
return;
} else {
this.creating = true;
}
}
if (!serverID) {
this.props.dispatchActionPromise(
createEntryActionTypes,
this.createAction(newText),
);
} else {
this.props.dispatchActionPromise(
saveEntryActionTypes,
this.saveAction(serverID, newText),
);
}
}
async createAction(text: string): Promise {
const localID = this.props.entryInfo.localID;
invariant(localID, "if there's no serverID, there should be a localID");
const curSaveAttempt = this.nextSaveAttemptIndex++;
this.guardedSetState({ loadingStatus: 'loading' });
try {
const response = await this.props.createEntry({
text,
timestamp: this.props.entryInfo.creationTime,
date: dateString(
this.props.entryInfo.year,
this.props.entryInfo.month,
this.props.entryInfo.day,
),
threadID: this.props.entryInfo.threadID,
localID,
calendarQuery: this.props.calendarQuery(),
});
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'inactive' });
}
this.creating = false;
if (this.needsUpdateAfterCreation) {
this.needsUpdateAfterCreation = false;
this.dispatchSave(response.entryID, this.state.text);
}
if (this.needsDeleteAfterCreation) {
this.needsDeleteAfterCreation = false;
this.dispatchDelete(response.entryID, false);
}
return response;
} catch (e) {
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'error' });
}
this.currentlySaving = null;
this.creating = false;
throw e;
}
}
async saveAction(
entryID: string,
newText: string,
): Promise {
const curSaveAttempt = this.nextSaveAttemptIndex++;
this.guardedSetState({ loadingStatus: 'loading' });
try {
const response = await this.props.saveEntry({
entryID,
text: newText,
prevText: this.props.entryInfo.text,
timestamp: Date.now(),
calendarQuery: this.props.calendarQuery(),
});
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'inactive' });
}
return { ...response, threadID: this.props.entryInfo.threadID };
} catch (e) {
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'error' });
}
this.currentlySaving = null;
if (e instanceof ServerError && e.message === 'concurrent_modification') {
const onRefresh = () => {
this.setState({ loadingStatus: 'inactive' }, this.updateHeight);
this.props.dispatch({
type: concurrentModificationResetActionType,
payload: { id: entryID, dbText: e.payload.db },
});
this.props.popModal();
};
this.props.pushModal(
,
);
}
throw e;
}
}
onDelete: (event: SyntheticEvent) => void = event => {
event.preventDefault();
if (!this.props.loggedIn) {
this.props.pushModal( );
return;
}
this.dispatchDelete(this.props.entryInfo.id, true);
};
dispatchDelete(serverID: ?string, focusOnNextEntry: boolean) {
const { localID } = this.props.entryInfo;
this.props.dispatchActionPromise(
deleteEntryActionTypes,
this.deleteAction(serverID, focusOnNextEntry),
undefined,
{ localID, serverID },
);
}
async deleteAction(
serverID: ?string,
focusOnNextEntry: boolean,
): Promise {
invariant(
this.props.loggedIn,
'user should be logged in if delete triggered',
);
if (focusOnNextEntry) {
this.props.focusOnFirstEntryNewerThan(this.props.entryInfo.creationTime);
}
if (serverID) {
return await this.props.deleteEntry({
entryID: serverID,
prevText: this.props.entryInfo.text,
calendarQuery: this.props.calendarQuery(),
});
} else if (this.creating) {
this.needsDeleteAfterCreation = true;
}
return null;
}
onHistory: (event: SyntheticEvent) => void = event => {
event.preventDefault();
this.props.pushModal(
,
);
};
}
export type InnerEntry = Entry;
const ConnectedEntry: React.ComponentType = React.memo(
function ConnectedEntry(props) {
const { threadID } = props.entryInfo;
const threadInfo = useSelector(
state => threadInfoSelector(state)[threadID],
);
const loggedIn = useSelector(
state =>
!!(state.currentUserInfo && !state.currentUserInfo.anonymous && true),
);
const calendarQuery = useSelector(nonThreadCalendarQuery);
const online = useSelector(
state => state.connection.status === 'connected',
);
const callCreateEntry = useServerCall(createEntry);
const callSaveEntry = useServerCall(saveEntry);
const callDeleteEntry = useServerCall(deleteEntry);
const dispatchActionPromise = useDispatchActionPromise();
const dispatch = useDispatch();
const modalContext = useModalContext();
return (
);
},
);
export default ConnectedEntry;
diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js
index 0a3d1b981..f81842eae 100644
--- a/web/chat/chat-input-bar.react.js
+++ b/web/chat/chat-input-bar.react.js
@@ -1,578 +1,578 @@
// @flow
import invariant from 'invariant';
import _difference from 'lodash/fp/difference';
import * as React from 'react';
import {
joinThreadActionTypes,
joinThread,
newThreadActionTypes,
} from 'lib/actions/thread-actions';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors';
import {
userStoreSearchIndex,
relativeMemberInfoSelectorForMembersOfThread,
} from 'lib/selectors/user-selectors';
import { localIDPrefix, trimMessage } from 'lib/shared/message-utils';
import SearchIndex from 'lib/shared/search-index';
import {
threadHasPermission,
viewerIsMember,
threadFrozenDueToViewerBlock,
threadActualMembers,
checkIfDefaultMembersAreVoiced,
} from 'lib/shared/thread-utils';
import type { CalendarQuery } from 'lib/types/entry-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import { messageTypes } from 'lib/types/message-types';
import {
type ThreadInfo,
threadPermissions,
type ClientThreadJoinRequest,
type ThreadJoinPayload,
} from 'lib/types/thread-types';
import type { RelativeMemberInfo } from 'lib/types/thread-types';
import { type UserInfos } from 'lib/types/user-types';
import {
type DispatchActionPromise,
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils';
import Button from '../components/button.react';
import {
type InputState,
type PendingMultimediaUpload,
} from '../input/input-state';
import LoadingIndicator from '../loading-indicator.react';
import { allowedMimeTypeString } from '../media/file-utils';
import Multimedia from '../media/multimedia.react';
import { useSelector } from '../redux/redux-utils';
import { nonThreadCalendarQuery } from '../selectors/nav-selectors';
import SWMansionIcon from '../SWMansionIcon.react';
import css from './chat-input-bar.css';
import MentionSuggestionTooltip from './mention-suggestion-tooltip.react';
import { mentionRegex } from './mention-utils';
type BaseProps = {
+threadInfo: ThreadInfo,
+inputState: InputState,
};
type Props = {
...BaseProps,
// Redux state
+viewerID: ?string,
+joinThreadLoadingStatus: LoadingStatus,
+threadCreationInProgress: boolean,
+calendarQuery: () => CalendarQuery,
+nextLocalID: number,
+isThreadActive: boolean,
+userInfos: UserInfos,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+joinThread: (request: ClientThreadJoinRequest) => Promise,
+userSearchIndex: SearchIndex,
+threadMembers: $ReadOnlyArray,
+typeaheadMatchedStrings: ?TypeaheadMatchedStrings,
};
export type TypeaheadMatchedStrings = {
+entireText: string,
+textBeforeAtSymbol: string,
+usernamePrefix: string,
};
class ChatInputBar extends React.PureComponent {
textarea: ?HTMLTextAreaElement;
multimediaInput: ?HTMLInputElement;
componentDidMount() {
this.updateHeight();
if (this.props.isThreadActive) {
this.addReplyListener();
}
}
componentWillUnmount() {
if (this.props.isThreadActive) {
this.removeReplyListener();
}
}
componentDidUpdate(prevProps: Props) {
if (this.props.isThreadActive && !prevProps.isThreadActive) {
this.addReplyListener();
} else if (!this.props.isThreadActive && prevProps.isThreadActive) {
this.removeReplyListener();
}
const { inputState } = this.props;
const prevInputState = prevProps.inputState;
if (inputState.draft !== prevInputState.draft) {
this.updateHeight();
}
const curUploadIDs = ChatInputBar.unassignedUploadIDs(
inputState.pendingUploads,
);
const prevUploadIDs = ChatInputBar.unassignedUploadIDs(
prevInputState.pendingUploads,
);
if (
this.multimediaInput &&
_difference(prevUploadIDs)(curUploadIDs).length > 0
) {
// Whenever a pending upload is removed, we reset the file
// HTMLInputElement's value field, so that if the same upload occurs again
// the onChange call doesn't get filtered
this.multimediaInput.value = '';
} else if (
this.textarea &&
_difference(curUploadIDs)(prevUploadIDs).length > 0
) {
// Whenever a pending upload is added, we focus the textarea
this.textarea.focus();
return;
}
if (
(this.props.threadInfo.id !== prevProps.threadInfo.id ||
(inputState.textCursorPosition !== prevInputState.textCursorPosition &&
this.textarea?.selectionStart === this.textarea?.selectionEnd)) &&
this.textarea
) {
this.textarea.focus();
this.textarea?.setSelectionRange(
inputState.textCursorPosition,
inputState.textCursorPosition,
'none',
);
}
}
static unassignedUploadIDs(
pendingUploads: $ReadOnlyArray,
) {
return pendingUploads
.filter(
(pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID,
)
.map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID);
}
updateHeight() {
const textarea = this.textarea;
if (textarea) {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 150);
textarea.style.height = `${newHeight}px`;
}
}
addReplyListener() {
invariant(
this.props.inputState,
'inputState should be set in addReplyListener',
);
this.props.inputState.addReplyListener(this.focusAndUpdateText);
}
removeReplyListener() {
invariant(
this.props.inputState,
'inputState should be set in removeReplyListener',
);
this.props.inputState.removeReplyListener(this.focusAndUpdateText);
}
render() {
const isMember = viewerIsMember(this.props.threadInfo);
const canJoin = threadHasPermission(
this.props.threadInfo,
threadPermissions.JOIN_THREAD,
);
let joinButton = null;
if (!isMember && canJoin && !this.props.threadCreationInProgress) {
let buttonContent;
if (this.props.joinThreadLoadingStatus === 'loading') {
buttonContent = (
);
} else {
buttonContent = (
<>
Join Chat
>
);
}
joinButton = (
{buttonContent}
);
}
const { pendingUploads, cancelPendingUpload } = this.props.inputState;
const multimediaPreviews = pendingUploads.map(pendingUpload => (
));
const previews =
multimediaPreviews.length > 0 ? (
{multimediaPreviews}
) : null;
let content;
// If the thread is created by somebody else while the viewer is attempting
// to create it, the threadInfo might be modified in-place and won't
// list the viewer as a member, which will end up hiding the input. In
// this case, we will assume that our creation action will get translated,
// into a join and as long as members are voiced, we can show the input.
const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced(
this.props.threadInfo,
);
let sendButton;
if (this.props.inputState.draft.length) {
sendButton = (
);
}
if (
threadHasPermission(this.props.threadInfo, threadPermissions.VOICED) ||
(this.props.threadCreationInProgress && defaultMembersAreVoiced)
) {
content = (
);
} else if (
threadFrozenDueToViewerBlock(
this.props.threadInfo,
this.props.viewerID,
this.props.userInfos,
) &&
threadActualMembers(this.props.threadInfo.members).length === 2
) {
content = (
You can't send messages to a user that you've blocked.
);
} else if (isMember) {
content = (
You don't have permission to send messages.
);
} else if (defaultMembersAreVoiced && canJoin) {
content = null;
} else {
content = (
You don't have permission to send messages.
);
}
let typeaheadTooltip;
if (this.props.typeaheadMatchedStrings && this.textarea) {
typeaheadTooltip = (
);
}
return (
{joinButton}
{previews}
{content}
{typeaheadTooltip}
);
}
textareaRef = (textarea: ?HTMLTextAreaElement) => {
this.textarea = textarea;
if (textarea) {
textarea.focus();
}
};
onChangeMessageText = (event: SyntheticEvent) => {
this.props.inputState.setDraft(event.currentTarget.value);
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
onClickTextarea = (event: SyntheticEvent) => {
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
onSelectTextarea = (event: SyntheticEvent) => {
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
focusAndUpdateText = (text: string) => {
// We need to call focus() first on Safari, otherwise the cursor
// ends up at the start instead of the end for some reason
const { textarea } = this;
invariant(textarea, 'textarea should be set');
textarea.focus();
// We reset the textarea to an empty string at the start so that the cursor
// always ends up at the end, even if the text doesn't actually change
textarea.value = '';
const currentText = this.props.inputState.draft;
if (!currentText.startsWith(text)) {
const prependedText = text.concat(currentText);
this.props.inputState.setDraft(prependedText);
textarea.value = prependedText;
} else {
textarea.value = currentText;
}
// The above strategies make sure the cursor is at the end,
// but we also need to make sure that we're scrolled to the bottom
textarea.scrollTop = textarea.scrollHeight;
};
onKeyDown = (event: SyntheticKeyboardEvent) => {
- if (event.keyCode === 13 && !event.shiftKey) {
+ if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.send();
}
};
onSend = (event: SyntheticEvent) => {
event.preventDefault();
this.send();
};
send() {
let { nextLocalID } = this.props;
const text = trimMessage(this.props.inputState.draft);
if (text) {
this.dispatchTextMessageAction(text, nextLocalID);
nextLocalID++;
}
if (this.props.inputState.pendingUploads.length > 0) {
this.props.inputState.createMultimediaMessage(
nextLocalID,
this.props.threadInfo,
);
}
}
dispatchTextMessageAction(text: string, nextLocalID: number) {
this.props.inputState.setDraft('');
const localID = `${localIDPrefix}${nextLocalID}`;
const creatorID = this.props.viewerID;
invariant(creatorID, 'should have viewer ID in order to send a message');
this.props.inputState.sendTextMessage(
{
type: messageTypes.TEXT,
localID,
threadID: this.props.threadInfo.id,
text,
creatorID,
time: Date.now(),
},
this.props.threadInfo,
);
}
multimediaInputRef = (multimediaInput: ?HTMLInputElement) => {
this.multimediaInput = multimediaInput;
};
onMultimediaClick = () => {
if (this.multimediaInput) {
this.multimediaInput.click();
}
};
onMultimediaFileChange = async (
event: SyntheticInputEvent,
) => {
const result = await this.props.inputState.appendFiles([
...event.target.files,
]);
if (!result && this.multimediaInput) {
this.multimediaInput.value = '';
}
};
onClickJoin = () => {
this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction());
};
async joinAction() {
const query = this.props.calendarQuery();
return await this.props.joinThread({
threadID: this.props.threadInfo.id,
calendarQuery: {
startDate: query.startDate,
endDate: query.endDate,
filters: [
...query.filters,
{ type: 'threads', threadIDs: [this.props.threadInfo.id] },
],
},
});
}
}
const joinThreadLoadingStatusSelector = createLoadingStatusSelector(
joinThreadActionTypes,
);
const createThreadLoadingStatusSelector = createLoadingStatusSelector(
newThreadActionTypes,
);
const ConnectedChatInputBar: React.ComponentType = React.memo(
function ConnectedChatInputBar(props) {
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const nextLocalID = useSelector(state => state.nextLocalID);
const isThreadActive = useSelector(
state => props.threadInfo.id === state.navInfo.activeChatThreadID,
);
const userInfos = useSelector(state => state.userStore.userInfos);
const joinThreadLoadingStatus = useSelector(
joinThreadLoadingStatusSelector,
);
const createThreadLoadingStatus = useSelector(
createThreadLoadingStatusSelector,
);
const threadCreationInProgress = createThreadLoadingStatus === 'loading';
const calendarQuery = useSelector(nonThreadCalendarQuery);
const dispatchActionPromise = useDispatchActionPromise();
const callJoinThread = useServerCall(joinThread);
const userSearchIndex = useSelector(userStoreSearchIndex);
const threadMembers = useSelector(
relativeMemberInfoSelectorForMembersOfThread(props.threadInfo.id),
);
const inputSliceEndingAtCursor = React.useMemo(
() =>
props.inputState.draft.slice(0, props.inputState.textCursorPosition),
[props.inputState.draft, props.inputState.textCursorPosition],
);
// we only try to match if there is end of text or whitespace after cursor
const typeaheadRegexMatches = React.useMemo(
() =>
inputSliceEndingAtCursor.length === props.inputState.draft.length ||
/\s/.test(props.inputState.draft[props.inputState.textCursorPosition])
? inputSliceEndingAtCursor.match(mentionRegex)
: null,
[
inputSliceEndingAtCursor,
props.inputState.textCursorPosition,
props.inputState.draft,
],
);
const typeaheadMatchedStrings: ?TypeaheadMatchedStrings = React.useMemo(
() =>
typeaheadRegexMatches !== null
? {
entireText: typeaheadRegexMatches[0],
textBeforeAtSymbol: typeaheadRegexMatches[1],
usernamePrefix: typeaheadRegexMatches[2],
}
: null,
[typeaheadRegexMatches],
);
return (
);
},
);
export default ConnectedChatInputBar;
diff --git a/web/media/multimedia-modal.react.js b/web/media/multimedia-modal.react.js
index b7b8cf738..6e0e9aa70 100644
--- a/web/media/multimedia-modal.react.js
+++ b/web/media/multimedia-modal.react.js
@@ -1,73 +1,73 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { XCircle as XCircleIcon } from 'react-feather';
import { useModalContext } from 'lib/components/modal-provider.react';
import css from './media.css';
type BaseProps = {
+uri: string,
};
type Props = {
...BaseProps,
+popModal: (modal: ?React.Node) => void,
};
class MultimediaModal extends React.PureComponent {
overlay: ?HTMLDivElement;
componentDidMount() {
invariant(this.overlay, 'overlay ref unset');
this.overlay.focus();
}
render(): React.Node {
return (
);
}
overlayRef: (overlay: ?HTMLDivElement) => void = overlay => {
this.overlay = overlay;
};
onBackgroundClick: (
event: SyntheticEvent,
) => void = event => {
if (event.target === this.overlay) {
this.props.popModal();
}
};
onKeyDown: (
event: SyntheticKeyboardEvent,
) => void = event => {
- if (event.keyCode === 27) {
+ if (event.key === 'Escape') {
this.props.popModal();
}
};
}
function ConnectedMultiMediaModal(props: BaseProps): React.Node {
const modalContext = useModalContext();
return ;
}
export default ConnectedMultiMediaModal;
diff --git a/web/modals/threads/color-picker.react.js b/web/modals/threads/color-picker.react.js
index c15b63253..8cd23e471 100644
--- a/web/modals/threads/color-picker.react.js
+++ b/web/modals/threads/color-picker.react.js
@@ -1,79 +1,78 @@
// @flow
import * as React from 'react';
import { type ColorResult, 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,
};
class ColorPicker extends React.PureComponent {
constructor(props: Props) {
super(props);
this.state = {
pickerOpen: false,
};
}
render(): React.Node {
let picker = null;
if (this.state.pickerOpen && !this.props.disabled) {
picker = (
);
}
const style = { backgroundColor: `#${this.props.value}` };
return (
);
}
onPickerKeyDown: (
event: SyntheticKeyboardEvent,
) => void = event => {
- if (event.keyCode === 27) {
- // Esc
+ if (event.key === 'Escape') {
this.setState({ pickerOpen: false });
}
};
onChangeColor: (color: ColorResult) => void = color => {
this.props.onChange(color.hex.substring(1, 7));
};
onClick: () => void = () => {
this.setState({ pickerOpen: true });
};
onBlur: () => void = () => {
this.setState({ pickerOpen: false });
};
}
export default ColorPicker;