Page MenuHomePhorge

chat-input-bar.react.js
No OneTemporary

Size
20 KB
Referenced Files
None
Subscribers
None

chat-input-bar.react.js

// @flow
import invariant from 'invariant';
import _difference from 'lodash/fp/difference.js';
import * as React from 'react';
import {
joinThreadActionTypes,
newThreadActionTypes,
useJoinThread,
} from 'lib/actions/thread-actions.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import {
useChatMentionContext,
useThreadChatMentionCandidates,
} from 'lib/hooks/chat-mention-hooks.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import {
getTypeaheadRegexMatches,
type MentionTypeaheadSuggestionItem,
type TypeaheadMatchedStrings,
useMentionTypeaheadChatSuggestions,
useMentionTypeaheadUserSuggestions,
useUserMentionsCandidates,
} from 'lib/shared/mention-utils.js';
import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js';
import {
checkIfDefaultMembersAreVoiced,
threadActualMembers,
threadFrozenDueToViewerBlock,
threadHasPermission,
viewerIsMember,
} from 'lib/shared/thread-utils.js';
import type { CalendarQuery } from 'lib/types/entry-types.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import {
type ClientThreadJoinRequest,
type ThreadJoinPayload,
} from 'lib/types/thread-types.js';
import { type UserInfos } from 'lib/types/user-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import css from './chat-input-bar.css';
import TypeaheadTooltip from './typeahead-tooltip.react.js';
import Button from '../components/button.react.js';
import {
type InputState,
type PendingMultimediaUpload,
} from '../input/input-state.js';
import LoadingIndicator from '../loading-indicator.react.js';
import { allowedMimeTypeString } from '../media/file-utils.js';
import Multimedia from '../media/multimedia.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js';
import {
getMentionTypeaheadTooltipActions,
getMentionTypeaheadTooltipButtons,
webMentionTypeaheadRegex,
} from '../utils/typeahead-utils.js';
type BaseProps = {
+threadInfo: ThreadInfo,
+inputState: InputState,
};
type Props = {
...BaseProps,
+viewerID: ?string,
+joinThreadLoadingStatus: LoadingStatus,
+threadCreationInProgress: boolean,
+calendarQuery: () => CalendarQuery,
+nextLocalID: number,
+isThreadActive: boolean,
+userInfos: UserInfos,
+dispatchActionPromise: DispatchActionPromise,
+joinThread: (request: ClientThreadJoinRequest) => Promise<ThreadJoinPayload>,
+typeaheadMatchedStrings: ?TypeaheadMatchedStrings,
+suggestions: $ReadOnlyArray<MentionTypeaheadSuggestionItem>,
+parentThreadInfo: ?ThreadInfo,
};
class ChatInputBar extends React.PureComponent<Props> {
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();
}
if (
inputState.draft !== prevInputState.draft ||
inputState.textCursorPosition !== prevInputState.textCursorPosition
) {
inputState.setTypeaheadState({
canBeVisible: true,
});
}
const curUploadIDs = ChatInputBar.unassignedUploadIDs(
inputState.pendingUploads,
);
const prevUploadIDs = ChatInputBar.unassignedUploadIDs(
prevInputState.pendingUploads,
);
const { multimediaInput, textarea } = this;
if (
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
multimediaInput.value = '';
} else if (
textarea &&
_difference(curUploadIDs)(prevUploadIDs).length > 0
) {
// Whenever a pending upload is added, we focus the textarea
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<PendingMultimediaUpload>,
): Array<string> {
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(): React.Node {
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 = (
<LoadingIndicator
status={this.props.joinThreadLoadingStatus}
size="medium"
color="white"
/>
);
} else {
buttonContent = (
<>
<SWMansionIcon icon="plus" size={24} />
<p className={css.joinButtonText}>Join Chat</p>
</>
);
}
joinButton = (
<div className={css.joinButtonContainer}>
<Button
variant="filled"
buttonColor={{ backgroundColor: `#${this.props.threadInfo.color}` }}
onClick={this.onClickJoin}
>
{buttonContent}
</Button>
</div>
);
}
const { pendingUploads, cancelPendingUpload } = this.props.inputState;
const multimediaPreviews = pendingUploads.map(pendingUpload => {
const { uri, mediaType, thumbHash, dimensions } = pendingUpload;
let mediaSource = { thumbHash, dimensions };
if (mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video') {
mediaSource = {
...mediaSource,
type: mediaType,
uri,
thumbnailURI: null,
};
} else {
const { encryptionKey } = pendingUpload;
invariant(
encryptionKey,
'encryptionKey should be set for encrypted media',
);
mediaSource = {
...mediaSource,
type: mediaType,
blobURI: uri,
encryptionKey,
thumbnailBlobURI: null,
thumbnailEncryptionKey: null,
};
}
return (
<Multimedia
mediaSource={mediaSource}
pendingUpload={pendingUpload}
remove={cancelPendingUpload}
multimediaCSSClass={css.multimedia}
multimediaImageCSSClass={css.multimediaImage}
key={pendingUpload.localID}
/>
);
});
const previews =
multimediaPreviews.length > 0 ? (
<div className={css.previews}>{multimediaPreviews}</div>
) : 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 = (
<a onClick={this.onSend} className={css.sendButton}>
<SWMansionIcon
icon="send-2"
size={22}
color={`#${this.props.threadInfo.color}`}
/>
</a>
);
}
if (
threadHasPermission(this.props.threadInfo, threadPermissions.VOICED) ||
(this.props.threadCreationInProgress && defaultMembersAreVoiced)
) {
content = (
<div className={css.inputBarWrapper}>
<a className={css.multimediaUpload} onClick={this.onMultimediaClick}>
<input
type="file"
onChange={this.onMultimediaFileChange}
ref={this.multimediaInputRef}
accept={allowedMimeTypeString}
multiple
/>
<SWMansionIcon
icon="image-1"
size={22}
color={`#${this.props.threadInfo.color}`}
disableFill
/>
</a>
<div className={css.inputBarTextInput}>
<textarea
rows="1"
placeholder="Type your message"
value={this.props.inputState.draft}
onChange={this.onChangeMessageText}
onKeyDown={this.onKeyDown}
onClick={this.onClickTextarea}
onSelect={this.onSelectTextarea}
ref={this.textareaRef}
autoFocus
/>
</div>
{sendButton}
</div>
);
} else if (
threadFrozenDueToViewerBlock(
this.props.threadInfo,
this.props.viewerID,
this.props.userInfos,
) &&
threadActualMembers(this.props.threadInfo.members).length === 2
) {
content = (
<span className={css.explanation}>
You can&rsquo;t send messages to a user that you&rsquo;ve blocked.
</span>
);
} else if (isMember) {
content = (
<span className={css.explanation}>
You don&rsquo;t have permission to send messages.
</span>
);
} else if (defaultMembersAreVoiced && canJoin) {
content = null;
} else {
content = (
<span className={css.explanation}>
You don&rsquo;t have permission to send messages.
</span>
);
}
let typeaheadTooltip;
if (
this.props.inputState.typeaheadState.canBeVisible &&
this.props.suggestions.length > 0 &&
this.props.typeaheadMatchedStrings &&
this.textarea
) {
typeaheadTooltip = (
<TypeaheadTooltip
inputState={this.props.inputState}
textarea={this.textarea}
matchedStrings={this.props.typeaheadMatchedStrings}
suggestions={this.props.suggestions}
typeaheadTooltipActionsGetter={getMentionTypeaheadTooltipActions}
typeaheadTooltipButtonsGetter={getMentionTypeaheadTooltipButtons}
/>
);
}
return (
<div className={css.inputBar}>
{joinButton}
{previews}
{content}
{typeaheadTooltip}
</div>
);
}
textareaRef = (textarea: ?HTMLTextAreaElement) => {
this.textarea = textarea;
if (textarea) {
textarea.focus();
}
};
onChangeMessageText = (event: SyntheticEvent<HTMLTextAreaElement>) => {
this.props.inputState.setDraft(event.currentTarget.value);
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
onClickTextarea = (event: SyntheticEvent<HTMLTextAreaElement>) => {
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
onSelectTextarea = (event: SyntheticEvent<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
const { accept, close, moveChoiceUp, moveChoiceDown } =
this.props.inputState.typeaheadState;
const actions = {
Enter: accept,
Tab: accept,
ArrowDown: moveChoiceDown,
ArrowUp: moveChoiceUp,
Escape: close,
};
if (
this.props.inputState.typeaheadState.canBeVisible &&
actions[event.key]
) {
event.preventDefault();
actions[event.key]();
} else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.send();
}
};
onSend = (event: SyntheticEvent<HTMLAnchorElement>) => {
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');
void this.props.inputState.sendTextMessage(
{
type: messageTypes.TEXT,
localID,
threadID: this.props.threadInfo.id,
text,
creatorID,
time: Date.now(),
},
this.props.threadInfo,
this.props.parentThreadInfo,
);
}
multimediaInputRef = (multimediaInput: ?HTMLInputElement) => {
this.multimediaInput = multimediaInput;
};
onMultimediaClick = () => {
if (this.multimediaInput) {
this.multimediaInput.click();
}
};
onMultimediaFileChange = async (
event: SyntheticInputEvent<HTMLInputElement>,
) => {
const result = await this.props.inputState.appendFiles(
this.props.threadInfo,
[...event.target.files],
);
if (!result && this.multimediaInput) {
this.multimediaInput.value = '';
}
};
onClickJoin = () => {
void this.props.dispatchActionPromise(
joinThreadActionTypes,
this.joinAction(),
);
};
async joinAction(): Promise<ThreadJoinPayload> {
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<BaseProps> =
React.memo<BaseProps>(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 = useJoinThread();
const { getChatMentionSearchIndex } = useChatMentionContext();
const chatMentionSearchIndex = getChatMentionSearchIndex(props.threadInfo);
const { parentThreadID } = props.threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
const userMentionsCandidates = useUserMentionsCandidates(
props.threadInfo,
parentThreadInfo,
);
const chatMentionCandidates = useThreadChatMentionCandidates(
props.threadInfo,
);
const typeaheadRegexMatches = React.useMemo(
() =>
getTypeaheadRegexMatches(
props.inputState.draft,
{
start: props.inputState.textCursorPosition,
end: props.inputState.textCursorPosition,
},
webMentionTypeaheadRegex,
),
[props.inputState.textCursorPosition, props.inputState.draft],
);
const typeaheadMatchedStrings: ?TypeaheadMatchedStrings =
React.useMemo(() => {
if (typeaheadRegexMatches === null) {
return null;
}
return {
textBeforeAtSymbol: typeaheadRegexMatches.groups?.textPrefix ?? '',
query: typeaheadRegexMatches.groups?.mentionText ?? '',
};
}, [typeaheadRegexMatches]);
React.useEffect(() => {
if (props.inputState.typeaheadState.keepUpdatingThreadMembers) {
const setter = props.inputState.setTypeaheadState;
setter({
frozenUserMentionsCandidates: userMentionsCandidates,
frozenChatMentionsCandidates: chatMentionCandidates,
});
}
}, [
userMentionsCandidates,
props.inputState.setTypeaheadState,
props.inputState.typeaheadState.keepUpdatingThreadMembers,
chatMentionCandidates,
]);
const suggestedUsers = useMentionTypeaheadUserSuggestions(
props.inputState.typeaheadState.frozenUserMentionsCandidates,
typeaheadMatchedStrings,
);
const suggestedChats = useMentionTypeaheadChatSuggestions(
chatMentionSearchIndex,
props.inputState.typeaheadState.frozenChatMentionsCandidates,
typeaheadMatchedStrings,
);
const suggestions: $ReadOnlyArray<MentionTypeaheadSuggestionItem> =
React.useMemo(
() => [...suggestedUsers, ...suggestedChats],
[suggestedUsers, suggestedChats],
);
return (
<ChatInputBar
{...props}
viewerID={viewerID}
joinThreadLoadingStatus={joinThreadLoadingStatus}
threadCreationInProgress={threadCreationInProgress}
calendarQuery={calendarQuery}
nextLocalID={nextLocalID}
isThreadActive={isThreadActive}
userInfos={userInfos}
dispatchActionPromise={dispatchActionPromise}
joinThread={callJoinThread}
typeaheadMatchedStrings={typeaheadMatchedStrings}
suggestions={suggestions}
parentThreadInfo={parentThreadInfo}
/>
);
});
export default ConnectedChatInputBar;

File Metadata

Mime Type
text/x-java
Expires
Sun, Dec 7, 7:54 AM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5623315
Default Alt Text
chat-input-bar.react.js (20 KB)

Event Timeline