diff --git a/web/avatars/edit-thread-avatar-menu.react.js b/web/avatars/edit-thread-avatar-menu.react.js
index cf9960e8d..554a5d3bc 100644
--- a/web/avatars/edit-thread-avatar-menu.react.js
+++ b/web/avatars/edit-thread-avatar-menu.react.js
@@ -1,125 +1,121 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- RawThreadInfo,
- LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js';
import { useUploadAvatarMedia } from './avatar-hooks.react.js';
import css from './edit-avatar-menu.css';
import ThreadEmojiAvatarSelectionModal from './thread-emoji-avatar-selection-modal.react.js';
import MenuItem from '../components/menu-item.react.js';
import Menu from '../components/menu.react.js';
import { allowedMimeTypeString } from '../media/file-utils.js';
const editIcon = (
);
type Props = {
- +threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: RawThreadInfo | ThreadInfo,
};
function EditThreadAvatarMenu(props: Props): React.Node {
const { threadInfo } = props;
const editThreadAvatarContext = React.useContext(EditThreadAvatarContext);
invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set');
const { baseSetThreadAvatar } = editThreadAvatarContext;
const removeThreadAvatar = React.useCallback(
() => baseSetThreadAvatar(threadInfo.id, { type: 'remove' }),
[baseSetThreadAvatar, threadInfo.id],
);
const removeMenuItem = React.useMemo(
() => (
),
[removeThreadAvatar],
);
const imageInputRef = React.useRef();
const onImageMenuItemClicked = React.useCallback(
() => imageInputRef.current?.click(),
[],
);
const uploadAvatarMedia = useUploadAvatarMedia();
const onImageSelected = React.useCallback(
async (event: SyntheticEvent) => {
const { target } = event;
invariant(target instanceof HTMLInputElement, 'target not input');
const uploadResult = await uploadAvatarMedia(target.files[0]);
baseSetThreadAvatar(threadInfo.id, uploadResult);
},
[baseSetThreadAvatar, threadInfo.id, uploadAvatarMedia],
);
const imageMenuItem = React.useMemo(
() => (
),
[onImageMenuItemClicked],
);
const { pushModal } = useModalContext();
const openEmojiSelectionModal = React.useCallback(
() =>
pushModal( ),
[pushModal, threadInfo],
);
const emojiMenuItem = React.useMemo(
() => (
),
[openEmojiSelectionModal],
);
const menuItems = React.useMemo(() => {
const items = [emojiMenuItem, imageMenuItem];
if (threadInfo.avatar) {
items.push(removeMenuItem);
}
return items;
}, [emojiMenuItem, imageMenuItem, removeMenuItem, threadInfo.avatar]);
return (
{menuItems}
);
}
export default EditThreadAvatarMenu;
diff --git a/web/avatars/edit-thread-avatar.react.js b/web/avatars/edit-thread-avatar.react.js
index d14cd1d13..85f184a52 100644
--- a/web/avatars/edit-thread-avatar.react.js
+++ b/web/avatars/edit-thread-avatar.react.js
@@ -1,52 +1,48 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js';
import { threadHasPermission } from 'lib/shared/thread-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
-import type {
- RawThreadInfo,
- LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js';
import EditThreadAvatarMenu from './edit-thread-avatar-menu.react.js';
import css from './edit-thread-avatar.css';
import ThreadAvatar from './thread-avatar.react.js';
type Props = {
- +threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: RawThreadInfo | ThreadInfo,
+disabled?: boolean,
};
function EditThreadAvatar(props: Props): React.Node {
const editThreadAvatarContext = React.useContext(EditThreadAvatarContext);
invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set');
const { threadAvatarSaveInProgress } = editThreadAvatarContext;
const { threadInfo } = props;
const canEditThreadAvatar = threadHasPermission(
threadInfo,
threadPermissions.EDIT_THREAD_AVATAR,
);
let editThreadAvatarMenu;
if (canEditThreadAvatar && !threadAvatarSaveInProgress) {
editThreadAvatarMenu = ;
}
return (
{editThreadAvatarMenu}
);
}
export default EditThreadAvatar;
diff --git a/web/avatars/thread-avatar.react.js b/web/avatars/thread-avatar.react.js
index b2db42e75..cf74a5406 100644
--- a/web/avatars/thread-avatar.react.js
+++ b/web/avatars/thread-avatar.react.js
@@ -1,60 +1,56 @@
// @flow
import * as React from 'react';
import {
useAvatarForThread,
useENSResolvedAvatar,
} from 'lib/shared/avatar-utils.js';
import { getSingleOtherUser } from 'lib/shared/thread-utils.js';
import type { AvatarSize } from 'lib/types/avatar-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
-import type {
- LegacyThreadInfo,
- RawThreadInfo,
-} from 'lib/types/thread-types.js';
+import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js';
import Avatar from './avatar.react.js';
import { useSelector } from '../redux/redux-utils.js';
type Props = {
- +threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: RawThreadInfo | ThreadInfo,
+size: AvatarSize,
+showSpinner?: boolean,
};
function ThreadAvatar(props: Props): React.Node {
const { threadInfo, size, showSpinner } = props;
const avatarInfo = useAvatarForThread(threadInfo);
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
let displayUserIDForThread;
if (threadInfo.type === threadTypes.PRIVATE) {
displayUserIDForThread = viewerID;
} else if (threadInfo.type === threadTypes.PERSONAL) {
displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID);
}
const displayUser = useSelector(state =>
displayUserIDForThread
? state.userStore.userInfos[displayUserIDForThread]
: null,
);
const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser);
return (
);
}
export default ThreadAvatar;
diff --git a/web/avatars/thread-emoji-avatar-selection-modal.react.js b/web/avatars/thread-emoji-avatar-selection-modal.react.js
index 274996d92..c069e0168 100644
--- a/web/avatars/thread-emoji-avatar-selection-modal.react.js
+++ b/web/avatars/thread-emoji-avatar-selection-modal.react.js
@@ -1,57 +1,53 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js';
import {
getDefaultAvatar,
useAvatarForThread,
} from 'lib/shared/avatar-utils.js';
import type {
ClientAvatar,
ClientEmojiAvatar,
} from 'lib/types/avatar-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- RawThreadInfo,
- LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js';
import EmojiAvatarSelectionModal from './emoji-avatar-selection-modal.react.js';
type Props = {
- +threadInfo: LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: RawThreadInfo | ThreadInfo,
};
function ThreadEmojiAvatarSelectionModal(props: Props): React.Node {
const { threadInfo } = props;
const editThreadAvatarContext = React.useContext(EditThreadAvatarContext);
invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set');
const { baseSetThreadAvatar, threadAvatarSaveInProgress } =
editThreadAvatarContext;
const currentThreadAvatar: ClientAvatar = useAvatarForThread(threadInfo);
const defaultThreadAvatar: ClientEmojiAvatar = getDefaultAvatar(
threadInfo.id,
threadInfo.color,
);
const setEmojiAvatar = React.useCallback(
(pendingEmojiAvatar: ClientEmojiAvatar): Promise =>
baseSetThreadAvatar(threadInfo.id, pendingEmojiAvatar),
[baseSetThreadAvatar, threadInfo.id],
);
return (
);
}
export default ThreadEmojiAvatarSelectionModal;
diff --git a/web/calendar/day.react.js b/web/calendar/day.react.js
index bff1ce675..7e6d2b38e 100644
--- a/web/calendar/day.react.js
+++ b/web/calendar/day.react.js
@@ -1,261 +1,258 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import _some from 'lodash/fp/some.js';
import * as React from 'react';
import {
createLocalEntry,
createLocalEntryActionType,
} from 'lib/actions/entry-actions.js';
import {
useModalContext,
type PushModal,
} from 'lib/components/modal-provider.react.js';
import { onScreenThreadInfos as onScreenThreadInfosSelector } from 'lib/selectors/thread-selectors.js';
import { entryKey } from 'lib/shared/entry-utils.js';
import type { EntryInfo } from 'lib/types/entry-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { dateString, dateFromString } from 'lib/utils/date-utils.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import css from './calendar.css';
import type { InnerEntry } from './entry.react.js';
import Entry from './entry.react.js';
import LogInFirstModal from '../modals/account/log-in-first-modal.react.js';
import HistoryModal from '../modals/history/history-modal.react.js';
import ThreadPickerModal from '../modals/threads/thread-picker-modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { htmlTargetFromEvent } from '../vector-utils.js';
import { AddVector, HistoryVector } from '../vectors.react.js';
type BaseProps = {
+dayString: string,
+entryInfos: $ReadOnlyArray,
+startingTabIndex: number,
};
type Props = {
...BaseProps,
- +onScreenThreadInfos: $ReadOnlyArray<
- LegacyThreadInfo | MinimallyEncodedThreadInfo,
- >,
+ +onScreenThreadInfos: $ReadOnlyArray,
+viewerID: ?string,
+loggedIn: boolean,
+nextLocalID: number,
+dispatch: Dispatch,
+pushModal: PushModal,
+popModal: () => void,
};
type State = {
+hovered: boolean,
};
class Day extends React.PureComponent {
state: State = {
hovered: false,
};
entryContainer: ?HTMLDivElement;
entryContainerSpacer: ?HTMLDivElement;
actionLinks: ?HTMLDivElement;
entries: Map = new Map();
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(): React.Node {
const now = new Date();
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 (
);
});
const entryContainerClasses = classNames(css.entryContainer, {
[css.focusedEntryContainer]: hovered,
});
const date = dateFromString(this.props.dayString);
return (
{date.getDate()}
{actionLinks}
);
}
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);
};
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.props.pushModal(
,
);
}
};
createNewEntry = (threadID: string) => {
if (!this.props.loggedIn) {
this.props.pushModal( );
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.pushModal(
,
);
};
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();
}
};
}
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 dispatch = useDispatch();
const { pushModal, popModal } = useModalContext();
return (
);
},
);
export default ConnectedDay;
diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js
index 5947491bc..7c8236bb5 100644
--- a/web/chat/chat-input-bar.react.js
+++ b/web/chat/chat-input-bar.react.js
@@ -1,692 +1,692 @@
// @flow
import invariant from 'invariant';
import _difference from 'lodash/fp/difference.js';
import * as React from 'react';
import {
joinThreadActionTypes,
useJoinThread,
newThreadActionTypes,
} 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 { userStoreMentionSearchIndex } from 'lib/selectors/user-selectors.js';
import {
getMentionTypeaheadUserSuggestions,
getTypeaheadRegexMatches,
getUserMentionsCandidates,
getMentionTypeaheadChatSuggestions,
type MentionTypeaheadSuggestionItem,
type TypeaheadMatchedStrings,
} from 'lib/shared/mention-utils.js';
import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js';
import {
threadHasPermission,
viewerIsMember,
threadFrozenDueToViewerBlock,
threadActualMembers,
checkIfDefaultMembersAreVoiced,
} 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 { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import {
type LegacyThreadInfo,
type ClientThreadJoinRequest,
type ThreadJoinPayload,
+ type ThreadInfo,
} from 'lib/types/thread-types.js';
import { type UserInfos } from 'lib/types/user-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/action-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 {
webMentionTypeaheadRegex,
getMentionTypeaheadTooltipActions,
getMentionTypeaheadTooltipButtons,
} from '../utils/typeahead-utils.js';
type BaseProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +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,
+typeaheadMatchedStrings: ?TypeaheadMatchedStrings,
+suggestions: $ReadOnlyArray,
+parentThreadInfo: ?LegacyThreadInfo,
};
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();
}
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,
): Array {
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 = (
);
} else {
buttonContent = (
<>
Join Chat
>
);
}
joinButton = (
{buttonContent}
);
}
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 (
);
});
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.inputState.typeaheadState.canBeVisible &&
this.props.suggestions.length > 0 &&
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) => {
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) => {
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,
this.props.parentThreadInfo,
);
}
multimediaInputRef = (multimediaInput: ?HTMLInputElement) => {
this.multimediaInput = multimediaInput;
};
onMultimediaClick = () => {
if (this.multimediaInput) {
this.multimediaInput.click();
}
};
onMultimediaFileChange = async (
event: SyntheticInputEvent,
) => {
const result = await this.props.inputState.appendFiles(
this.props.threadInfo,
[...event.target.files],
);
if (!result && this.multimediaInput) {
this.multimediaInput.value = '';
}
};
onClickJoin = () => {
this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction());
};
async joinAction(): Promise {
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 = useJoinThread();
const userSearchIndex = useSelector(userStoreMentionSearchIndex);
const { getChatMentionSearchIndex } = useChatMentionContext();
const chatMentionSearchIndex = getChatMentionSearchIndex(props.threadInfo);
const { parentThreadID } = props.threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
const userMentionsCandidates = getUserMentionsCandidates(
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(
() =>
typeaheadRegexMatches !== null
? {
textBeforeAtSymbol:
typeaheadRegexMatches.groups?.textPrefix ?? '',
query: typeaheadRegexMatches.groups?.mentionText ?? '',
}
: null,
[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 suggestions = React.useMemo(() => {
if (!typeaheadMatchedStrings) {
return ([]: $ReadOnlyArray);
}
const suggestedUsers = getMentionTypeaheadUserSuggestions(
userSearchIndex,
props.inputState.typeaheadState.frozenUserMentionsCandidates,
viewerID,
typeaheadMatchedStrings.query,
);
const suggestedChats = getMentionTypeaheadChatSuggestions(
chatMentionSearchIndex,
props.inputState.typeaheadState.frozenChatMentionsCandidates,
typeaheadMatchedStrings.query,
);
return ([
...suggestedUsers,
...suggestedChats,
]: $ReadOnlyArray);
}, [
typeaheadMatchedStrings,
userSearchIndex,
props.inputState.typeaheadState.frozenUserMentionsCandidates,
props.inputState.typeaheadState.frozenChatMentionsCandidates,
viewerID,
chatMentionSearchIndex,
]);
return (
);
});
export default ConnectedChatInputBar;
diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js
index f758c67f6..80dc8d910 100644
--- a/web/chat/chat-message-list.react.js
+++ b/web/chat/chat-message-list.react.js
@@ -1,487 +1,486 @@
// @flow
import classNames from 'classnames';
import { detect as detectBrowser } from 'detect-browser';
import invariant from 'invariant';
import _debounce from 'lodash/debounce.js';
import * as React from 'react';
import {
fetchMessagesBeforeCursorActionTypes,
useFetchMessagesBeforeCursor,
fetchMostRecentMessagesActionTypes,
useFetchMostRecentMessages,
} from 'lib/actions/message-actions.js';
import type {
FetchMostRecentMessagesInput,
FetchMessagesBeforeCursorInput,
} from 'lib/actions/message-actions.js';
import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js';
import { registerFetchKey } from 'lib/reducers/loading-reducer.js';
import {
type ChatMessageItem,
useMessageListData,
} from 'lib/selectors/chat-selectors.js';
import { messageKey } from 'lib/shared/message-utils.js';
import { threadIsPending } from 'lib/shared/thread-utils.js';
import type { FetchMessageInfosPayload } from 'lib/types/message-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import { editBoxHeight, defaultMaxTextAreaHeight } from './chat-constants.js';
import css from './chat-message-list.css';
import type { ScrollToMessageCallback } from './edit-message-provider.js';
import { useEditModalContext } from './edit-message-provider.js';
import { MessageListContext } from './message-list-types.js';
import Message from './message.react.js';
import RelationshipPrompt from './relationship-prompt/relationship-prompt.js';
import { useTooltipContext } from './tooltip-provider.js';
import { type InputState, InputStateContext } from '../input/input-state.js';
import LoadingIndicator from '../loading-indicator.react.js';
import { useTextMessageRulesFunc } from '../markdown/rules.react.js';
import { useSelector } from '../redux/redux-utils.js';
const browser = detectBrowser();
const supportsReverseFlex =
!browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81;
// Margin between the top of the maximum height edit box
// and the top of the container
const editBoxTopMargin = 10;
type BaseProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
type Props = {
...BaseProps,
+activeChatThreadID: ?string,
+messageListData: ?$ReadOnlyArray,
+startReached: boolean,
+dispatchActionPromise: DispatchActionPromise,
+fetchMessagesBeforeCursor: (
input: FetchMessagesBeforeCursorInput,
) => Promise,
+fetchMostRecentMessages: (
input: FetchMostRecentMessagesInput,
) => Promise,
+inputState: ?InputState,
+clearTooltip: () => mixed,
+oldestMessageServerID: ?string,
+isEditState: boolean,
+addScrollToMessageListener: ScrollToMessageCallback => mixed,
+removeScrollToMessageListener: ScrollToMessageCallback => mixed,
};
type Snapshot = {
+scrollTop: number,
+scrollHeight: number,
};
type State = {
+scrollingEndCallback: ?() => mixed,
};
class ChatMessageList extends React.PureComponent {
container: ?HTMLDivElement;
messageContainer: ?HTMLDivElement;
loadingFromScroll = false;
constructor(props: Props) {
super(props);
this.state = {
scrollingEndCallback: null,
};
}
componentDidMount() {
this.scrollToBottom();
this.props.addScrollToMessageListener(this.scrollToMessage);
}
componentWillUnmount() {
this.props.removeScrollToMessageListener(this.scrollToMessage);
}
getSnapshotBeforeUpdate(prevProps: Props): ?Snapshot {
if (
ChatMessageList.hasNewMessage(this.props, prevProps) &&
this.messageContainer
) {
const { scrollTop, scrollHeight } = this.messageContainer;
return { scrollTop, scrollHeight };
}
return null;
}
static hasNewMessage(props: Props, prevProps: Props): boolean {
const { messageListData } = props;
if (!messageListData || messageListData.length === 0) {
return false;
}
const prevMessageListData = prevProps.messageListData;
if (!prevMessageListData || prevMessageListData.length === 0) {
return true;
}
return (
ChatMessageList.keyExtractor(prevMessageListData[0]) !==
ChatMessageList.keyExtractor(messageListData[0])
);
}
componentDidUpdate(prevProps: Props, prevState: State, snapshot: ?Snapshot) {
const { messageListData } = this.props;
const prevMessageListData = prevProps.messageListData;
const { messageContainer } = this;
if (messageContainer && prevMessageListData !== messageListData) {
this.onScroll();
}
// We'll scroll to the bottom if the user was already scrolled to the bottom
// before the new message, or if the new message was composed locally
const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps);
if (
this.props.activeChatThreadID !== prevProps.activeChatThreadID ||
(hasNewMessage &&
messageListData &&
messageListData[0].itemType === 'message' &&
messageListData[0].messageInfo.localID) ||
(hasNewMessage &&
snapshot &&
Math.abs(snapshot.scrollTop) <= 1 &&
!this.props.isEditState)
) {
this.scrollToBottom();
} else if (hasNewMessage && messageContainer && snapshot) {
const { scrollTop, scrollHeight } = messageContainer;
if (
scrollHeight > snapshot.scrollHeight &&
scrollTop === snapshot.scrollTop
) {
const newHeight = scrollHeight - snapshot.scrollHeight;
const newScrollTop = Math.abs(scrollTop) + newHeight;
if (supportsReverseFlex) {
messageContainer.scrollTop = -1 * newScrollTop;
} else {
messageContainer.scrollTop = newScrollTop;
}
}
}
}
scrollToBottom() {
if (this.messageContainer) {
this.messageContainer.scrollTop = 0;
}
}
static keyExtractor(item: ChatMessageItem): string {
if (item.itemType === 'loader') {
return 'loader';
}
return messageKey(item.messageInfo);
}
renderItem = (item: ChatMessageItem): React.Node => {
if (item.itemType === 'loader') {
return (
);
}
const { threadInfo } = this.props;
invariant(threadInfo, 'ThreadInfo should be set if messageListData is');
return (
);
};
scrollingEndCallbackWrapper = (
composedMessageID: string,
callback: (maxHeight: number) => mixed,
): (() => mixed) => {
return () => {
const maxHeight = this.getMaxEditTextAreaHeight(composedMessageID);
callback(maxHeight);
};
};
scrollToMessage = (
composedMessageID: string,
callback: (maxHeight: number) => mixed,
) => {
const element = document.getElementById(composedMessageID);
if (!element) {
return;
}
const scrollingEndCallback = this.scrollingEndCallbackWrapper(
composedMessageID,
callback,
);
if (!this.willMessageEditWindowOverflow(composedMessageID)) {
scrollingEndCallback();
return;
}
this.setState(
{
scrollingEndCallback,
},
() => {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// It covers the case when browser decide not to scroll to the message
// because it's already in the view.
// In this case, the 'scroll' event won't be triggered,
// so we need to call the callback manually.
this.debounceEditModeAfterScrollToMessage();
},
);
};
getMaxEditTextAreaHeight = (composedMessageID: string): number => {
const { messageContainer } = this;
if (!messageContainer) {
return defaultMaxTextAreaHeight;
}
const messageElement = document.getElementById(composedMessageID);
if (!messageElement) {
console.log(`couldn't find the message element`);
return defaultMaxTextAreaHeight;
}
const msgPos = messageElement.getBoundingClientRect();
const containerPos = messageContainer.getBoundingClientRect();
const messageBottom = msgPos.bottom;
const containerTop = containerPos.top;
const maxHeight =
messageBottom - containerTop - editBoxHeight - editBoxTopMargin;
return maxHeight;
};
willMessageEditWindowOverflow(composedMessageID: string): boolean {
const { messageContainer } = this;
if (!messageContainer) {
return false;
}
const messageElement = document.getElementById(composedMessageID);
if (!messageElement) {
console.log(`couldn't find the message element`);
return false;
}
const msgPos = messageElement.getBoundingClientRect();
const containerPos = messageContainer.getBoundingClientRect();
const containerTop = containerPos.top;
const containerBottom = containerPos.bottom;
const availableTextAreaHeight =
(containerBottom - containerTop) / 2 - editBoxHeight;
const messageHeight = msgPos.height;
const expectedMinimumHeight = Math.min(
defaultMaxTextAreaHeight,
availableTextAreaHeight,
);
const offset = Math.max(
0,
expectedMinimumHeight + editBoxHeight + editBoxTopMargin - messageHeight,
);
const messageTop = msgPos.top - offset;
const messageBottom = msgPos.bottom;
return messageBottom > containerBottom || messageTop < containerTop;
}
render(): React.Node {
const { messageListData, threadInfo, inputState, isEditState } = this.props;
if (!messageListData) {
return
;
}
invariant(inputState, 'InputState should be set');
const messages = messageListData.map(this.renderItem);
let relationshipPrompt = null;
if (threadInfo.type === threadTypes.PERSONAL) {
relationshipPrompt = ;
}
const messageContainerStyle = classNames({
[css.disableAnchor]:
this.state.scrollingEndCallback !== null || isEditState,
[css.messageContainer]: true,
[css.mirroredMessageContainer]: !supportsReverseFlex,
});
return (
{relationshipPrompt}
{messages}
);
}
messageContainerRef = (messageContainer: ?HTMLDivElement) => {
this.messageContainer = messageContainer;
// In case we already have all the most recent messages,
// but they're not enough
this.possiblyLoadMoreMessages();
if (messageContainer) {
messageContainer.addEventListener('scroll', this.onScroll);
}
};
onScroll = () => {
if (!this.messageContainer) {
return;
}
this.props.clearTooltip();
this.possiblyLoadMoreMessages();
this.debounceEditModeAfterScrollToMessage();
};
debounceEditModeAfterScrollToMessage: () => void = _debounce(() => {
if (this.state.scrollingEndCallback) {
this.state.scrollingEndCallback();
}
this.setState({ scrollingEndCallback: null });
}, 100);
async possiblyLoadMoreMessages() {
if (!this.messageContainer) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = this.messageContainer;
if (
this.props.startReached ||
Math.abs(scrollTop) + clientHeight + 55 < scrollHeight
) {
return;
}
if (this.loadingFromScroll) {
return;
}
this.loadingFromScroll = true;
const threadID = this.props.activeChatThreadID;
invariant(threadID, 'should be set');
try {
const { oldestMessageServerID } = this.props;
if (oldestMessageServerID) {
await this.props.dispatchActionPromise(
fetchMessagesBeforeCursorActionTypes,
this.props.fetchMessagesBeforeCursor({
threadID,
beforeMessageID: oldestMessageServerID,
}),
);
} else {
await this.props.dispatchActionPromise(
fetchMostRecentMessagesActionTypes,
this.props.fetchMostRecentMessages({ threadID }),
);
}
} finally {
this.loadingFromScroll = false;
}
}
}
registerFetchKey(fetchMessagesBeforeCursorActionTypes);
registerFetchKey(fetchMostRecentMessagesActionTypes);
const ConnectedChatMessageList: React.ComponentType =
React.memo(function ConnectedChatMessageList(
props: BaseProps,
): React.Node {
const { threadInfo } = props;
const messageListData = useMessageListData({
threadInfo,
searching: false,
userInfoInputArray: [],
});
const startReached = !!useSelector(state => {
const activeID = threadInfo.id;
if (!activeID) {
return null;
}
if (threadIsPending(activeID)) {
return true;
}
const threadMessageInfo = state.messageStore.threads[activeID];
if (!threadMessageInfo) {
return null;
}
return threadMessageInfo.startReached;
});
const dispatchActionPromise = useDispatchActionPromise();
const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor();
const callFetchMostRecentMessages = useFetchMostRecentMessages();
const inputState = React.useContext(InputStateContext);
const { clearTooltip } = useTooltipContext();
const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo);
const getTextMessageMarkdownRules = useTextMessageRulesFunc(
threadInfo,
chatMentionCandidates,
);
const messageListContext = React.useMemo(() => {
if (!getTextMessageMarkdownRules) {
return undefined;
}
return { getTextMessageMarkdownRules };
}, [getTextMessageMarkdownRules]);
const oldestMessageServerID = useOldestMessageServerID(threadInfo.id);
const {
editState,
addScrollToMessageListener,
removeScrollToMessageListener,
} = useEditModalContext();
const isEditState = editState !== null;
return (
);
});
export default ConnectedChatMessageList;
diff --git a/web/chat/chat-thread-list-item-menu.react.js b/web/chat/chat-thread-list-item-menu.react.js
index 1641937be..3f5f76b7f 100644
--- a/web/chat/chat-thread-list-item-menu.react.js
+++ b/web/chat/chat-thread-list-item-menu.react.js
@@ -1,77 +1,76 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import useToggleUnreadStatus from 'lib/hooks/toggle-unread-status.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-thread-list-item-menu.css';
import Button from '../components/button.react.js';
import { useThreadIsActive } from '../selectors/thread-selectors.js';
type Props = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+mostRecentNonLocalMessage: ?string,
+renderStyle?: 'chat' | 'thread',
};
function ChatThreadListItemMenu(props: Props): React.Node {
const { renderStyle = 'chat', threadInfo, mostRecentNonLocalMessage } = props;
const active = useThreadIsActive(threadInfo.id);
const [menuVisible, setMenuVisible] = React.useState(false);
const toggleMenu = React.useCallback(
(event: SyntheticEvent) => {
event.stopPropagation();
setMenuVisible(!menuVisible);
},
[menuVisible],
);
const hideMenu = React.useCallback(() => {
setMenuVisible(false);
}, []);
const toggleUnreadStatus = useToggleUnreadStatus(
threadInfo,
mostRecentNonLocalMessage,
hideMenu,
);
const onToggleUnreadStatusClicked = React.useCallback(
(event: SyntheticEvent) => {
event.stopPropagation();
toggleUnreadStatus();
},
[toggleUnreadStatus],
);
const toggleUnreadStatusButtonText = `Mark as ${
threadInfo.currentUser.unread ? 'read' : 'unread'
}`;
const menuIconSize = renderStyle === 'chat' ? 24 : 20;
const menuCls = classNames(css.menu, {
[css.menuSidebar]: renderStyle === 'thread',
});
const btnCls = classNames(css.menuContent, {
[css.menuContentVisible]: menuVisible,
[css.active]: active,
});
return (
{toggleUnreadStatusButtonText}
);
}
export default ChatThreadListItemMenu;
diff --git a/web/chat/chat-thread-list-see-more-sidebars.react.js b/web/chat/chat-thread-list-see-more-sidebars.react.js
index 004927996..7c02a3e11 100644
--- a/web/chat/chat-thread-list-see-more-sidebars.react.js
+++ b/web/chat/chat-thread-list-see-more-sidebars.react.js
@@ -1,51 +1,50 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { IoIosMore } from 'react-icons/io/index.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-thread-list.css';
import SidebarsModal from '../modals/threads/sidebars/sidebars-modal.react.js';
type Props = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+unread: boolean,
};
function ChatThreadListSeeMoreSidebars(props: Props): React.Node {
const { unread, threadInfo } = props;
const { pushModal, popModal } = useModalContext();
const onClick = React.useCallback(
() =>
pushModal(
,
),
[popModal, pushModal, threadInfo.id],
);
return (
);
}
export default ChatThreadListSeeMoreSidebars;
diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js
index 2de5f6a10..ce7788be9 100644
--- a/web/chat/composed-message.react.js
+++ b/web/chat/composed-message.react.js
@@ -1,258 +1,257 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import {
Circle as CircleIcon,
CheckCircle as CheckCircleIcon,
XCircle as XCircleIcon,
} from 'react-feather';
import { useStringForUser } from 'lib/hooks/ens-cache.js';
import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { getMessageLabel } from 'lib/shared/edit-messages-utils.js';
import { assertComposableMessageType } from 'lib/types/message-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import { getComposedMessageID } from './chat-constants.js';
import css from './chat-message-list.css';
import FailedSend from './failed-send.react.js';
import InlineEngagement from './inline-engagement.react.js';
import UserAvatar from '../avatars/user-avatar.react.js';
import CommIcon from '../CommIcon.react.js';
import { type InputState, InputStateContext } from '../input/input-state.js';
import { usePushUserProfileModal } from '../modals/user-profile/user-profile-utils.js';
import { useMessageTooltip } from '../utils/tooltip-action-utils.js';
import { tooltipPositions } from '../utils/tooltip-utils.js';
export type ComposedMessageID = string;
const availableTooltipPositionsForViewerMessage = [
tooltipPositions.LEFT,
tooltipPositions.LEFT_BOTTOM,
tooltipPositions.LEFT_TOP,
tooltipPositions.RIGHT,
tooltipPositions.RIGHT_BOTTOM,
tooltipPositions.RIGHT_TOP,
tooltipPositions.BOTTOM,
tooltipPositions.TOP,
];
const availableTooltipPositionsForNonViewerMessage = [
tooltipPositions.RIGHT,
tooltipPositions.RIGHT_BOTTOM,
tooltipPositions.RIGHT_TOP,
tooltipPositions.LEFT,
tooltipPositions.LEFT_BOTTOM,
tooltipPositions.LEFT_TOP,
tooltipPositions.BOTTOM,
tooltipPositions.TOP,
];
type BaseProps = {
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+shouldDisplayPinIndicator: boolean,
+sendFailed: boolean,
+children: React.Node,
+fixedWidth?: boolean,
+borderRadius: number,
};
type DefaultProps = { +borderRadius: number };
type BaseConfig = React.Config;
type Props = {
...BaseProps,
// withInputState
+inputState: ?InputState,
+onMouseLeave: ?() => mixed,
+onMouseEnter: (event: SyntheticEvent) => mixed,
+containsInlineEngagement: boolean,
+stringForUser: ?string,
+onClickUser: () => mixed,
};
class ComposedMessage extends React.PureComponent {
static defaultProps: DefaultProps = { borderRadius: 8 };
render(): React.Node {
assertComposableMessageType(this.props.item.messageInfo.type);
const { borderRadius, item, threadInfo, shouldDisplayPinIndicator } =
this.props;
const { hasBeenEdited, isPinned } = item;
const { id, creator } = item.messageInfo;
const threadColor = threadInfo.color;
const { isViewer } = creator;
const contentClassName = classNames({
[css.content]: true,
[css.viewerContent]: isViewer,
[css.nonViewerContent]: !isViewer,
});
const messageBoxContainerClassName = classNames({
[css.messageBoxContainer]: true,
[css.fixedWidthMessageBoxContainer]: this.props.fixedWidth,
});
const messageBoxClassName = classNames({
[css.messageBox]: true,
[css.fixedWidthMessageBox]: this.props.fixedWidth,
});
const messageBoxStyle = {
borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius,
borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius,
borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius,
borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius,
};
let authorName = null;
const { stringForUser } = this.props;
if (stringForUser) {
authorName = (
{stringForUser}
);
}
let deliveryIcon = null;
let failedSendInfo = null;
if (isViewer) {
let deliveryIconSpan;
let deliveryIconColor = threadColor;
if (id !== null && id !== undefined) {
deliveryIconSpan = ;
} else if (this.props.sendFailed) {
deliveryIconSpan = ;
deliveryIconColor = 'FF0000';
failedSendInfo = ;
} else {
deliveryIconSpan = ;
}
deliveryIcon = (
{deliveryIconSpan}
);
}
let inlineEngagement = null;
const label = getMessageLabel(hasBeenEdited, threadInfo.id);
if (
(this.props.containsInlineEngagement && item.threadCreatedFromMessage) ||
Object.keys(item.reactions).length > 0 ||
label
) {
const positioning = isViewer ? 'right' : 'left';
inlineEngagement = (
);
}
let avatar;
if (!isViewer && item.endsCluster) {
avatar = (
);
} else if (!isViewer) {
avatar =
;
}
const pinIconPositioning = isViewer ? 'left' : 'right';
const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin';
const pinIconContainerClassName = classNames({
[css.pinIconContainer]: true,
[css.pinIconLeft]: pinIconPositioning === 'left',
[css.pinIconRight]: pinIconPositioning === 'right',
});
let pinIcon;
if (isPinned && shouldDisplayPinIndicator) {
pinIcon = (
);
}
return (
{authorName}
{avatar}
{pinIcon}
{this.props.children}
{deliveryIcon}
{failedSendInfo}
{inlineEngagement}
);
}
}
type ConnectedConfig = React.Config<
BaseProps,
typeof ComposedMessage.defaultProps,
>;
const ConnectedComposedMessage: React.ComponentType =
React.memo(function ConnectedComposedMessage(props) {
const { item, threadInfo } = props;
const inputState = React.useContext(InputStateContext);
const { creator } = props.item.messageInfo;
const { isViewer } = creator;
const availablePositions = isViewer
? availableTooltipPositionsForViewerMessage
: availableTooltipPositionsForNonViewerMessage;
const containsInlineEngagement = !!item.threadCreatedFromMessage;
const { onMouseLeave, onMouseEnter } = useMessageTooltip({
item,
threadInfo,
availablePositions,
});
const shouldShowUsername = !isViewer && item.startsCluster;
const stringForUser = useStringForUser(shouldShowUsername ? creator : null);
const pushUserProfileModal = usePushUserProfileModal(creator.id);
return (
);
});
export default ConnectedComposedMessage;
diff --git a/web/chat/edit-message-provider.js b/web/chat/edit-message-provider.js
index 8b363704c..785f738d3 100644
--- a/web/chat/edit-message-provider.js
+++ b/web/chat/edit-message-provider.js
@@ -1,220 +1,219 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import ModalOverlay from 'lib/components/modal-overlay.react.js';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types';
+import type { ThreadInfo } from 'lib/types/thread-types';
import { EditTextMessage } from './edit-text-message.react.js';
export type ModalPosition = {
+left: number,
+top: number,
+width: number,
+height: number,
};
export type EditState = {
+messageInfo: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+editedMessageDraft: ?string,
+isError: boolean,
+position?: ModalPosition,
+maxHeight: number,
};
export type ScrollToMessageCallback = (
composedMessageID: string,
callback: (maxHeight: number) => void,
) => void;
type EditModalContextType = {
+renderEditModal: (params: EditState) => void,
+clearEditModal: () => void,
+editState: ?EditState,
+setDraft: string => void,
+setError: boolean => void,
+updatePosition: ModalPosition => void,
+scrollToMessage: ScrollToMessageCallback,
+addScrollToMessageListener: ScrollToMessageCallback => void,
+removeScrollToMessageListener: ScrollToMessageCallback => void,
};
const EditModalContext: React.Context =
React.createContext({
renderEditModal: () => {},
clearEditModal: () => {},
editState: null,
setDraft: () => {},
setError: () => {},
updatePosition: () => {},
scrollToMessage: () => {},
addScrollToMessageListener: () => {},
removeScrollToMessageListener: () => {},
});
type Props = {
+children: React.Node,
};
function EditModalProvider(props: Props): React.Node {
const { children } = props;
const [editState, setEditState] = React.useState(null);
const [scrollToMessageCallbacks, setScrollToMessageCallbacks] =
React.useState>([]);
const clearEditModal = React.useCallback(() => {
setEditState(null);
}, []);
const renderEditModal = React.useCallback((newEditState: EditState): void => {
setEditState(newEditState);
}, []);
const modal = React.useMemo(() => {
if (!editState || !editState.position) {
return null;
}
const tooltipNode = (
);
const tooltipContainerStyle = {
position: 'fixed',
left: editState.position.left,
top: editState.position.top,
width: editState.position.width,
height: editState.position.height,
};
return {tooltipNode}
;
}, [editState]);
const setDraft = React.useCallback(
(draft: ?string) => {
if (!editState) {
return;
}
setEditState({
...editState,
editedMessageDraft: draft,
});
},
[editState, setEditState],
);
const setError = React.useCallback(
(isError: boolean) => {
invariant(editState, 'editState should be set in setError');
setEditState({
...editState,
isError,
});
},
[editState, setEditState],
);
const updatePosition = React.useCallback(
(position: ModalPosition) => {
invariant(editState, 'editState should be set in updatePosition');
setEditState({
...editState,
position,
});
},
[editState, setEditState],
);
const scrollToMessage: ScrollToMessageCallback = React.useCallback(
(messageKey: string, callback: (maxHeight: number) => void) => {
scrollToMessageCallbacks.forEach((callback2: ScrollToMessageCallback) =>
callback2(messageKey, callback),
);
},
[scrollToMessageCallbacks],
);
const addScrollToMessageListener = React.useCallback(
(callback: ScrollToMessageCallback): void => {
setScrollToMessageCallbacks(prevScrollToMessageCallbacks => [
...prevScrollToMessageCallbacks,
callback,
]);
},
[],
);
const removeScrollToMessageListener = React.useCallback(
(callback: ScrollToMessageCallback) => {
setScrollToMessageCallbacks(prevScrollToMessageCallbacks =>
prevScrollToMessageCallbacks.filter(
candidate => candidate !== callback,
),
);
},
[],
);
const value = React.useMemo(
() => ({
renderEditModal,
clearEditModal: clearEditModal,
editState,
setDraft,
setError,
updatePosition,
scrollToMessage,
addScrollToMessageListener,
removeScrollToMessageListener,
}),
[
renderEditModal,
clearEditModal,
editState,
setDraft,
setError,
updatePosition,
scrollToMessage,
addScrollToMessageListener,
removeScrollToMessageListener,
],
);
const modalOverlay = React.useMemo(() => {
if (!modal) {
return null;
}
return (
{modal}
);
}, [clearEditModal, modal]);
return (
{children}
{modalOverlay}
);
}
function useEditModalContext(): EditModalContextType {
const context = React.useContext(EditModalContext);
invariant(context, 'EditModalContext not found');
return context;
}
export { EditModalProvider, useEditModalContext };
diff --git a/web/chat/edit-text-message.react.js b/web/chat/edit-text-message.react.js
index 7f3badffb..217bb1eda 100644
--- a/web/chat/edit-text-message.react.js
+++ b/web/chat/edit-text-message.react.js
@@ -1,197 +1,196 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { useCallback } from 'react';
import { XCircle as XCircleIcon } from 'react-feather';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { useEditMessage } from 'lib/shared/edit-messages-utils.js';
import { trimMessage } from 'lib/shared/message-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import { editBoxBottomRowHeight } from './chat-constants.js';
import ChatInputTextArea from './chat-input-text-area.react.js';
import ComposedMessage from './composed-message.react.js';
import { useEditModalContext } from './edit-message-provider.js';
import css from './edit-text-message.css';
import type { ButtonColor } from '../components/button.react.js';
import Button from '../components/button.react.js';
type Props = {
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+background: boolean,
};
const cancelButtonColor: ButtonColor = {
backgroundColor: 'transparent',
};
const bottomRowStyle = { height: editBoxBottomRowHeight };
function EditTextMessage(props: Props): React.Node {
const { background, threadInfo, item } = props;
const { editState, clearEditModal, setDraft, setError, updatePosition } =
useEditModalContext();
const editMessage = useEditMessage();
const myRef = React.useRef(null);
const editedMessageDraft = editState?.editedMessageDraft ?? '';
const threadColor = threadInfo.color;
const saveButtonColor: ButtonColor = React.useMemo(
() => ({
backgroundColor: `#${threadColor}`,
}),
[threadColor],
);
const isMessageEmpty = React.useMemo(
() => trimMessage(editedMessageDraft) === '',
[editedMessageDraft],
);
const isMessageEdited = React.useMemo(() => {
const { messageInfo } = item;
if (!messageInfo || !messageInfo.text || !editState) {
return false;
}
if (!editedMessageDraft) {
return false;
}
const trimmedDraft = trimMessage(editedMessageDraft);
return trimmedDraft !== messageInfo.text;
}, [editState, editedMessageDraft, item]);
const checkAndEdit = async () => {
const { id: messageInfoID } = item.messageInfo;
if (isMessageEmpty) {
return;
}
if (!isMessageEdited) {
clearEditModal();
return;
}
if (!messageInfoID || !editState?.editedMessageDraft) {
return;
}
try {
await editMessage(messageInfoID, editState.editedMessageDraft);
clearEditModal();
} catch (e) {
setError(true);
}
};
const updateDimensions = useCallback(() => {
if (!myRef.current || !background) {
return;
}
const { left, top, width, height } = myRef.current.getBoundingClientRect();
updatePosition({
left,
top,
width,
height,
});
}, [background, updatePosition]);
const preventCloseTab = React.useCallback(
(event: BeforeUnloadEvent) => {
if (!isMessageEdited) {
return null;
}
event.preventDefault();
return (event.returnValue = '');
},
[isMessageEdited],
);
React.useEffect(() => {
if (!background) {
return undefined;
}
window.addEventListener('resize', updateDimensions);
window.addEventListener('beforeunload', preventCloseTab);
return () => {
window.removeEventListener('resize', updateDimensions);
window.removeEventListener('beforeunload', preventCloseTab);
};
}, [background, preventCloseTab, updateDimensions]);
React.useEffect(() => {
updateDimensions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let editFailed;
if (editState?.isError) {
editFailed = (
Edit failed.
Please try again.
);
}
const containerStyle = classNames(css.editMessage, {
[css.backgroundEditMessage]: background,
});
const maxTextAreaHeight = editState?.maxHeight;
return (
{editFailed}
Save (enter)
Cancel (esc)
);
}
const ComposedEditTextMessage: React.ComponentType = React.memo(
function ComposedEditTextMessage(props) {
const { background, ...restProps } = props;
return (
);
},
);
export { EditTextMessage, ComposedEditTextMessage };
diff --git a/web/chat/failed-send.react.js b/web/chat/failed-send.react.js
index 760de2711..dc2d08667 100644
--- a/web/chat/failed-send.react.js
+++ b/web/chat/failed-send.react.js
@@ -1,163 +1,162 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { messageID } from 'lib/shared/message-utils.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import {
type RawComposableMessageInfo,
assertComposableMessageType,
} from 'lib/types/message-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { LegacyThreadInfo, ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-message-list.css';
import multimediaMessageSendFailed from './multimedia-message-send-failed.js';
import textMessageSendFailed from './text-message-send-failed.js';
import Button from '../components/button.react.js';
import { type InputState, InputStateContext } from '../input/input-state.js';
import { useSelector } from '../redux/redux-utils.js';
type BaseProps = {
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
type Props = {
...BaseProps,
+rawMessageInfo: RawComposableMessageInfo,
+inputState: ?InputState,
+parentThreadInfo: ?LegacyThreadInfo,
};
class FailedSend extends React.PureComponent {
retryingText = false;
retryingMedia = false;
componentDidUpdate(prevProps: Props) {
if (
(this.props.rawMessageInfo.type === messageTypes.IMAGES ||
this.props.rawMessageInfo.type === messageTypes.MULTIMEDIA) &&
(prevProps.rawMessageInfo.type === messageTypes.IMAGES ||
prevProps.rawMessageInfo.type === messageTypes.MULTIMEDIA)
) {
const { inputState } = this.props;
const prevInputState = prevProps.inputState;
invariant(
inputState && prevInputState,
'inputState should be set in FailedSend',
);
const isFailed = multimediaMessageSendFailed(this.props.item, inputState);
const wasFailed = multimediaMessageSendFailed(
prevProps.item,
prevInputState,
);
const isDone =
this.props.item.messageInfo.id !== null &&
this.props.item.messageInfo.id !== undefined;
const wasDone =
prevProps.item.messageInfo.id !== null &&
prevProps.item.messageInfo.id !== undefined;
if ((isFailed && !wasFailed) || (isDone && !wasDone)) {
this.retryingMedia = false;
}
} else if (
this.props.rawMessageInfo.type === messageTypes.TEXT &&
prevProps.rawMessageInfo.type === messageTypes.TEXT
) {
const isFailed = textMessageSendFailed(this.props.item);
const wasFailed = textMessageSendFailed(prevProps.item);
const isDone =
this.props.item.messageInfo.id !== null &&
this.props.item.messageInfo.id !== undefined;
const wasDone =
prevProps.item.messageInfo.id !== null &&
prevProps.item.messageInfo.id !== undefined;
if ((isFailed && !wasFailed) || (isDone && !wasDone)) {
this.retryingText = false;
}
}
}
render(): React.Node {
return (
Delivery failed.
Retry?
);
}
retrySend = () => {
const { inputState } = this.props;
invariant(inputState, 'inputState should be set in FailedSend');
const { rawMessageInfo } = this.props;
if (rawMessageInfo.type === messageTypes.TEXT) {
if (this.retryingText) {
return;
}
this.retryingText = true;
inputState.sendTextMessage(
{
...rawMessageInfo,
time: Date.now(),
},
this.props.threadInfo,
this.props.parentThreadInfo,
);
} else if (
rawMessageInfo.type === messageTypes.IMAGES ||
rawMessageInfo.type === messageTypes.MULTIMEDIA
) {
const { localID } = rawMessageInfo;
invariant(localID, 'failed RawMessageInfo should have localID');
if (this.retryingMedia) {
return;
}
this.retryingMedia = true;
inputState.retryMultimediaMessage(localID, this.props.threadInfo);
}
};
}
const ConnectedFailedSend: React.ComponentType =
React.memo(function ConnectedFailedSend(props) {
const { messageInfo } = props.item;
assertComposableMessageType(messageInfo.type);
const id = messageID(messageInfo);
const rawMessageInfo = useSelector(
state => state.messageStore.messages[id],
);
assertComposableMessageType(rawMessageInfo.type);
invariant(
rawMessageInfo.type === messageTypes.TEXT ||
rawMessageInfo.type === messageTypes.IMAGES ||
rawMessageInfo.type === messageTypes.MULTIMEDIA,
'FailedSend should only be used for composable message types',
);
const inputState = React.useContext(InputStateContext);
const { parentThreadID } = props.threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
return (
);
});
export default ConnectedFailedSend;
diff --git a/web/chat/inline-engagement.react.js b/web/chat/inline-engagement.react.js
index eddc7f265..efb1cf75b 100644
--- a/web/chat/inline-engagement.react.js
+++ b/web/chat/inline-engagement.react.js
@@ -1,115 +1,114 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import type { ReactionInfo } from 'lib/selectors/chat-selectors.js';
import { getInlineEngagementSidebarText } from 'lib/shared/inline-engagement-utils.js';
import type { MessageInfo } from 'lib/types/message-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import css from './inline-engagement.css';
import ReactionPill from './reaction-pill.react.js';
import CommIcon from '../CommIcon.react.js';
import { useOnClickThread } from '../selectors/thread-selectors.js';
type Props = {
+messageInfo: MessageInfo,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
- +sidebarThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+ +sidebarThreadInfo: ?ThreadInfo,
+reactions: ReactionInfo,
+positioning: 'left' | 'center' | 'right',
+label?: ?string,
};
function InlineEngagement(props: Props): React.Node {
const {
messageInfo,
threadInfo,
sidebarThreadInfo,
reactions,
positioning,
label,
} = props;
const { popModal } = useModalContext();
const isLeft = positioning === 'left';
const labelClasses = classNames({
[css.messageLabel]: true,
[css.messageLabelLeft]: isLeft,
[css.messageLabelRight]: !isLeft,
});
const editedLabel = React.useMemo(() => {
if (!label) {
return null;
}
return (
{label}
);
}, [label, labelClasses]);
const onClickSidebarInner = useOnClickThread(sidebarThreadInfo);
const onClickSidebar = React.useCallback(
(event: SyntheticEvent) => {
popModal();
onClickSidebarInner(event);
},
[popModal, onClickSidebarInner],
);
const repliesText = getInlineEngagementSidebarText(sidebarThreadInfo);
const sidebarItem = React.useMemo(() => {
if (!sidebarThreadInfo || !repliesText) {
return null;
}
return (
{repliesText}
);
}, [sidebarThreadInfo, repliesText, onClickSidebar]);
const reactionsList = React.useMemo(() => {
if (Object.keys(reactions).length === 0) {
return null;
}
return Object.keys(reactions).map(reaction => (
));
}, [reactions, messageInfo.id, threadInfo.id]);
const containerClasses = classNames([
css.inlineEngagementContainer,
{
[css.leftContainer]: positioning === 'left',
[css.centerContainer]: positioning === 'center',
[css.rightContainer]: positioning === 'right',
},
]);
return (
{editedLabel}
{sidebarItem}
{reactionsList}
);
}
export default InlineEngagement;
diff --git a/web/chat/message-tooltip.react.js b/web/chat/message-tooltip.react.js
index 8cf2ef006..81238e032 100644
--- a/web/chat/message-tooltip.react.js
+++ b/web/chat/message-tooltip.react.js
@@ -1,233 +1,232 @@
// @flow
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import classNames from 'classnames';
import * as React from 'react';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { useNextLocalID } from 'lib/shared/message-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import {
tooltipButtonStyle,
tooltipLabelStyle,
tooltipStyle,
} from './chat-constants.js';
import css from './message-tooltip.css';
import {
useSendReaction,
getEmojiKeyboardPosition,
} from './reaction-message-utils.js';
import { useTooltipContext } from './tooltip-provider.js';
import type {
MessageTooltipAction,
TooltipSize,
TooltipPositionStyle,
} from '../utils/tooltip-utils.js';
type MessageTooltipProps = {
+actions: $ReadOnlyArray,
+messageTimestamp: string,
+tooltipPositionStyle: TooltipPositionStyle,
+tooltipSize: TooltipSize,
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function MessageTooltip(props: MessageTooltipProps): React.Node {
const {
actions,
messageTimestamp,
tooltipPositionStyle,
tooltipSize,
item,
threadInfo,
} = props;
const { messageInfo, reactions } = item;
const { alignment = 'left' } = tooltipPositionStyle;
const [activeTooltipLabel, setActiveTooltipLabel] = React.useState();
const { shouldRenderEmojiKeyboard } = useTooltipContext();
// emoji-mart actually doesn't render its contents until a useEffect runs:
// https://github.com/missive/emoji-mart/blob/d29728f7b4e295e46f9b64aa80335aa4a3c15b8e/packages/emoji-mart-react/react.tsx#L13-L19
// We need to measure the width/height of the picker, but because of this we
// need to do the measurement in our own useEffect, in order to guarantee it
// runs after emoji-mart's useEffect. To do this, we have to define two pieces
// of React state:
// - emojiKeyboardNode, which will get set by the emoji keyboard's ref and
// will trigger our useEffect
// - emojiKeyboardRenderedNode, which will get set in that useEffect and will
// trigger the rerendering of this component with the correct height/width
const [emojiKeyboardNode, setEmojiKeyboardNode] =
React.useState(null);
const [emojiKeyboardRenderedNode, setEmojiKeyboardRenderedNode] =
React.useState(null);
React.useEffect(() => {
if (emojiKeyboardNode) {
// It would be more simple to just call getEmojiKeyboardPosition
// immediately here, but some quirk of emoji-mart causes the width of the
// node to be 0 here. If instead we wait until the next render of this
// component to check the width, it ends up being set correctly.
setEmojiKeyboardRenderedNode(emojiKeyboardNode);
}
}, [emojiKeyboardNode]);
const messageActionButtonsContainerClassName = classNames(
css.messageActionContainer,
css.messageActionButtons,
);
const messageTooltipButtonStyle = React.useMemo(() => tooltipButtonStyle, []);
const tooltipButtons = React.useMemo(() => {
if (!actions || actions.length === 0) {
return null;
}
const buttons = actions.map(({ label, onClick, actionButtonContent }) => {
const onMouseEnter = () => {
setActiveTooltipLabel(label);
};
const onMouseLeave = () =>
setActiveTooltipLabel(oldLabel =>
label === oldLabel ? null : oldLabel,
);
return (
{actionButtonContent}
);
});
return (
{buttons}
);
}, [
actions,
messageActionButtonsContainerClassName,
messageTooltipButtonStyle,
]);
const messageTooltipLabelStyle = React.useMemo(() => tooltipLabelStyle, []);
const messageTooltipTopLabelStyle = React.useMemo(
() => ({
height: `${tooltipLabelStyle.height + 2 * tooltipLabelStyle.padding}px`,
}),
[],
);
const tooltipLabel = React.useMemo(() => {
if (!activeTooltipLabel) {
return null;
}
return (
{activeTooltipLabel}
);
}, [activeTooltipLabel, messageTooltipLabelStyle]);
const tooltipTimestamp = React.useMemo(() => {
if (!messageTimestamp) {
return null;
}
return (
{messageTimestamp}
);
}, [messageTimestamp, messageTooltipLabelStyle]);
const emojiKeyboardPosition = React.useMemo(
() =>
getEmojiKeyboardPosition(
emojiKeyboardRenderedNode,
tooltipPositionStyle,
tooltipSize,
),
[emojiKeyboardRenderedNode, tooltipPositionStyle, tooltipSize],
);
const emojiKeyboardPositionStyle = React.useMemo(() => {
if (!emojiKeyboardPosition) {
return null;
}
return {
bottom: emojiKeyboardPosition.bottom,
left: emojiKeyboardPosition.left,
};
}, [emojiKeyboardPosition]);
const localID = useNextLocalID();
const sendReaction = useSendReaction(
messageInfo.id,
localID,
threadInfo.id,
reactions,
);
const onEmojiSelect = React.useCallback(
(emoji: { +native: string, ... }) => {
const reactionInput = emoji.native;
sendReaction(reactionInput);
},
[sendReaction],
);
const emojiKeyboard = React.useMemo(() => {
if (!shouldRenderEmojiKeyboard) {
return null;
}
return (
);
}, [emojiKeyboardPositionStyle, onEmojiSelect, shouldRenderEmojiKeyboard]);
const messageTooltipContainerStyle = React.useMemo(() => tooltipStyle, []);
const containerClassName = classNames({
[css.messageTooltipContainer]: true,
[css.leftTooltipAlign]: alignment === 'left',
[css.centerTooltipAlign]: alignment === 'center',
[css.rightTooltipAlign]: alignment === 'right',
});
return (
<>
{emojiKeyboard}
{tooltipLabel}
{tooltipButtons}
{tooltipTimestamp}
>
);
}
export default MessageTooltip;
diff --git a/web/chat/message.react.js b/web/chat/message.react.js
index c90c82d03..65cb7be86 100644
--- a/web/chat/message.react.js
+++ b/web/chat/message.react.js
@@ -1,81 +1,80 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import { longAbsoluteDate } from 'lib/utils/date-utils.js';
import css from './chat-message-list.css';
import { useEditModalContext } from './edit-message-provider.js';
import { ComposedEditTextMessage } from './edit-text-message.react.js';
import MultimediaMessage from './multimedia-message.react.js';
import RobotextMessage from './robotext-message.react.js';
import TextMessage from './text-message.react.js';
type Props = {
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+shouldDisplayPinIndicator: boolean,
};
function Message(props: Props): React.Node {
const { item } = props;
let conversationHeader = null;
if (item.startsConversation) {
conversationHeader = (
{longAbsoluteDate(item.messageInfo.time)}
);
}
const { editState } = useEditModalContext();
let message;
if (
item.messageInfo.id &&
editState?.messageInfo.messageInfo?.id === item.messageInfo.id
) {
message = (
);
} else if (item.messageInfo.type === messageTypes.TEXT) {
message = (
);
} else if (
item.messageInfo.type === messageTypes.IMAGES ||
item.messageInfo.type === messageTypes.MULTIMEDIA
) {
message = (
);
} else {
invariant(item.robotext, "Flow can't handle our fancy types :(");
message = ;
}
return (
{conversationHeader}
{message}
);
}
export default Message;
diff --git a/web/chat/multimedia-message.react.js b/web/chat/multimedia-message.react.js
index 678b8f897..339bc3a72 100644
--- a/web/chat/multimedia-message.react.js
+++ b/web/chat/multimedia-message.react.js
@@ -1,111 +1,110 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
encryptedMediaBlobURI,
encryptedVideoThumbnailBlobURI,
} from 'lib/media/media-utils.js';
import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-message-list.css';
import ComposedMessage from './composed-message.react.js';
import sendFailed from './multimedia-message-send-failed.js';
import { type InputState, InputStateContext } from '../input/input-state.js';
import Multimedia from '../media/multimedia.react.js';
type BaseProps = {
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+shouldDisplayPinIndicator: boolean,
};
type Props = {
...BaseProps,
// withInputState
+inputState: ?InputState,
};
class MultimediaMessage extends React.PureComponent {
render(): React.Node {
const { item, inputState } = this.props;
invariant(
item.messageInfo.type === messageTypes.IMAGES ||
item.messageInfo.type === messageTypes.MULTIMEDIA,
'MultimediaMessage should only be used for multimedia messages',
);
const { localID, media } = item.messageInfo;
invariant(inputState, 'inputState should be set in MultimediaMessage');
const pendingUploads = localID ? inputState.assignedUploads[localID] : null;
const multimedia = [];
for (const singleMedia of media) {
const pendingUpload = pendingUploads
? pendingUploads.find(upload => upload.localID === singleMedia.id)
: null;
const thumbHash = singleMedia.thumbHash ?? singleMedia.thumbnailThumbHash;
let mediaSource;
if (singleMedia.type === 'photo' || singleMedia.type === 'video') {
const { type, uri, thumbnailURI, dimensions } = singleMedia;
mediaSource = { type, uri, thumbHash, thumbnailURI, dimensions };
} else {
const { type, encryptionKey, thumbnailEncryptionKey, dimensions } =
singleMedia;
const blobURI = encryptedMediaBlobURI(singleMedia);
const thumbnailBlobURI =
singleMedia.type === 'encrypted_video'
? encryptedVideoThumbnailBlobURI(singleMedia)
: null;
mediaSource = {
type,
blobURI,
encryptionKey,
thumbnailBlobURI,
thumbnailEncryptionKey,
dimensions,
thumbHash,
};
}
multimedia.push(
,
);
}
invariant(multimedia.length > 0, 'should be at least one multimedia...');
const content =
multimedia.length > 1 ? (
{multimedia}
) : (
multimedia
);
return (
1}
borderRadius={16}
>
{content}
);
}
}
const ConnectedMultimediaMessage: React.ComponentType =
React.memo(function ConnectedMultimediaMessage(props) {
const inputState = React.useContext(InputStateContext);
return ;
});
export default ConnectedMultimediaMessage;
diff --git a/web/chat/relationship-prompt/relationship-prompt.js b/web/chat/relationship-prompt/relationship-prompt.js
index 78e94d920..57b502a98 100644
--- a/web/chat/relationship-prompt/relationship-prompt.js
+++ b/web/chat/relationship-prompt/relationship-prompt.js
@@ -1,111 +1,110 @@
// @flow
import {
faUserMinus,
faUserPlus,
faUserShield,
faUserSlash,
} from '@fortawesome/free-solid-svg-icons';
import * as React from 'react';
import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { userRelationshipStatus } from 'lib/types/relationship-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import RelationshipPromptButtonContainer from './relationship-prompt-button-container.js';
import RelationshipPromptButton from './relationship-prompt-button.js';
import { buttonThemes } from '../../components/button.react.js';
-type Props = { +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo };
+type Props = { +threadInfo: ThreadInfo };
function RelationshipPrompt(props: Props) {
const { threadInfo } = props;
const {
otherUserInfo,
callbacks: { blockUser, unblockUser, friendUser, unfriendUser },
} = useRelationshipPrompt(threadInfo);
if (!otherUserInfo?.username) {
return null;
}
const relationshipStatus = otherUserInfo.relationshipStatus;
if (relationshipStatus === userRelationshipStatus.FRIEND) {
return null;
} else if (relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER) {
return (
);
} else if (
relationshipStatus === userRelationshipStatus.BOTH_BLOCKED ||
relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER
) {
return (
);
} else if (relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED) {
return (
);
} else if (relationshipStatus === userRelationshipStatus.REQUEST_SENT) {
return (
);
} else {
return (
);
}
}
const MemoizedRelationshipPrompt: React.ComponentType =
React.memo(RelationshipPrompt);
export default MemoizedRelationshipPrompt;
diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js
index 17e55711c..b680397d5 100644
--- a/web/chat/robotext-message.react.js
+++ b/web/chat/robotext-message.react.js
@@ -1,164 +1,163 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import {
entityTextToReact,
useENSNamesForEntityText,
} from 'lib/utils/entity-text.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import InlineEngagement from './inline-engagement.react.js';
import css from './robotext-message.css';
import Markdown from '../markdown/markdown.react.js';
import { linkRules } from '../markdown/rules.react.js';
import { usePushUserProfileModal } from '../modals/user-profile/user-profile-utils.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import { useMessageTooltip } from '../utils/tooltip-action-utils.js';
import { tooltipPositions } from '../utils/tooltip-utils.js';
const availableTooltipPositionsForRobotext = [
tooltipPositions.LEFT,
tooltipPositions.LEFT_TOP,
tooltipPositions.LEFT_BOTTOM,
tooltipPositions.RIGHT,
tooltipPositions.RIGHT_TOP,
tooltipPositions.RIGHT_BOTTOM,
];
type Props = {
+item: RobotextChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function RobotextMessage(props: Props): React.Node {
let inlineEngagement;
const { item, threadInfo } = props;
const { threadCreatedFromMessage, reactions } = item;
if (threadCreatedFromMessage || Object.keys(reactions).length > 0) {
inlineEngagement = (
);
}
const { messageInfo, robotext } = item;
const { threadID } = messageInfo;
const robotextWithENSNames = useENSNamesForEntityText(robotext);
invariant(
robotextWithENSNames,
'useENSNamesForEntityText only returns falsey when passed falsey',
);
const textParts = React.useMemo(() => {
return entityTextToReact(robotextWithENSNames, threadID, {
// eslint-disable-next-line react/display-name
renderText: ({ text }) => (
{text}
),
// eslint-disable-next-line react/display-name
renderThread: ({ id, name }) => ,
// eslint-disable-next-line react/display-name
renderUser: ({ userID, usernameText }) => (
),
// eslint-disable-next-line react/display-name
renderColor: ({ hex }) => ,
});
}, [robotextWithENSNames, threadID]);
const { onMouseEnter, onMouseLeave } = useMessageTooltip({
item,
threadInfo,
availablePositions: availableTooltipPositionsForRobotext,
});
return (
{textParts}
{inlineEngagement}
);
}
type BaseInnerThreadEntityProps = {
+id: string,
+name: string,
};
type InnerThreadEntityProps = {
...BaseInnerThreadEntityProps,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+dispatch: Dispatch,
};
class InnerThreadEntity extends React.PureComponent {
render(): React.Node {
return {this.props.name} ;
}
onClickThread = (event: SyntheticEvent) => {
event.preventDefault();
const id = this.props.id;
this.props.dispatch({
type: updateNavInfoActionType,
payload: {
activeChatThreadID: id,
},
});
};
}
const ThreadEntity = React.memo(
function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) {
const { id } = props;
const threadInfo = useSelector(state => threadInfoSelector(state)[id]);
const dispatch = useDispatch();
return (
);
},
);
type UserEntityProps = {
+userID: string,
+usernameText: string,
};
function UserEntity(props: UserEntityProps) {
const { userID, usernameText } = props;
const pushUserProfileModal = usePushUserProfileModal(userID);
return {usernameText} ;
}
function ColorEntity(props: { color: string }) {
const colorStyle = { color: props.color };
return {props.color} ;
}
const MemoizedRobotextMessage: React.ComponentType =
React.memo(RobotextMessage);
export default MemoizedRobotextMessage;
diff --git a/web/chat/text-message.react.js b/web/chat/text-message.react.js
index 2d81d9812..e052e9b60 100644
--- a/web/chat/text-message.react.js
+++ b/web/chat/text-message.react.js
@@ -1,70 +1,69 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { colorIsDark } from 'lib/shared/color-utils.js';
import { onlyEmojiRegex } from 'lib/shared/emojis.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-message-list.css';
import ComposedMessage from './composed-message.react.js';
import { MessageListContext } from './message-list-types.js';
import textMessageSendFailed from './text-message-send-failed.js';
import Markdown from '../markdown/markdown.react.js';
type Props = {
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+shouldDisplayPinIndicator: boolean,
};
function TextMessage(props: Props): React.Node {
invariant(
props.item.messageInfo.type === messageTypes.TEXT,
'TextMessage should only be used for messageTypes.TEXT',
);
const {
text,
creator: { isViewer },
} = props.item.messageInfo;
const messageStyle: { backgroundColor?: string } = {};
let darkColor = true;
if (isViewer) {
const threadColor = props.threadInfo.color;
darkColor = colorIsDark(threadColor);
messageStyle.backgroundColor = `#${threadColor}`;
}
const onlyEmoji = onlyEmojiRegex.test(text);
const messageClassName = classNames({
[css.textMessage]: true,
[css.textMessageDefaultBackground]: !isViewer,
[css.normalTextMessage]: !onlyEmoji,
[css.emojiOnlyTextMessage]: onlyEmoji,
[css.darkTextMessage]: darkColor,
[css.lightTextMessage]: !darkColor,
});
const messageListContext = React.useContext(MessageListContext);
invariant(messageListContext, 'DummyTextNode should have MessageListContext');
const rules = messageListContext.getTextMessageMarkdownRules(darkColor);
return (
{text}
);
}
export default TextMessage;
diff --git a/web/chat/thread-menu.react.js b/web/chat/thread-menu.react.js
index 93bd235d0..413ef5584 100644
--- a/web/chat/thread-menu.react.js
+++ b/web/chat/thread-menu.react.js
@@ -1,315 +1,314 @@
// @flow
import * as React from 'react';
import {
useLeaveThread,
leaveThreadActionTypes,
} from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js';
import { childThreadInfos } from 'lib/selectors/thread-selectors.js';
import {
threadHasPermission,
viewerIsMember,
threadIsChannel,
} from 'lib/shared/thread-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import css from './thread-menu.css';
import MenuItem from '../components/menu-item.react.js';
import Menu from '../components/menu.react.js';
import SidebarPromoteModal from '../modals/chat/sidebar-promote-modal.react.js';
import ConfirmLeaveThreadModal from '../modals/threads/confirm-leave-thread-modal.react.js';
import ComposeSubchannelModal from '../modals/threads/create/compose-subchannel-modal.react.js';
import ThreadSettingsMediaGalleryModal from '../modals/threads/gallery/thread-settings-media-gallery.react.js';
import ThreadMembersModal from '../modals/threads/members/members-modal.react.js';
import ThreadNotificationsModal from '../modals/threads/notifications/notifications-modal.react.js';
import ThreadSettingsModal from '../modals/threads/settings/thread-settings-modal.react.js';
import SidebarsModal from '../modals/threads/sidebars/sidebars-modal.react.js';
import SubchannelsModal from '../modals/threads/subchannels/subchannels-modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
type ThreadMenuProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function ThreadMenu(props: ThreadMenuProps): React.Node {
const { pushModal, popModal } = useModalContext();
const { threadInfo } = props;
const { onPromoteSidebar, canPromoteSidebar } = usePromoteSidebar(threadInfo);
const onClickSettings = React.useCallback(
() => pushModal( ),
[pushModal, threadInfo.id],
);
const settingsItem = React.useMemo(() => {
return (
);
}, [onClickSettings]);
const onClickMembers = React.useCallback(
() =>
pushModal(
,
),
[popModal, pushModal, threadInfo.id],
);
const membersItem = React.useMemo(() => {
if (threadInfo.type === threadTypes.PERSONAL) {
return null;
}
return (
);
}, [onClickMembers, threadInfo.type]);
const onClickThreadMediaGallery = React.useCallback(
() =>
pushModal(
,
),
[popModal, pushModal, threadInfo],
);
const threadMediaGalleryItem = React.useMemo(
() => (
),
[onClickThreadMediaGallery],
);
const childThreads = useSelector(
state => childThreadInfos(state)[threadInfo.id],
);
const hasSidebars = React.useMemo(() => {
return childThreads?.some(
childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR,
);
}, [childThreads]);
const onClickSidebars = React.useCallback(
() =>
pushModal( ),
[popModal, pushModal, threadInfo.id],
);
const sidebarItem = React.useMemo(() => {
if (!hasSidebars) {
return null;
}
return (
);
}, [hasSidebars, onClickSidebars]);
const canCreateSubchannels = React.useMemo(
() => threadHasPermission(threadInfo, threadPermissions.CREATE_SUBCHANNELS),
[threadInfo],
);
const hasSubchannels = React.useMemo(() => {
return !!childThreads?.some(threadIsChannel);
}, [childThreads]);
const onClickViewSubchannels = React.useCallback(
() =>
pushModal(
,
),
[popModal, pushModal, threadInfo.id],
);
const viewSubchannelsItem = React.useMemo(() => {
if (!hasSubchannels) {
return null;
}
return (
);
}, [hasSubchannels, onClickViewSubchannels]);
const onClickCreateSubchannel = React.useCallback(
() =>
pushModal(
,
),
[popModal, pushModal, threadInfo],
);
const createSubchannelsItem = React.useMemo(() => {
if (!canCreateSubchannels) {
return null;
}
return (
);
}, [canCreateSubchannels, onClickCreateSubchannel]);
const dispatchActionPromise = useDispatchActionPromise();
const callLeaveThread = useLeaveThread();
const onConfirmLeaveThread = React.useCallback(() => {
dispatchActionPromise(
leaveThreadActionTypes,
callLeaveThread({ threadID: threadInfo.id }),
);
popModal();
}, [callLeaveThread, popModal, dispatchActionPromise, threadInfo.id]);
const onClickLeaveThread = React.useCallback(
() =>
pushModal(
,
),
[popModal, onConfirmLeaveThread, pushModal, threadInfo],
);
const leaveThreadItem = React.useMemo(() => {
const canLeaveThread = threadHasPermission(
threadInfo,
threadPermissions.LEAVE_THREAD,
);
if (!viewerIsMember(threadInfo) || !canLeaveThread) {
return null;
}
return (
);
}, [onClickLeaveThread, threadInfo]);
const onClickPromoteSidebarToThread = React.useCallback(
() =>
pushModal(
,
),
[pushModal, threadInfo, popModal, onPromoteSidebar],
);
const promoteSidebar = React.useMemo(() => {
return (
);
}, [onClickPromoteSidebarToThread]);
const onClickNotifications = React.useCallback(() => {
pushModal(
,
);
}, [popModal, pushModal, threadInfo.id]);
const notificationsItem = React.useMemo(() => {
if (!viewerIsMember(threadInfo)) {
return null;
}
return (
);
}, [onClickNotifications, threadInfo]);
const menuItems = React.useMemo(() => {
const separator = ;
const items = [
settingsItem,
notificationsItem,
membersItem,
threadMediaGalleryItem,
sidebarItem,
viewSubchannelsItem,
createSubchannelsItem,
leaveThreadItem && separator,
canPromoteSidebar && promoteSidebar,
leaveThreadItem,
];
return items.filter(Boolean);
}, [
settingsItem,
notificationsItem,
membersItem,
threadMediaGalleryItem,
sidebarItem,
viewSubchannelsItem,
promoteSidebar,
createSubchannelsItem,
leaveThreadItem,
canPromoteSidebar,
]);
const icon = React.useMemo(
() => ,
[],
);
return {menuItems} ;
}
export default ThreadMenu;
diff --git a/web/chat/thread-top-bar.react.js b/web/chat/thread-top-bar.react.js
index 41280ccba..bcd7c80f1 100644
--- a/web/chat/thread-top-bar.react.js
+++ b/web/chat/thread-top-bar.react.js
@@ -1,105 +1,104 @@
// @flow
import * as React from 'react';
import { ChevronRight } from 'react-feather';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { threadIsPending } from 'lib/shared/thread-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import ThreadMenu from './thread-menu.react.js';
import css from './thread-top-bar.css';
import ThreadAvatar from '../avatars/thread-avatar.react.js';
import Button from '../components/button.react.js';
import { InputStateContext } from '../input/input-state.js';
import MessageResultsModal from '../modals/chat/message-results-modal.react.js';
import MessageSearchModal from '../modals/search/message-search-modal.react.js';
type ThreadTopBarProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function ThreadTopBar(props: ThreadTopBarProps): React.Node {
const { threadInfo } = props;
const { pushModal } = useModalContext();
let threadMenu = null;
if (!threadIsPending(threadInfo.id)) {
threadMenu = ;
}
// To allow the pinned messages modal to be re-used by the message search
// modal, it will be useful to make the modal accept a prop that defines it's
// name, instead of setting it directly in the modal.
const bannerText = React.useMemo(() => {
if (!threadInfo.pinnedCount || threadInfo.pinnedCount === 0) {
return '';
}
const messageNoun = threadInfo.pinnedCount === 1 ? 'message' : 'messages';
return `${threadInfo.pinnedCount} pinned ${messageNoun}`;
}, [threadInfo.pinnedCount]);
const inputState = React.useContext(InputStateContext);
const pushThreadPinsModal = React.useCallback(() => {
pushModal(
,
);
}, [pushModal, inputState, threadInfo, bannerText]);
const pinnedCountBanner = React.useMemo(() => {
if (!bannerText) {
return null;
}
return (
);
}, [bannerText, pushThreadPinsModal]);
const onClickSearch = React.useCallback(
() =>
pushModal(
,
),
[inputState, pushModal, threadInfo],
);
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
<>
{pinnedCountBanner}
>
);
}
export default ThreadTopBar;
diff --git a/web/components/message-result.react.js b/web/components/message-result.react.js
index d58173a55..db28d2f8d 100644
--- a/web/components/message-result.react.js
+++ b/web/components/message-result.react.js
@@ -1,71 +1,70 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
import { useStringForUser } from 'lib/hooks/ens-cache.js';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { longAbsoluteDate } from 'lib/utils/date-utils.js';
import css from './message-result.css';
import { MessageListContext } from '../chat/message-list-types.js';
import Message from '../chat/message.react.js';
import { useTextMessageRulesFunc } from '../markdown/rules.react.js';
type MessageResultProps = {
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+scrollable: boolean,
};
function MessageResult(props: MessageResultProps): React.Node {
const { item, threadInfo, scrollable } = props;
const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo);
const getTextMessageMarkdownRules = useTextMessageRulesFunc(
threadInfo,
chatMentionCandidates,
);
const messageListContext = React.useMemo(() => {
if (!getTextMessageMarkdownRules) {
return undefined;
}
return { getTextMessageMarkdownRules };
}, [getTextMessageMarkdownRules]);
const shouldShowUsername = !item.startsConversation && !item.startsCluster;
const username = useStringForUser(
shouldShowUsername ? item.messageInfo.creator : null,
);
const messageContainerClassNames = classNames({
[css.messageContainer]: true,
[css.messageContainerOverflow]: scrollable,
});
return (
{username}
{longAbsoluteDate(item.messageInfo.time)}
);
}
export default MessageResult;
diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js
index 625de1965..f32306d0d 100644
--- a/web/input/input-state-container.react.js
+++ b/web/input/input-state-container.react.js
@@ -1,1710 +1,1708 @@
// @flow
import invariant from 'invariant';
import _groupBy from 'lodash/fp/groupBy.js';
import _keyBy from 'lodash/fp/keyBy.js';
import _omit from 'lodash/fp/omit.js';
import _partition from 'lodash/fp/partition.js';
import _sortBy from 'lodash/fp/sortBy.js';
import _memoize from 'lodash/memoize.js';
import * as React from 'react';
import { createSelector } from 'reselect';
import {
createLocalMessageActionType,
sendMultimediaMessageActionTypes,
useLegacySendMultimediaMessage,
sendTextMessageActionTypes,
useSendTextMessage,
} from 'lib/actions/message-actions.js';
import type {
LegacySendMultimediaMessageInput,
SendTextMessageInput,
} from 'lib/actions/message-actions.js';
import { queueReportsActionType } from 'lib/actions/report-actions.js';
import { useNewThread } from 'lib/actions/thread-actions.js';
import {
uploadMultimedia,
updateMultimediaMessageMediaActionType,
useDeleteUpload,
useBlobServiceUpload,
type MultimediaUploadCallbacks,
type MultimediaUploadExtras,
type BlobServiceUploadAction,
type DeleteUploadInput,
} from 'lib/actions/upload-actions.js';
import {
useModalContext,
type PushModal,
} from 'lib/components/modal-provider.react.js';
import blobService from 'lib/facts/blob-service.js';
import commStaffCommunity from 'lib/facts/comm-staff-community.js';
import { getNextLocalUploadID } from 'lib/media/media-utils.js';
import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors.js';
import {
createMediaMessageInfo,
localIDPrefix,
useMessageCreationSideEffectsFunc,
} from 'lib/shared/message-utils.js';
import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js';
import {
createRealThreadFromPendingThread,
draftKeyFromThreadID,
threadIsPending,
threadIsPendingSidebar,
patchThreadInfoToIncludeMentionedMembersOfParent,
threadInfoInsideCommunity,
} from 'lib/shared/thread-utils.js';
import type { CalendarQuery } from 'lib/types/entry-types.js';
import type {
UploadMultimediaResult,
MediaMissionStep,
MediaMissionFailure,
MediaMissionResult,
MediaMission,
} from 'lib/types/media-types.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import {
type RawMessageInfo,
type RawMultimediaMessageInfo,
type SendMessageResult,
type SendMessagePayload,
} from 'lib/types/message-types.js';
import type { RawImagesMessageInfo } from 'lib/types/messages/images.js';
import type { RawMediaMessageInfo } from 'lib/types/messages/media.js';
import type { RawTextMessageInfo } from 'lib/types/messages/text.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import { reportTypes } from 'lib/types/report-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
import {
type ClientNewThreadRequest,
type NewThreadResult,
type LegacyThreadInfo,
+ type ThreadInfo,
} from 'lib/types/thread-types.js';
import {
type DispatchActionPromise,
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import {
makeBlobServiceEndpointURL,
isBlobServiceURI,
blobHashFromBlobServiceURI,
} from 'lib/utils/blob-service.js';
import { getConfig } from 'lib/utils/config.js';
import { getMessageForException, cloneError } from 'lib/utils/errors.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { generateReportID } from 'lib/utils/report-utils.js';
import {
type PendingMultimediaUpload,
type TypeaheadState,
InputStateContext,
type BaseInputState,
type TypeaheadInputState,
type InputState,
} from './input-state.js';
import { encryptFile } from '../media/encryption-utils.js';
import { generateThumbHash } from '../media/image-utils.js';
import {
preloadMediaResource,
validateFile,
preloadImage,
} from '../media/media-utils.js';
import InvalidUploadModal from '../modals/chat/invalid-upload.react.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js';
type CombinedInputState = {
+inputBaseState: BaseInputState,
+typeaheadState: TypeaheadInputState,
};
type BaseProps = {
+children: React.Node,
};
type Props = {
...BaseProps,
+activeChatThreadID: ?string,
+drafts: { +[key: string]: string },
+viewerID: ?string,
+messageStoreMessages: { +[id: string]: RawMessageInfo },
+pendingRealizedThreadIDs: $ReadOnlyMap,
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
+calendarQuery: () => CalendarQuery,
+uploadMultimedia: (
multimedia: Object,
extras: MultimediaUploadExtras,
callbacks: MultimediaUploadCallbacks,
) => Promise,
+blobServiceUpload: BlobServiceUploadAction,
+deleteUpload: (input: DeleteUploadInput) => Promise,
+sendMultimediaMessage: (
input: LegacySendMultimediaMessageInput,
) => Promise,
+sendTextMessage: (input: SendTextMessageInput) => Promise,
+newThread: (request: ClientNewThreadRequest) => Promise,
+pushModal: PushModal,
+sendCallbacks: $ReadOnlyArray<() => mixed>,
+registerSendCallback: (() => mixed) => void,
+unregisterSendCallback: (() => mixed) => void,
+textMessageCreationSideEffectsFunc: CreationSideEffectsFunc,
};
type WritableState = {
pendingUploads: {
[threadID: string]: { [localUploadID: string]: PendingMultimediaUpload },
},
textCursorPositions: { [threadID: string]: number },
typeaheadState: TypeaheadState,
};
type State = $ReadOnly;
type PropsAndState = {
...Props,
...State,
};
class InputStateContainer extends React.PureComponent {
state: State = {
pendingUploads: {},
textCursorPositions: {},
typeaheadState: {
canBeVisible: false,
keepUpdatingThreadMembers: true,
frozenUserMentionsCandidates: [],
frozenChatMentionsCandidates: {},
moveChoiceUp: null,
moveChoiceDown: null,
close: null,
accept: null,
},
};
replyCallbacks: Array<(message: string) => void> = [];
pendingThreadCreations: Map> = new Map<
string,
Promise,
>();
// TODO: flip the switch
// Note that this enables Blob service for encrypted media only
useBlobServiceUploads = false;
// When the user sends a multimedia message that triggers the creation of a
// sidebar, the sidebar gets created right away, but the message needs to wait
// for the uploads to complete before sending. We use this Set to track the
// message localIDs that need sidebarCreation: true.
pendingSidebarCreationMessageLocalIDs: Set = new Set();
static reassignToRealizedThreads(
state: { +[threadID: string]: T },
props: Props,
): ?{ [threadID: string]: T } {
const newState: { [string]: T } = {};
let updated = false;
for (const threadID in state) {
const newThreadID =
props.pendingRealizedThreadIDs.get(threadID) ?? threadID;
if (newThreadID !== threadID) {
updated = true;
}
newState[newThreadID] = state[threadID];
}
return updated ? newState : null;
}
static getDerivedStateFromProps(props: Props, state: State): ?Partial {
const pendingUploads = InputStateContainer.reassignToRealizedThreads(
state.pendingUploads,
props,
);
const textCursorPositions = InputStateContainer.reassignToRealizedThreads(
state.textCursorPositions,
props,
);
if (!pendingUploads && !textCursorPositions) {
return null;
}
const stateUpdate: Partial = {};
if (pendingUploads) {
stateUpdate.pendingUploads = pendingUploads;
}
if (textCursorPositions) {
stateUpdate.textCursorPositions = textCursorPositions;
}
return stateUpdate;
}
static completedMessageIDs(state: State): Set {
const completed = new Map();
for (const threadID in state.pendingUploads) {
const pendingUploads = state.pendingUploads[threadID];
for (const localUploadID in pendingUploads) {
const upload = pendingUploads[localUploadID];
const { messageID, serverID, failed } = upload;
if (!messageID || !messageID.startsWith(localIDPrefix)) {
continue;
}
if (!serverID || failed) {
completed.set(messageID, false);
continue;
}
if (completed.get(messageID) === undefined) {
completed.set(messageID, true);
}
}
}
const messageIDs = new Set();
for (const [messageID, isCompleted] of completed) {
if (isCompleted) {
messageIDs.add(messageID);
}
}
return messageIDs;
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.props.viewerID !== prevProps.viewerID) {
this.setState({ pendingUploads: {} });
return;
}
const previouslyAssignedMessageIDs = new Set();
for (const threadID in prevState.pendingUploads) {
const pendingUploads = prevState.pendingUploads[threadID];
for (const localUploadID in pendingUploads) {
const { messageID } = pendingUploads[localUploadID];
if (messageID) {
previouslyAssignedMessageIDs.add(messageID);
}
}
}
const newlyAssignedUploads = new Map<
string,
{
+threadID: string,
+shouldEncrypt: boolean,
+uploads: PendingMultimediaUpload[],
},
>();
for (const threadID in this.state.pendingUploads) {
const pendingUploads = this.state.pendingUploads[threadID];
for (const localUploadID in pendingUploads) {
const upload = pendingUploads[localUploadID];
const { messageID } = upload;
if (
!messageID ||
!messageID.startsWith(localIDPrefix) ||
previouslyAssignedMessageIDs.has(messageID)
) {
continue;
}
const { shouldEncrypt } = upload;
let assignedUploads = newlyAssignedUploads.get(messageID);
if (!assignedUploads) {
assignedUploads = { threadID, shouldEncrypt, uploads: [] };
newlyAssignedUploads.set(messageID, assignedUploads);
}
if (shouldEncrypt !== assignedUploads.shouldEncrypt) {
console.warn(
`skipping upload ${localUploadID} ` +
"because shouldEncrypt doesn't match",
);
continue;
}
assignedUploads.uploads.push(upload);
}
}
const newMessageInfos = new Map();
for (const [messageID, assignedUploads] of newlyAssignedUploads) {
const { uploads, threadID, shouldEncrypt } = assignedUploads;
const creatorID = this.props.viewerID;
invariant(creatorID, 'need viewer ID in order to send a message');
const media = uploads.map(
({
localID,
serverID,
uri,
mediaType,
dimensions,
encryptionKey,
thumbHash,
}) => {
// We can get into this state where dimensions are null if the user is
// uploading a file type that the browser can't render. In that case
// we fake the dimensions here while we wait for the server to tell us
// the true dimensions.
const shimmedDimensions = dimensions ?? { height: 0, width: 0 };
invariant(
mediaType === 'photo' || mediaType === 'encrypted_photo',
"web InputStateContainer can't handle video",
);
if (
mediaType !== 'encrypted_photo' &&
mediaType !== 'encrypted_video'
) {
return {
id: serverID ? serverID : localID,
uri,
type: 'photo',
dimensions: shimmedDimensions,
thumbHash,
};
}
invariant(
encryptionKey,
'encrypted media must have an encryption key',
);
return {
id: serverID ? serverID : localID,
blobURI: uri,
type: 'encrypted_photo',
encryptionKey,
dimensions: shimmedDimensions,
thumbHash,
};
},
);
const messageInfo = createMediaMessageInfo(
{
localID: messageID,
threadID,
creatorID,
media,
},
{ forceMultimediaMessageType: shouldEncrypt },
);
newMessageInfos.set(messageID, messageInfo);
}
const currentlyCompleted = InputStateContainer.completedMessageIDs(
this.state,
);
const previouslyCompleted =
InputStateContainer.completedMessageIDs(prevState);
for (const messageID of currentlyCompleted) {
if (previouslyCompleted.has(messageID)) {
continue;
}
let rawMessageInfo = newMessageInfos.get(messageID);
if (rawMessageInfo) {
newMessageInfos.delete(messageID);
} else {
rawMessageInfo = this.getRawMultimediaMessageInfo(messageID);
}
this.sendMultimediaMessage(rawMessageInfo);
}
for (const [, messageInfo] of newMessageInfos) {
this.props.dispatch({
type: createLocalMessageActionType,
payload: messageInfo,
});
}
}
getRawMultimediaMessageInfo(
localMessageID: string,
): RawMultimediaMessageInfo {
const rawMessageInfo = this.props.messageStoreMessages[localMessageID];
invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`);
invariant(
rawMessageInfo.type === messageTypes.IMAGES ||
rawMessageInfo.type === messageTypes.MULTIMEDIA,
`rawMessageInfo ${localMessageID} should be multimedia`,
);
return rawMessageInfo;
}
shouldEncryptMedia(threadInfo: LegacyThreadInfo): boolean {
return threadInfoInsideCommunity(threadInfo, commStaffCommunity.id);
}
async sendMultimediaMessage(
messageInfo: RawMultimediaMessageInfo,
): Promise {
if (!threadIsPending(messageInfo.threadID)) {
this.props.dispatchActionPromise(
sendMultimediaMessageActionTypes,
this.sendMultimediaMessageAction(messageInfo),
undefined,
messageInfo,
);
return;
}
this.props.dispatch({
type: sendMultimediaMessageActionTypes.started,
payload: messageInfo,
});
let newThreadID = null;
try {
const threadCreationPromise = this.pendingThreadCreations.get(
messageInfo.threadID,
);
if (!threadCreationPromise) {
// When we create or retry multimedia message, we add a promise to
// pendingThreadCreations map. This promise can be removed in
// sendMultimediaMessage and sendTextMessage methods. When any of these
// method remove the promise, it has to be settled. If the promise was
// fulfilled, this method would be called with realized thread, so we
// can conclude that the promise was rejected. We don't have enough info
// here to retry the thread creation, but we can mark the message as
// failed. Then the retry will be possible and promise will be created
// again.
throw new Error('Thread creation failed');
}
newThreadID = await threadCreationPromise;
} catch (e) {
const copy = cloneError(e);
copy.localID = messageInfo.localID;
copy.threadID = messageInfo.threadID;
this.props.dispatch({
type: sendMultimediaMessageActionTypes.failed,
payload: copy,
error: true,
});
return;
} finally {
this.pendingThreadCreations.delete(messageInfo.threadID);
}
// While the thread was being created, the image preload may have completed,
// and we might have a finalized URI now. So we fetch from Redux again
const { localID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should exist for locally-created RawMessageInfo',
);
const latestMessageInfo = this.getRawMultimediaMessageInfo(localID);
// Conditional is necessary for Flow
let newMessageInfo;
if (latestMessageInfo.type === messageTypes.MULTIMEDIA) {
newMessageInfo = {
...latestMessageInfo,
threadID: newThreadID,
time: Date.now(),
};
} else {
newMessageInfo = {
...latestMessageInfo,
threadID: newThreadID,
time: Date.now(),
};
}
this.props.dispatchActionPromise(
sendMultimediaMessageActionTypes,
this.sendMultimediaMessageAction(newMessageInfo),
undefined,
newMessageInfo,
);
}
async sendMultimediaMessageAction(
messageInfo: RawMultimediaMessageInfo,
): Promise {
const { localID, threadID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should be set',
);
const sidebarCreation =
this.pendingSidebarCreationMessageLocalIDs.has(localID);
const mediaIDs = [];
for (const { id } of messageInfo.media) {
mediaIDs.push(id);
}
try {
const result = await this.props.sendMultimediaMessage({
threadID,
localID,
mediaIDs,
sidebarCreation,
});
this.pendingSidebarCreationMessageLocalIDs.delete(localID);
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const prevUploads = prevState.pendingUploads[newThreadID];
const newUploads: { [string]: PendingMultimediaUpload } = {};
for (const localUploadID in prevUploads) {
const upload = prevUploads[localUploadID];
if (upload.messageID !== localID) {
newUploads[localUploadID] = upload;
} else if (!upload.uriIsReal) {
newUploads[localUploadID] = {
...upload,
messageID: result.id,
};
}
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newUploads,
},
};
});
return {
localID,
serverID: result.id,
threadID,
time: result.time,
interface: result.interface,
};
} catch (e) {
const copy = cloneError(e);
copy.localID = localID;
copy.threadID = threadID;
throw copy;
}
}
- startThreadCreation(
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
- ): Promise {
+ startThreadCreation(threadInfo: ThreadInfo): Promise {
if (!threadIsPending(threadInfo.id)) {
return Promise.resolve(threadInfo.id);
}
let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id);
if (!threadCreationPromise) {
const calendarQuery = this.props.calendarQuery();
threadCreationPromise = createRealThreadFromPendingThread({
threadInfo,
dispatchActionPromise: this.props.dispatchActionPromise,
createNewThread: this.props.newThread,
sourceMessageID: threadInfo.sourceMessageID,
viewerID: this.props.viewerID,
calendarQuery,
});
this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise);
}
return threadCreationPromise;
}
inputBaseStateSelector: (?string) => PropsAndState => BaseInputState =
_memoize((threadID: ?string) =>
createSelector(
(propsAndState: PropsAndState) =>
threadID ? propsAndState.pendingUploads[threadID] : null,
(propsAndState: PropsAndState) =>
threadID
? propsAndState.drafts[draftKeyFromThreadID(threadID)]
: null,
(propsAndState: PropsAndState) =>
threadID ? propsAndState.textCursorPositions[threadID] : null,
(
pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload },
draft: ?string,
textCursorPosition: ?number,
) => {
let threadPendingUploads: $ReadOnlyArray =
[];
const assignedUploads: {
[string]: $ReadOnlyArray,
} = {};
if (pendingUploads) {
const [uploadsWithMessageIDs, uploadsWithoutMessageIDs] =
_partition('messageID')(pendingUploads);
threadPendingUploads = _sortBy('localID')(uploadsWithoutMessageIDs);
const threadAssignedUploads = _groupBy('messageID')(
uploadsWithMessageIDs,
);
for (const messageID in threadAssignedUploads) {
// lodash libdefs don't return $ReadOnlyArray
assignedUploads[messageID] = [
...threadAssignedUploads[messageID],
];
}
}
return {
pendingUploads: threadPendingUploads,
assignedUploads,
draft: draft ?? '',
textCursorPosition: textCursorPosition ?? 0,
appendFiles: (
threadInfo: LegacyThreadInfo,
files: $ReadOnlyArray,
) => this.appendFiles(threadInfo, files),
cancelPendingUpload: (localUploadID: string) =>
this.cancelPendingUpload(threadID, localUploadID),
sendTextMessage: (
messageInfo: RawTextMessageInfo,
threadInfo: LegacyThreadInfo,
parentThreadInfo: ?LegacyThreadInfo,
) =>
this.sendTextMessage(messageInfo, threadInfo, parentThreadInfo),
createMultimediaMessage: (
localID: number,
threadInfo: LegacyThreadInfo,
) => this.createMultimediaMessage(localID, threadInfo),
setDraft: (newDraft: string) => this.setDraft(threadID, newDraft),
setTextCursorPosition: (newPosition: number) =>
this.setTextCursorPosition(threadID, newPosition),
messageHasUploadFailure: (localMessageID: string) =>
this.messageHasUploadFailure(assignedUploads[localMessageID]),
retryMultimediaMessage: (
localMessageID: string,
threadInfo: LegacyThreadInfo,
) =>
this.retryMultimediaMessage(
localMessageID,
threadInfo,
assignedUploads[localMessageID],
),
addReply: (message: string) => this.addReply(message),
addReplyListener: this.addReplyListener,
removeReplyListener: this.removeReplyListener,
registerSendCallback: this.props.registerSendCallback,
unregisterSendCallback: this.props.unregisterSendCallback,
};
},
),
);
typeaheadStateSelector: PropsAndState => TypeaheadInputState = createSelector(
(propsAndState: PropsAndState) => propsAndState.typeaheadState,
(typeaheadState: TypeaheadState) => ({
typeaheadState,
setTypeaheadState: this.setTypeaheadState,
}),
);
inputStateSelector: CombinedInputState => InputState = createSelector(
(state: CombinedInputState) => state.inputBaseState,
(state: CombinedInputState) => state.typeaheadState,
(inputBaseState: BaseInputState, typeaheadState: TypeaheadInputState) => ({
...inputBaseState,
...typeaheadState,
}),
);
getRealizedOrPendingThreadID(threadID: string): string {
return this.props.pendingRealizedThreadIDs.get(threadID) ?? threadID;
}
async appendFiles(
threadInfo: LegacyThreadInfo,
files: $ReadOnlyArray,
): Promise {
const selectionTime = Date.now();
const { pushModal } = this.props;
const appendResults = await Promise.all(
files.map(file => this.appendFile(threadInfo, file, selectionTime)),
);
if (appendResults.some(({ result }) => !result.success)) {
pushModal( );
const time = Date.now() - selectionTime;
const reports = [];
for (const appendResult of appendResults) {
const { steps } = appendResult;
let { result } = appendResult;
let uploadLocalID;
if (result.success) {
uploadLocalID = result.pendingUpload.localID;
result = { success: false, reason: 'web_sibling_validation_failed' };
}
const mediaMission = { steps, result, userTime: time, totalTime: time };
reports.push({ mediaMission, uploadLocalID });
}
this.queueMediaMissionReports(reports);
return false;
}
const newUploads = appendResults.map(({ result }) => {
invariant(result.success, 'any failed validation should be caught above');
return result.pendingUpload;
});
const newUploadsObject = _keyBy('localID')(newUploads);
this.setState(
prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id);
const prevUploads = prevState.pendingUploads[newThreadID];
const mergedUploads = prevUploads
? { ...prevUploads, ...newUploadsObject }
: newUploadsObject;
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: mergedUploads,
},
};
},
() => this.uploadFiles(threadInfo.id, newUploads),
);
return true;
}
async appendFile(
threadInfo: LegacyThreadInfo,
file: File,
selectTime: number,
): Promise<{
steps: $ReadOnlyArray,
result:
| MediaMissionFailure
| { success: true, pendingUpload: PendingMultimediaUpload },
}> {
const steps: MediaMissionStep[] = [
{
step: 'web_selection',
filename: file.name,
size: file.size,
mime: file.type,
selectTime,
},
];
let response;
const validationStart = Date.now();
try {
response = await validateFile(file);
} catch (e) {
return {
steps,
result: {
success: false,
reason: 'processing_exception',
time: Date.now() - validationStart,
exceptionMessage: getMessageForException(e),
},
};
}
const { steps: validationSteps, result } = response;
steps.push(...validationSteps);
if (!result.success) {
return { steps, result };
}
const { uri, file: fixedFile, mediaType, dimensions } = result;
const shouldEncrypt = this.shouldEncryptMedia(threadInfo);
let encryptionResult;
if (shouldEncrypt) {
let encryptionResponse;
const encryptionStart = Date.now();
try {
encryptionResponse = await encryptFile(fixedFile);
} catch (e) {
return {
steps,
result: {
success: false,
reason: 'encryption_exception',
time: Date.now() - encryptionStart,
exceptionMessage: getMessageForException(e),
},
};
}
steps.push(...encryptionResponse.steps);
encryptionResult = encryptionResponse.result;
}
if (encryptionResult && !encryptionResult.success) {
return { steps, result: encryptionResult };
}
const { steps: thumbHashSteps, result: thumbHashResult } =
await generateThumbHash(fixedFile, encryptionResult?.encryptionKey);
const thumbHash = thumbHashResult.success
? thumbHashResult.thumbHash
: null;
steps.push(...thumbHashSteps);
return {
steps,
result: {
success: true,
pendingUpload: {
localID: getNextLocalUploadID(),
serverID: null,
messageID: null,
failed: false,
file: encryptionResult?.file ?? fixedFile,
mediaType: encryptionResult ? 'encrypted_photo' : mediaType,
dimensions,
uri: encryptionResult?.uri ?? uri,
loop: false,
uriIsReal: false,
blobHolder: null,
blobHash: encryptionResult?.sha256Hash,
encryptionKey: encryptionResult?.encryptionKey,
thumbHash,
progressPercent: 0,
abort: null,
steps,
selectTime,
shouldEncrypt,
},
},
};
}
uploadFiles(
threadID: string,
uploads: $ReadOnlyArray,
): Promise {
return Promise.all(
uploads.map(upload => this.uploadFile(threadID, upload)),
);
}
async uploadFile(threadID: string, upload: PendingMultimediaUpload) {
const { selectTime, localID, encryptionKey } = upload;
const isEncrypted =
!!encryptionKey &&
(upload.mediaType === 'encrypted_photo' ||
upload.mediaType === 'encrypted_video');
const steps = [...upload.steps];
let userTime;
const sendReport = (missionResult: MediaMissionResult) => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const latestUpload = this.state.pendingUploads[newThreadID][localID];
invariant(
latestUpload,
`pendingUpload ${localID} for ${newThreadID} missing in sendReport`,
);
const { serverID, messageID } = latestUpload;
const totalTime = Date.now() - selectTime;
userTime = userTime ? userTime : totalTime;
const mission = { steps, result: missionResult, totalTime, userTime };
this.queueMediaMissionReports([
{
mediaMission: mission,
uploadLocalID: localID,
uploadServerID: serverID,
messageLocalID: messageID,
},
]);
};
let uploadResult, uploadExceptionMessage;
const uploadStart = Date.now();
try {
const callbacks = {
onProgress: (percent: number) =>
this.setProgress(threadID, localID, percent),
abortHandler: (abort: () => void) =>
this.handleAbortCallback(threadID, localID, abort),
};
if (
this.useBlobServiceUploads &&
(upload.mediaType === 'encrypted_photo' ||
upload.mediaType === 'encrypted_video')
) {
const { blobHash, dimensions, thumbHash } = upload;
invariant(
encryptionKey && blobHash && dimensions,
'incomplete encrypted upload',
);
uploadResult = await this.props.blobServiceUpload({
uploadInput: {
blobInput: {
type: 'file',
file: upload.file,
},
blobHash,
encryptionKey,
dimensions,
loop: false,
thumbHash,
},
keyserverOrThreadID: threadID,
callbacks,
});
} else {
let uploadExtras = {
...upload.dimensions,
loop: false,
thumbHash: upload.thumbHash,
};
if (encryptionKey) {
uploadExtras = { ...uploadExtras, encryptionKey };
}
uploadResult = await this.props.uploadMultimedia(
upload.file,
uploadExtras,
callbacks,
);
}
} catch (e) {
uploadExceptionMessage = getMessageForException(e);
this.handleUploadFailure(threadID, localID);
}
userTime = Date.now() - selectTime;
steps.push({
step: 'upload',
success: !!uploadResult,
exceptionMessage: uploadExceptionMessage,
time: Date.now() - uploadStart,
inputFilename: upload.file.name,
outputMediaType: uploadResult && uploadResult.mediaType,
outputURI: uploadResult && uploadResult.uri,
outputDimensions: uploadResult && uploadResult.dimensions,
outputLoop: uploadResult && uploadResult.loop,
});
if (!uploadResult) {
sendReport({
success: false,
reason: 'http_upload_failed',
exceptionMessage: uploadExceptionMessage,
});
return;
}
const result = uploadResult;
const outputMediaType = isEncrypted ? 'encrypted_photo' : result.mediaType;
const successThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploadAfterSuccess =
this.state.pendingUploads[successThreadID][localID];
invariant(
uploadAfterSuccess,
`pendingUpload ${localID}/${result.id} for ${successThreadID} missing ` +
`after upload`,
);
if (uploadAfterSuccess.messageID) {
this.props.dispatch({
type: updateMultimediaMessageMediaActionType,
payload: {
messageID: uploadAfterSuccess.messageID,
currentMediaID: localID,
mediaUpdate: {
id: result.id,
},
},
});
}
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploads = prevState.pendingUploads[newThreadID];
const currentUpload = uploads[localID];
invariant(
currentUpload,
`pendingUpload ${localID}/${result.id} for ${newThreadID} ` +
`missing while assigning serverID`,
);
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: {
...uploads,
[localID]: {
...currentUpload,
serverID: result.id,
blobHolder: result.blobHolder,
abort: null,
},
},
},
};
});
if (encryptionKey) {
const { steps: preloadSteps } = await preloadMediaResource(result.uri);
steps.push(...preloadSteps);
} else {
const { steps: preloadSteps } = await preloadImage(result.uri);
steps.push(...preloadSteps);
}
sendReport({ success: true });
const preloadThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploadAfterPreload =
this.state.pendingUploads[preloadThreadID][localID];
invariant(
uploadAfterPreload,
`pendingUpload ${localID}/${result.id} for ${preloadThreadID} missing ` +
`after preload`,
);
if (uploadAfterPreload.messageID) {
const { mediaType, uri, dimensions, loop } = result;
const { thumbHash } = upload;
let mediaUpdate = {
loop,
dimensions,
...(thumbHash ? { thumbHash } : undefined),
};
if (!isEncrypted) {
mediaUpdate = {
...mediaUpdate,
type: mediaType,
uri,
};
} else {
mediaUpdate = {
...mediaUpdate,
type: outputMediaType,
blobURI: uri,
encryptionKey,
};
}
this.props.dispatch({
type: updateMultimediaMessageMediaActionType,
payload: {
messageID: uploadAfterPreload.messageID,
currentMediaID: result.id ?? uploadAfterPreload.localID,
mediaUpdate,
},
});
}
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploads = prevState.pendingUploads[newThreadID];
const currentUpload = uploads[localID];
invariant(
currentUpload,
`pendingUpload ${localID}/${result.id} for ${newThreadID} ` +
`missing while assigning URI`,
);
const { messageID } = currentUpload;
if (messageID && !messageID.startsWith(localIDPrefix)) {
const newPendingUploads = _omit([localID])(uploads);
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: {
...uploads,
[localID]: {
...currentUpload,
uri: result.uri,
mediaType: outputMediaType,
dimensions: result.dimensions,
uriIsReal: true,
loop: result.loop,
},
},
},
};
});
}
handleAbortCallback(
threadID: string,
localUploadID: string,
abort: () => void,
) {
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploads = prevState.pendingUploads[newThreadID];
const upload = uploads[localUploadID];
if (!upload) {
// The upload has been cancelled before we were even handed the
// abort function. We should immediately abort.
abort();
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: {
...uploads,
[localUploadID]: {
...upload,
abort,
},
},
},
};
});
}
handleUploadFailure(threadID: string, localUploadID: string) {
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploads = prevState.pendingUploads[newThreadID];
const upload = uploads[localUploadID];
if (!upload || !upload.abort || upload.serverID) {
// The upload has been cancelled or completed before it failed
return {};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: {
...uploads,
[localUploadID]: {
...upload,
failed: true,
progressPercent: 0,
abort: null,
},
},
},
};
});
}
queueMediaMissionReports(
partials: $ReadOnlyArray<{
mediaMission: MediaMission,
uploadLocalID?: ?string,
uploadServerID?: ?string,
messageLocalID?: ?string,
}>,
) {
const reports = partials.map(
({ mediaMission, uploadLocalID, uploadServerID, messageLocalID }) => ({
type: reportTypes.MEDIA_MISSION,
time: Date.now(),
platformDetails: getConfig().platformDetails,
mediaMission,
uploadServerID,
uploadLocalID,
messageLocalID,
id: generateReportID(),
}),
);
this.props.dispatch({ type: queueReportsActionType, payload: { reports } });
}
cancelPendingUpload(threadID: ?string, localUploadID: string) {
invariant(threadID, 'threadID should be set in cancelPendingUpload');
let revokeURL: ?string, abortRequest: ?() => void;
this.setState(
prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const currentPendingUploads = prevState.pendingUploads[newThreadID];
if (!currentPendingUploads) {
return {};
}
const pendingUpload = currentPendingUploads[localUploadID];
if (!pendingUpload) {
return {};
}
if (!pendingUpload.uriIsReal) {
revokeURL = pendingUpload.uri;
}
if (pendingUpload.abort) {
abortRequest = pendingUpload.abort;
}
if (pendingUpload.serverID) {
this.props.deleteUpload({
id: pendingUpload.serverID,
keyserverOrThreadID: threadID,
});
if (isBlobServiceURI(pendingUpload.uri)) {
invariant(
pendingUpload.blobHolder,
'blob service upload has no holder',
);
const endpoint = blobService.httpEndpoints.DELETE_BLOB;
const holder = pendingUpload.blobHolder;
const blobHash = blobHashFromBlobServiceURI(pendingUpload.uri);
fetch(makeBlobServiceEndpointURL(endpoint), {
method: endpoint.method,
body: JSON.stringify({
holder,
blob_hash: blobHash,
}),
headers: {
'content-type': 'application/json',
},
});
}
}
const newPendingUploads = _omit([localUploadID])(currentPendingUploads);
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
},
() => {
if (revokeURL) {
URL.revokeObjectURL(revokeURL);
}
if (abortRequest) {
abortRequest();
}
},
);
}
async sendTextMessage(
messageInfo: RawTextMessageInfo,
- inputThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
- parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo,
+ inputThreadInfo: ThreadInfo,
+ parentThreadInfo: ?ThreadInfo,
) {
this.props.sendCallbacks.forEach(callback => callback());
const { localID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should be set',
);
if (threadIsPendingSidebar(inputThreadInfo.id)) {
this.pendingSidebarCreationMessageLocalIDs.add(localID);
}
if (!threadIsPending(inputThreadInfo.id)) {
this.props.dispatchActionPromise(
sendTextMessageActionTypes,
this.sendTextMessageAction(
messageInfo,
inputThreadInfo,
parentThreadInfo,
),
undefined,
messageInfo,
);
return;
}
this.props.dispatch({
type: sendTextMessageActionTypes.started,
payload: messageInfo,
});
let threadInfo = inputThreadInfo;
const { viewerID } = this.props;
if (viewerID && inputThreadInfo.type === threadTypes.SIDEBAR) {
invariant(parentThreadInfo, 'sidebar should have parent');
threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent(
inputThreadInfo,
parentThreadInfo,
messageInfo.text,
viewerID,
);
if (threadInfo !== inputThreadInfo) {
this.props.dispatch({
type: updateNavInfoActionType,
payload: { pendingThread: threadInfo },
});
}
}
let newThreadID = null;
try {
newThreadID = await this.startThreadCreation(threadInfo);
} catch (e) {
const copy = cloneError(e);
copy.localID = messageInfo.localID;
copy.threadID = messageInfo.threadID;
this.props.dispatch({
type: sendTextMessageActionTypes.failed,
payload: copy,
error: true,
});
return;
} finally {
this.pendingThreadCreations.delete(threadInfo.id);
}
const newMessageInfo = {
...messageInfo,
threadID: newThreadID,
time: Date.now(),
};
// Branching to appease `flow`.
const newThreadInfo = threadInfo.minimallyEncoded
? {
...threadInfo,
id: newThreadID,
}
: {
...threadInfo,
id: newThreadID,
};
this.props.dispatchActionPromise(
sendTextMessageActionTypes,
this.sendTextMessageAction(
newMessageInfo,
newThreadInfo,
parentThreadInfo,
),
undefined,
newMessageInfo,
);
}
async sendTextMessageAction(
messageInfo: RawTextMessageInfo,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
- parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
+ parentThreadInfo: ?ThreadInfo,
): Promise {
try {
await this.props.textMessageCreationSideEffectsFunc(
messageInfo,
threadInfo,
parentThreadInfo,
);
const { localID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should be set',
);
const sidebarCreation =
this.pendingSidebarCreationMessageLocalIDs.has(localID);
const result = await this.props.sendTextMessage({
threadID: messageInfo.threadID,
localID,
text: messageInfo.text,
sidebarCreation,
});
this.pendingSidebarCreationMessageLocalIDs.delete(localID);
return {
localID,
serverID: result.id,
threadID: messageInfo.threadID,
time: result.time,
interface: result.interface,
};
} catch (e) {
const copy = cloneError(e);
copy.localID = messageInfo.localID;
copy.threadID = messageInfo.threadID;
throw copy;
}
}
// Creates a MultimediaMessage from the unassigned pending uploads,
// if there are any
createMultimediaMessage(localID: number, threadInfo: LegacyThreadInfo) {
this.props.sendCallbacks.forEach(callback => callback());
const localMessageID = `${localIDPrefix}${localID}`;
this.startThreadCreation(threadInfo);
if (threadIsPendingSidebar(threadInfo.id)) {
this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
}
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id);
const currentPendingUploads = prevState.pendingUploads[newThreadID];
if (!currentPendingUploads) {
return {};
}
const newPendingUploads: { [string]: PendingMultimediaUpload } = {};
let uploadAssigned = false;
for (const localUploadID in currentPendingUploads) {
const upload = currentPendingUploads[localUploadID];
if (upload.messageID) {
newPendingUploads[localUploadID] = upload;
} else {
const newUpload = {
...upload,
messageID: localMessageID,
};
uploadAssigned = true;
newPendingUploads[localUploadID] = newUpload;
}
}
if (!uploadAssigned) {
return {};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
});
}
setDraft(threadID: ?string, draft: string) {
invariant(threadID, 'threadID should be set in setDraft');
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
this.props.dispatch({
type: 'UPDATE_DRAFT',
payload: {
key: draftKeyFromThreadID(newThreadID),
text: draft,
},
});
}
setTextCursorPosition(threadID: ?string, newPosition: number) {
invariant(threadID, 'threadID should be set in setTextCursorPosition');
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
return {
textCursorPositions: {
...prevState.textCursorPositions,
[newThreadID]: newPosition,
},
};
});
}
setTypeaheadState = (newState: Partial) => {
this.setState(prevState => ({
typeaheadState: {
...prevState.typeaheadState,
...newState,
},
}));
};
setProgress(
threadID: string,
localUploadID: string,
progressPercent: number,
) {
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const pendingUploads = prevState.pendingUploads[newThreadID];
if (!pendingUploads) {
return {};
}
const pendingUpload = pendingUploads[localUploadID];
if (!pendingUpload) {
return {};
}
const newPendingUploads = {
...pendingUploads,
[localUploadID]: {
...pendingUpload,
progressPercent,
},
};
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
});
}
messageHasUploadFailure(
pendingUploads: ?$ReadOnlyArray,
): boolean {
if (!pendingUploads) {
return false;
}
return pendingUploads.some(upload => upload.failed);
}
retryMultimediaMessage(
localMessageID: string,
threadInfo: LegacyThreadInfo,
pendingUploads: ?$ReadOnlyArray,
) {
this.props.sendCallbacks.forEach(callback => callback());
const rawMessageInfo = this.getRawMultimediaMessageInfo(localMessageID);
let newRawMessageInfo;
// This conditional is for Flow
if (rawMessageInfo.type === messageTypes.MULTIMEDIA) {
newRawMessageInfo = ({
...rawMessageInfo,
time: Date.now(),
}: RawMediaMessageInfo);
} else {
newRawMessageInfo = ({
...rawMessageInfo,
time: Date.now(),
}: RawImagesMessageInfo);
}
this.startThreadCreation(threadInfo);
if (threadIsPendingSidebar(threadInfo.id)) {
this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
}
const completed = InputStateContainer.completedMessageIDs(this.state);
if (completed.has(localMessageID)) {
this.sendMultimediaMessage(newRawMessageInfo);
return;
}
if (!pendingUploads) {
return;
}
// We're not actually starting the send here,
// we just use this action to update the message's timestamp in Redux
this.props.dispatch({
type: sendMultimediaMessageActionTypes.started,
payload: newRawMessageInfo,
});
const uploadIDsToRetry = new Set();
const uploadsToRetry = [];
for (const pendingUpload of pendingUploads) {
const { serverID, messageID, localID, abort } = pendingUpload;
if (serverID || messageID !== localMessageID) {
continue;
}
if (abort) {
abort();
}
uploadIDsToRetry.add(localID);
uploadsToRetry.push(pendingUpload);
}
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id);
const prevPendingUploads = prevState.pendingUploads[newThreadID];
if (!prevPendingUploads) {
return {};
}
const newPendingUploads: { [string]: PendingMultimediaUpload } = {};
let pendingUploadChanged = false;
for (const localID in prevPendingUploads) {
const pendingUpload = prevPendingUploads[localID];
if (uploadIDsToRetry.has(localID) && !pendingUpload.serverID) {
newPendingUploads[localID] = {
...pendingUpload,
failed: false,
progressPercent: 0,
abort: null,
};
pendingUploadChanged = true;
} else {
newPendingUploads[localID] = pendingUpload;
}
}
if (!pendingUploadChanged) {
return {};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
});
this.uploadFiles(threadInfo.id, uploadsToRetry);
}
addReply = (message: string) => {
this.replyCallbacks.forEach(addReplyCallback => addReplyCallback(message));
};
addReplyListener = (callbackReply: (message: string) => void) => {
this.replyCallbacks.push(callbackReply);
};
removeReplyListener = (callbackReply: (message: string) => void) => {
this.replyCallbacks = this.replyCallbacks.filter(
candidate => candidate !== callbackReply,
);
};
render(): React.Node {
const { activeChatThreadID } = this.props;
// we're going with two selectors as we want to avoid
// recreation of chat state setter functions on typeahead state updates
const inputBaseState = this.inputBaseStateSelector(activeChatThreadID)({
...this.state,
...this.props,
});
const typeaheadState = this.typeaheadStateSelector({
...this.state,
...this.props,
});
const inputState = this.inputStateSelector({
inputBaseState,
typeaheadState,
});
return (
{this.props.children}
);
}
}
const ConnectedInputStateContainer: React.ComponentType =
React.memo(function ConnectedInputStateContainer(props) {
const activeChatThreadID = useSelector(
state => state.navInfo.activeChatThreadID,
);
const drafts = useSelector(state => state.draftStore.drafts);
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const messageStoreMessages = useSelector(
state => state.messageStore.messages,
);
const pendingToRealizedThreadIDs = useSelector(state =>
pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos),
);
const calendarQuery = useSelector(nonThreadCalendarQuery);
const callUploadMultimedia = useServerCall(uploadMultimedia);
const callBlobServiceUpload = useBlobServiceUpload();
const callDeleteUpload = useDeleteUpload();
const callSendMultimediaMessage = useLegacySendMultimediaMessage();
const callSendTextMessage = useSendTextMessage();
const callNewThread = useNewThread();
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const modalContext = useModalContext();
const [sendCallbacks, setSendCallbacks] = React.useState<
$ReadOnlyArray<() => mixed>,
>([]);
const registerSendCallback = React.useCallback((callback: () => mixed) => {
setSendCallbacks(prevCallbacks => [...prevCallbacks, callback]);
}, []);
const unregisterSendCallback = React.useCallback(
(callback: () => mixed) => {
setSendCallbacks(prevCallbacks =>
prevCallbacks.filter(candidate => candidate !== callback),
);
},
[],
);
const textMessageCreationSideEffectsFunc =
useMessageCreationSideEffectsFunc(messageTypes.TEXT);
return (
);
});
export default ConnectedInputStateContainer;
diff --git a/web/input/input-state.js b/web/input/input-state.js
index 898aaff46..3b556cff9 100644
--- a/web/input/input-state.js
+++ b/web/input/input-state.js
@@ -1,108 +1,104 @@
// @flow
import * as React from 'react';
import {
type MediaType,
type EncryptedMediaType,
type Dimensions,
type MediaMissionStep,
} from 'lib/types/media-types.js';
import type { RawTextMessageInfo } from 'lib/types/messages/text.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type {
- LegacyThreadInfo,
ChatMentionCandidates,
RelativeMemberInfo,
+ ThreadInfo,
} from 'lib/types/thread-types.js';
export type PendingMultimediaUpload = {
+localID: string,
// Pending uploads are assigned a serverID once they are complete
+serverID: ?string,
// Pending uploads are assigned a messageID once they are sent
+messageID: ?string,
// This is set to true if the upload fails for whatever reason
+failed: boolean,
+file: File,
+mediaType: MediaType | EncryptedMediaType,
+dimensions: ?Dimensions,
+uri: string,
+blobHolder: ?string,
+blobHash: ?string,
+encryptionKey: ?string,
+thumbHash: ?string,
+loop: boolean,
// URLs created with createObjectURL aren't considered "real". The distinction
// is required because those "fake" URLs must be disposed properly
+uriIsReal: boolean,
+progressPercent: number,
// This is set once the network request begins and used if the upload is
// cancelled
+abort: ?() => void,
+steps: MediaMissionStep[],
+selectTime: number,
+shouldEncrypt: boolean,
};
export type TypeaheadState = {
+canBeVisible: boolean,
+keepUpdatingThreadMembers: boolean,
+frozenUserMentionsCandidates: $ReadOnlyArray,
+frozenChatMentionsCandidates: ChatMentionCandidates,
+moveChoiceUp: ?() => void,
+moveChoiceDown: ?() => void,
+close: ?() => void,
+accept: ?() => void,
};
export type BaseInputState = {
+pendingUploads: $ReadOnlyArray,
+assignedUploads: {
[messageID: string]: $ReadOnlyArray,
},
+draft: string,
+textCursorPosition: number,
+appendFiles: (
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
files: $ReadOnlyArray,
) => Promise,
+cancelPendingUpload: (localUploadID: string) => void,
+sendTextMessage: (
messageInfo: RawTextMessageInfo,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
- parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
+ parentThreadInfo: ?ThreadInfo,
) => Promise,
- +createMultimediaMessage: (
- localID: number,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
- ) => void,
+ +createMultimediaMessage: (localID: number, threadInfo: ThreadInfo) => void,
+setDraft: (draft: string) => void,
+setTextCursorPosition: (newPosition: number) => void,
+messageHasUploadFailure: (localMessageID: string) => boolean,
+retryMultimediaMessage: (
localMessageID: string,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
) => void,
+addReply: (text: string) => void,
+addReplyListener: ((message: string) => void) => void,
+removeReplyListener: ((message: string) => void) => void,
+registerSendCallback: (() => mixed) => void,
+unregisterSendCallback: (() => mixed) => void,
};
export type TypeaheadInputState = {
+typeaheadState: TypeaheadState,
+setTypeaheadState: (Partial) => void,
};
// This type represents the input state for a particular thread
export type InputState = {
...BaseInputState,
...TypeaheadInputState,
};
const InputStateContext: React.Context =
React.createContext(null);
export { InputStateContext };
diff --git a/web/invite-links/manage/edit-link-modal.react.js b/web/invite-links/manage/edit-link-modal.react.js
index 3b6c33ac7..246adc5af 100644
--- a/web/invite-links/manage/edit-link-modal.react.js
+++ b/web/invite-links/manage/edit-link-modal.react.js
@@ -1,119 +1,118 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { inviteLinkURL } from 'lib/facts/links.js';
import { useInviteLinksActions } from 'lib/hooks/invite-links.js';
import {
defaultErrorMessage,
inviteLinkErrorMessages,
} from 'lib/shared/invite-links.js';
import type { InviteLink } from 'lib/types/link-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import css from './manage-invite-links-modal.css';
import Button from '../../components/button.react.js';
import Input from '../../modals/input.react.js';
import Modal from '../../modals/modal.react.js';
type Props = {
+inviteLink: ?InviteLink,
+enterViewMode: () => mixed,
+enterDisableMode: () => mixed,
- +community: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +community: ThreadInfo,
};
const disableButtonColor = {
color: 'var(--error-primary)',
borderColor: 'var(--error-primary)',
};
function EditLinkModal(props: Props): React.Node {
const { inviteLink, enterViewMode, enterDisableMode, community } = props;
const { popModal } = useModalContext();
const { error, isLoading, name, setName, createOrUpdateInviteLink } =
useInviteLinksActions(community.id, inviteLink);
const onChangeName = React.useCallback(
(event: SyntheticEvent) => {
setName(event.currentTarget.value);
},
[setName],
);
let errorComponent = null;
if (error) {
errorComponent = (
{inviteLinkErrorMessages[error] ?? defaultErrorMessage}
);
}
let disableLinkComponent = null;
if (inviteLink) {
disableLinkComponent = (
<>
You may also disable the community public link
Disable
>
);
}
return (
Invite links make it easy for your friends to join your community.
Anybody who knows your community’s invite link will be able to join
it.
Note that if you change your public link’s URL, other communities
will be able to claim the old URL.
Invite URL
{inviteLinkURL('')}
{errorComponent}
Back
Save & enable public link
{disableLinkComponent}
);
}
export default EditLinkModal;
diff --git a/web/markdown/rules.react.js b/web/markdown/rules.react.js
index a7cca37f9..0be7e606c 100644
--- a/web/markdown/rules.react.js
+++ b/web/markdown/rules.react.js
@@ -1,249 +1,248 @@
// @flow
import _memoize from 'lodash/memoize.js';
import * as React from 'react';
import * as SimpleMarkdown from 'simple-markdown';
import * as SharedMarkdown from 'lib/shared/markdown.js';
import { chatMentionRegex } from 'lib/shared/mention-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type {
- LegacyThreadInfo,
ChatMentionCandidates,
RelativeMemberInfo,
+ ThreadInfo,
} from 'lib/types/thread-types.js';
import MarkdownChatMention from './markdown-chat-mention.react.js';
import MarkdownSpoiler from './markdown-spoiler.react.js';
import MarkdownUserMention from './markdown-user-mention.react.js';
export type MarkdownRules = {
+simpleMarkdownRules: SharedMarkdown.ParserRules,
+useDarkStyle: boolean,
};
const linkRules: boolean => MarkdownRules = _memoize(useDarkStyle => {
const simpleMarkdownRules = {
// We are using default simple-markdown rules
// For more details, look at native/markdown/rules.react
link: {
...SimpleMarkdown.defaultRules.link,
match: () => null,
// eslint-disable-next-line react/display-name
react: (
node: SharedMarkdown.SingleASTNode,
output: SharedMarkdown.Output,
state: SharedMarkdown.State,
) => (
{output(node.content, state)}
),
},
paragraph: {
...SimpleMarkdown.defaultRules.paragraph,
match: SimpleMarkdown.blockRegex(SharedMarkdown.paragraphRegex),
// eslint-disable-next-line react/display-name
react: (
node: SharedMarkdown.SingleASTNode,
output: SharedMarkdown.Output,
state: SharedMarkdown.State,
) => (
{output(node.content, state)}
),
},
text: SimpleMarkdown.defaultRules.text,
url: {
...SimpleMarkdown.defaultRules.url,
match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex),
},
};
return {
simpleMarkdownRules: simpleMarkdownRules,
useDarkStyle,
};
});
const markdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => {
const linkMarkdownRules = linkRules(useDarkStyle);
const simpleMarkdownRules = {
...linkMarkdownRules.simpleMarkdownRules,
autolink: SimpleMarkdown.defaultRules.autolink,
link: {
...linkMarkdownRules.simpleMarkdownRules.link,
match: SimpleMarkdown.defaultRules.link.match,
},
blockQuote: {
...SimpleMarkdown.defaultRules.blockQuote,
// match end of blockQuote by either \n\n or end of string
match: SharedMarkdown.matchBlockQuote(SharedMarkdown.blockQuoteRegex),
parse: SharedMarkdown.parseBlockQuote,
},
spoiler: {
order: SimpleMarkdown.defaultRules.paragraph.order - 1,
match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex),
parse(
capture: SharedMarkdown.Capture,
parse: SharedMarkdown.Parser,
state: SharedMarkdown.State,
) {
const content = capture[1];
return {
content: SimpleMarkdown.parseInline(parse, content, state),
};
},
// eslint-disable-next-line react/display-name
react: (
node: SharedMarkdown.SingleASTNode,
output: SharedMarkdown.Output,
state: SharedMarkdown.State,
) => (
),
},
inlineCode: SimpleMarkdown.defaultRules.inlineCode,
em: SimpleMarkdown.defaultRules.em,
strong: SimpleMarkdown.defaultRules.strong,
del: SimpleMarkdown.defaultRules.del,
u: SimpleMarkdown.defaultRules.u,
heading: {
...SimpleMarkdown.defaultRules.heading,
match: SimpleMarkdown.blockRegex(SharedMarkdown.headingRegex),
},
mailto: SimpleMarkdown.defaultRules.mailto,
codeBlock: {
...SimpleMarkdown.defaultRules.codeBlock,
match: SimpleMarkdown.blockRegex(SharedMarkdown.codeBlockRegex),
parse: (capture: SharedMarkdown.Capture) => ({
content: capture[0].replace(/^ {4}/gm, ''),
}),
},
fence: {
...SimpleMarkdown.defaultRules.fence,
match: SimpleMarkdown.blockRegex(SharedMarkdown.fenceRegex),
parse: (capture: SharedMarkdown.Capture) => ({
type: 'codeBlock',
content: capture[2],
}),
},
json: {
order: SimpleMarkdown.defaultRules.paragraph.order - 1,
match: (source: string, state: SharedMarkdown.State) => {
if (state.inline) {
return null;
}
return SharedMarkdown.jsonMatch(source);
},
parse: (capture: SharedMarkdown.Capture) => {
const jsonCapture: SharedMarkdown.JSONCapture = (capture: any);
return {
type: 'codeBlock',
content: SharedMarkdown.jsonPrint(jsonCapture),
};
},
},
list: {
...SimpleMarkdown.defaultRules.list,
match: SharedMarkdown.matchList,
parse: SharedMarkdown.parseList,
},
escape: SimpleMarkdown.defaultRules.escape,
};
return {
...linkMarkdownRules,
simpleMarkdownRules,
useDarkStyle,
};
});
function useTextMessageRulesFunc(
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
chatMentionCandidates: ChatMentionCandidates,
): boolean => MarkdownRules {
const { members } = threadInfo;
return React.useMemo(
() =>
_memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) =>
textMessageRules(members, chatMentionCandidates, useDarkStyle),
),
[chatMentionCandidates, members],
);
}
function textMessageRules(
members: $ReadOnlyArray,
chatMentionCandidates: ChatMentionCandidates,
useDarkStyle: boolean,
): MarkdownRules {
const baseRules = markdownRules(useDarkStyle);
const membersMap = SharedMarkdown.createMemberMapForUserMentions(members);
return {
...baseRules,
simpleMarkdownRules: {
...baseRules.simpleMarkdownRules,
userMention: {
...SimpleMarkdown.defaultRules.strong,
match: SharedMarkdown.matchUserMentions(membersMap),
parse: (capture: SharedMarkdown.Capture) =>
SharedMarkdown.parseUserMentions(membersMap, capture),
// eslint-disable-next-line react/display-name
react: (
node: SharedMarkdown.SingleASTNode,
output: SharedMarkdown.Output,
state: SharedMarkdown.State,
) => (
),
},
chatMention: {
...SimpleMarkdown.defaultRules.strong,
match: SimpleMarkdown.inlineRegex(chatMentionRegex),
parse: (capture: SharedMarkdown.Capture) =>
SharedMarkdown.parseChatMention(chatMentionCandidates, capture),
// eslint-disable-next-line react/display-name
react: (
node: SharedMarkdown.SingleASTNode,
output: SharedMarkdown.Output,
state: SharedMarkdown.State,
) => (
),
},
},
};
}
let defaultTextMessageRules = null;
function getDefaultTextMessageRules(
overrideDefaultChatMentionCandidates: ChatMentionCandidates = {},
): MarkdownRules {
if (Object.keys(overrideDefaultChatMentionCandidates).length > 0) {
return textMessageRules([], overrideDefaultChatMentionCandidates, false);
}
if (!defaultTextMessageRules) {
defaultTextMessageRules = textMessageRules([], {}, false);
}
return defaultTextMessageRules;
}
export { linkRules, useTextMessageRulesFunc, getDefaultTextMessageRules };
diff --git a/web/modals/chat/message-results-modal.react.js b/web/modals/chat/message-results-modal.react.js
index e667d92cd..5c40ed0f1 100644
--- a/web/modals/chat/message-results-modal.react.js
+++ b/web/modals/chat/message-results-modal.react.js
@@ -1,163 +1,162 @@
// @flow
import * as React from 'react';
import {
fetchPinnedMessageActionTypes,
useFetchPinnedMessages,
} from 'lib/actions/message-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import {
messageListData,
type ChatMessageInfoItem,
} from 'lib/selectors/chat-selectors.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import {
createMessageInfo,
isInvalidPinSourceForThread,
modifyItemForResultScreen,
} from 'lib/shared/message-utils.js';
import type { RawMessageInfo } from 'lib/types/message-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import css from './message-results-modal.css';
import MessageResult from '../../components/message-result.react.js';
import LoadingIndicator from '../../loading-indicator.react.js';
import { useSelector } from '../../redux/redux-utils.js';
import Modal from '../modal.react.js';
type MessageResultsModalProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+modalName: string,
};
const loadingStatusSelector = createLoadingStatusSelector(
fetchPinnedMessageActionTypes,
);
function MessageResultsModal(props: MessageResultsModalProps): React.Node {
const { threadInfo, modalName } = props;
const { id: threadID } = threadInfo;
const { popModal } = useModalContext();
const [rawMessageResults, setRawMessageResults] = React.useState<
$ReadOnlyArray,
>([]);
const callFetchPinnedMessages = useFetchPinnedMessages();
const dispatchActionPromise = useDispatchActionPromise();
const userInfos = useSelector(state => state.userStore.userInfos);
const loadingStatus = useSelector(loadingStatusSelector);
React.useEffect(() => {
dispatchActionPromise(
fetchPinnedMessageActionTypes,
(async () => {
const result = await callFetchPinnedMessages({ threadID });
setRawMessageResults(result.pinnedMessages);
})(),
);
}, [dispatchActionPromise, callFetchPinnedMessages, threadID]);
const translatedMessageResults = React.useMemo(() => {
const threadInfos = { [threadID]: threadInfo };
return rawMessageResults
.map(messageInfo =>
createMessageInfo(messageInfo, null, userInfos, threadInfos),
)
.filter(Boolean);
}, [rawMessageResults, userInfos, threadID, threadInfo]);
const chatMessageInfos = useSelector(
messageListData(threadInfo.id, translatedMessageResults),
);
const sortedUniqueChatMessageInfoItems = React.useMemo(() => {
if (!chatMessageInfos) {
return ([]: ChatMessageInfoItem[]);
}
const chatMessageInfoItems = chatMessageInfos.filter(
item =>
item.itemType === 'message' &&
item.isPinned &&
!isInvalidPinSourceForThread(item.messageInfo, threadInfo),
);
// By the nature of using messageListData and passing in
// the desired translatedMessageResults as additional
// messages, we will have duplicate ChatMessageInfoItems.
const uniqueChatMessageInfoItemsMap = new Map<
string,
ChatMessageInfoItem,
>();
chatMessageInfoItems.forEach(
item =>
item.messageInfo &&
item.messageInfo.id &&
uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item),
);
// Push the items in the order they appear in the rawMessageResults
// since the messages fetched from the server are already sorted
// in the order of pin_time (newest first).
const sortedChatMessageInfoItems = [];
for (let i = 0; i < rawMessageResults.length; i++) {
const rawMessageID = rawMessageResults[i].id;
if (!rawMessageID) {
continue;
}
sortedChatMessageInfoItems.push(
uniqueChatMessageInfoItemsMap.get(rawMessageID),
);
}
return sortedChatMessageInfoItems;
}, [chatMessageInfos, rawMessageResults, threadInfo]);
const modifiedItems = React.useMemo(
() =>
sortedUniqueChatMessageInfoItems
.filter(Boolean)
.map(item => modifyItemForResultScreen(item)),
[sortedUniqueChatMessageInfoItems],
);
const messageResultsToDisplay = React.useMemo(() => {
const items = modifiedItems.map(item => (
));
return <>{items}>;
}, [modifiedItems, threadInfo]);
const loadingIndicator = React.useMemo(() => {
if (loadingStatus === 'loading') {
return (
);
}
return null;
}, [loadingStatus]);
return (
{loadingIndicator}
{messageResultsToDisplay}
);
}
export default MessageResultsModal;
diff --git a/web/modals/chat/sidebar-promote-modal.react.js b/web/modals/chat/sidebar-promote-modal.react.js
index 4b6640ad4..3b6e087c7 100644
--- a/web/modals/chat/sidebar-promote-modal.react.js
+++ b/web/modals/chat/sidebar-promote-modal.react.js
@@ -1,53 +1,52 @@
// @flow
import * as React from 'react';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './sidebar-promote-modal.css';
import Button from '../../components/button.react.js';
import Modal from '../modal.react.js';
type Props = {
+onClose: () => void,
+onConfirm: () => void,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function SidebarPromoteModal(props: Props): React.Node {
const { threadInfo, onClose, onConfirm } = props;
const { uiName } = useResolvedThreadInfo(threadInfo);
const handleConfirm = React.useCallback(() => {
onConfirm();
onClose();
}, [onClose, onConfirm]);
return (
{`Are you sure you want to promote "${uiName}"?
Promoting a thread to a channel cannot be undone.`}
Cancel
Promote to channel
);
}
export default SidebarPromoteModal;
diff --git a/web/modals/chat/toggle-pin-modal.react.js b/web/modals/chat/toggle-pin-modal.react.js
index d7ea3ef47..c34127fe7 100644
--- a/web/modals/chat/toggle-pin-modal.react.js
+++ b/web/modals/chat/toggle-pin-modal.react.js
@@ -1,131 +1,130 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
useToggleMessagePin,
toggleMessagePinActionTypes,
} from 'lib/actions/message-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { modifyItemForResultScreen } from 'lib/shared/message-utils.js';
import type { RawMessageInfo } from 'lib/types/message-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import css from './toggle-pin-modal.css';
import Button, { buttonThemes } from '../../components/button.react.js';
import MessageResult from '../../components/message-result.react.js';
import Modal from '../modal.react.js';
type TogglePinModalProps = {
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function TogglePinModal(props: TogglePinModalProps): React.Node {
const { item, threadInfo } = props;
const { messageInfo, isPinned } = item;
const { popModal } = useModalContext();
const callToggleMessagePin = useToggleMessagePin();
const dispatchActionPromise = useDispatchActionPromise();
const modalInfo = React.useMemo(() => {
if (isPinned) {
return {
name: 'Remove Pinned Message',
action: 'unpin',
confirmationText:
'Are you sure you want to remove this pinned message?',
buttonText: 'Remove Pinned Message',
buttonColor: buttonThemes.danger,
};
}
return {
name: 'Pin Message',
action: 'pin',
confirmationText: `You may pin this message to the channel
you are currently viewing. To unpin a message, select the pinned
messages icon in the channel.`,
buttonText: 'Pin Message',
buttonColor: buttonThemes.standard,
};
}, [isPinned]);
// We want to remove inline engagement (threadCreatedFromMessage / reactions)
// and the message header (startsConversation). We also want to set isViewer
// to false so that the message is left-aligned and uncolored.
const modifiedItem = React.useMemo(() => {
if (item.messageInfoType !== 'composable') {
return item;
}
const strippedItem = {
...item,
threadCreatedFromMessage: undefined,
reactions: {},
};
return modifyItemForResultScreen(strippedItem);
}, [item]);
const onClick = React.useCallback(() => {
const createToggleMessagePinPromise = async () => {
invariant(messageInfo.id, 'messageInfo.id should be defined');
const result = await callToggleMessagePin({
messageID: messageInfo.id,
action: modalInfo.action,
});
return ({
newMessageInfos: result.newMessageInfos,
threadID: result.threadID,
}: {
+newMessageInfos: $ReadOnlyArray,
+threadID: string,
});
};
dispatchActionPromise(
toggleMessagePinActionTypes,
createToggleMessagePinPromise(),
);
popModal();
}, [
modalInfo,
callToggleMessagePin,
dispatchActionPromise,
messageInfo.id,
popModal,
]);
return (
{modalInfo.confirmationText}
{modalInfo.buttonText}
Cancel
);
}
export default TogglePinModal;
diff --git a/web/modals/search/message-search-modal.react.js b/web/modals/search/message-search-modal.react.js
index bd67d7442..ac99b18cb 100644
--- a/web/modals/search/message-search-modal.react.js
+++ b/web/modals/search/message-search-modal.react.js
@@ -1,160 +1,159 @@
// @flow
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './message-search-modal.css';
import { useParseSearchResults } from './message-search-utils.react.js';
import { useTooltipContext } from '../../chat/tooltip-provider.js';
import Button from '../../components/button.react.js';
import MessageResult from '../../components/message-result.react.js';
import Search from '../../components/search.react.js';
import LoadingIndicator from '../../loading-indicator.react.js';
import { useMessageSearchContext } from '../../search/message-search-state-provider.react.js';
import Modal from '../modal.react.js';
type ContentProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function MessageSearchModal(props: ContentProps): React.Node {
const { threadInfo } = props;
const {
getQuery,
setQuery,
clearQuery,
searchMessages,
getSearchResults,
getEndReached,
} = useMessageSearchContext();
const [input, setInput] = React.useState(getQuery(threadInfo.id));
const onPressSearch = React.useCallback(() => {
setQuery(input, threadInfo.id);
searchMessages(threadInfo.id);
}, [setQuery, input, searchMessages, threadInfo.id]);
const onKeyDown = React.useCallback(
(event: SyntheticKeyboardEvent) => {
if (event.key === 'Enter') {
onPressSearch();
}
},
[onPressSearch],
);
const modifiedItems = useParseSearchResults(
threadInfo,
getSearchResults(threadInfo.id),
);
const { clearTooltip } = useTooltipContext();
const messageContainer = React.useRef(null);
const possiblyLoadMoreMessages = React.useCallback(() => {
if (!messageContainer.current) {
return;
}
const loaderTopOffset = 32;
const { scrollTop, scrollHeight, clientHeight } = messageContainer.current;
if (Math.abs(scrollTop) + clientHeight + loaderTopOffset < scrollHeight) {
return;
}
searchMessages(threadInfo.id);
}, [searchMessages, threadInfo.id]);
const onScroll = React.useCallback(() => {
clearTooltip();
possiblyLoadMoreMessages();
}, [clearTooltip, possiblyLoadMoreMessages]);
const renderItem = React.useCallback(
(item: ChatMessageInfoItem) => (
),
[threadInfo],
);
const messages = React.useMemo(
() => modifiedItems.map(item => renderItem(item)),
[modifiedItems, renderItem],
);
const endReached = getEndReached(threadInfo.id);
const query = getQuery(threadInfo.id);
const footer = React.useMemo(() => {
if (query === '') {
return (
Your search results will appear here
);
}
if (!endReached) {
return (
);
}
if (modifiedItems.length > 0) {
return End of results
;
}
return (
No results. Please try using different keywords to refine your search
);
}, [query, endReached, modifiedItems.length]);
const { uiName } = useResolvedThreadInfo(threadInfo);
const searchPlaceholder = `Searching in ${uiName}`;
const { popModal } = useModalContext();
const clearQueryWrapper = React.useCallback(
() => clearQuery(threadInfo.id),
[clearQuery, threadInfo.id],
);
return (
Search
{messages}
{footer}
);
}
export default MessageSearchModal;
diff --git a/web/modals/search/message-search-utils.react.js b/web/modals/search/message-search-utils.react.js
index fa36f66f2..5498e3480 100644
--- a/web/modals/search/message-search-utils.react.js
+++ b/web/modals/search/message-search-utils.react.js
@@ -1,52 +1,51 @@
// @flow
import * as React from 'react';
import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { messageListData } from 'lib/selectors/chat-selectors.js';
import {
createMessageInfo,
modifyItemForResultScreen,
} from 'lib/shared/message-utils.js';
import { filterChatMessageInfosForSearch } from 'lib/shared/search-utils.js';
import type { RawMessageInfo } from 'lib/types/message-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useSelector } from '../../redux/redux-utils.js';
function useParseSearchResults(
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
searchResults: $ReadOnlyArray,
): $ReadOnlyArray {
const userInfos = useSelector(state => state.userStore.userInfos);
const translatedSearchResults = React.useMemo(() => {
const threadInfos = { [threadInfo.id]: threadInfo };
return searchResults
.map(rawMessageInfo =>
createMessageInfo(rawMessageInfo, null, userInfos, threadInfos),
)
.filter(Boolean);
}, [searchResults, threadInfo, userInfos]);
const chatMessageInfos = useSelector(
messageListData(threadInfo.id, translatedSearchResults),
);
const filteredChatMessageInfos = React.useMemo(
() =>
filterChatMessageInfosForSearch(
chatMessageInfos,
translatedSearchResults,
) ?? [],
[chatMessageInfos, translatedSearchResults],
);
return React.useMemo(
() => filteredChatMessageInfos.map(item => modifyItemForResultScreen(item)),
[filteredChatMessageInfos],
);
}
export { useParseSearchResults };
diff --git a/web/modals/threads/confirm-leave-thread-modal.react.js b/web/modals/threads/confirm-leave-thread-modal.react.js
index 47bfc31b3..4d846d402 100644
--- a/web/modals/threads/confirm-leave-thread-modal.react.js
+++ b/web/modals/threads/confirm-leave-thread-modal.react.js
@@ -1,54 +1,53 @@
// @flow
import * as React from 'react';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import { type ThreadInfo } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './confirm-leave-thread-modal.css';
import Button, { buttonThemes } from '../../components/button.react.js';
import Modal from '../modal.react.js';
type Props = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+onClose: () => void,
+onConfirm: () => void,
};
function ConfirmLeaveThreadModal(props: Props): React.Node {
const { threadInfo, onClose, onConfirm } = props;
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
{'Are you sure you want to leave "'}
{uiName}
{'"?'}
Cancel
Yes, leave chat
);
}
export default ConfirmLeaveThreadModal;
diff --git a/web/modals/threads/create/compose-subchannel-modal.react.js b/web/modals/threads/create/compose-subchannel-modal.react.js
index 13b53f9b2..635024369 100644
--- a/web/modals/threads/create/compose-subchannel-modal.react.js
+++ b/web/modals/threads/create/compose-subchannel-modal.react.js
@@ -1,279 +1,278 @@
// @flow
import * as React from 'react';
import {
useNewThread,
newThreadActionTypes,
} from 'lib/actions/thread-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { trimText } from 'lib/utils/text-utils.js';
import css from './compose-subchannel-modal.css';
import SubchannelMembers from './steps/subchannel-members.react.js';
import SubchannelSettings from './steps/subchannel-settings.react.js';
import type { VisibilityType } from './steps/subchannel-settings.react.js';
import Stepper from '../../../components/stepper.react.js';
import { updateNavInfoActionType } from '../../../redux/action-types.js';
import { useSelector } from '../../../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../../../selectors/nav-selectors.js';
import Modal from '../../modal.react.js';
type Props = {
+onClose: () => void,
- +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +parentThreadInfo: ThreadInfo,
};
const getThreadType = (visibility: VisibilityType, announcement: boolean) => {
if (visibility === 'open') {
return announcement
? threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD
: threadTypes.COMMUNITY_OPEN_SUBTHREAD;
} else {
return announcement
? threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD
: threadTypes.COMMUNITY_SECRET_SUBTHREAD;
}
};
type Steps = 'settings' | 'members';
type HeaderProps = {
+parentThreadName: string,
};
function ComposeSubchannelHeader(props: HeaderProps): React.Node {
const { parentThreadName } = props;
return (
{'within '}
{parentThreadName}
);
}
const createSubchannelLoadingStatusSelector =
createLoadingStatusSelector(newThreadActionTypes);
function ComposeSubchannelModal(props: Props): React.Node {
const { parentThreadInfo, onClose } = props;
const { uiName: parentThreadName } = useResolvedThreadInfo(parentThreadInfo);
const [activeStep, setActiveStep] = React.useState('settings');
const [channelName, setChannelName] = React.useState('');
const [visibilityType, setVisibilityType] =
React.useState('open');
const [announcement, setAnnouncement] = React.useState(false);
const [selectedUsers, setSelectedUsers] = React.useState<
$ReadOnlySet,
>(new Set());
const [searchUserText, setSearchUserText] = React.useState('');
const loadingState = useSelector(createSubchannelLoadingStatusSelector);
const [errorMessage, setErrorMessage] = React.useState('');
const calendarQuery = useSelector(nonThreadCalendarQuery);
const callNewThread = useNewThread();
const dispatchActionPromise = useDispatchActionPromise();
const dispatch = useDispatch();
const createSubchannel = React.useCallback(async () => {
try {
const threadType = getThreadType(visibilityType, announcement);
const query = calendarQuery();
const result = await callNewThread({
name: channelName,
type: threadType,
parentThreadID: parentThreadInfo.id,
initialMemberIDs: Array.from(selectedUsers),
calendarQuery: query,
color: parentThreadInfo.color,
});
return result;
} catch (e) {
await setErrorMessage('unknown error');
return null;
}
}, [
parentThreadInfo,
selectedUsers,
visibilityType,
announcement,
callNewThread,
calendarQuery,
channelName,
]);
const dispatchCreateSubchannel = React.useCallback(async () => {
await setErrorMessage('');
const response = createSubchannel();
await dispatchActionPromise(newThreadActionTypes, response);
const result = await response;
if (result) {
const { newThreadID } = result;
await dispatch({
type: updateNavInfoActionType,
payload: {
activeChatThreadID: newThreadID,
},
});
props.onClose();
}
}, [dispatchActionPromise, createSubchannel, props, dispatch]);
const onChangeChannelName = React.useCallback(
(event: SyntheticEvent) => {
const target = event.currentTarget;
setChannelName(target.value);
},
[],
);
const onOpenVisibilityTypeSelected = React.useCallback(
() => setVisibilityType('open'),
[],
);
const onSecretVisibilityTypeSelected = React.useCallback(
() => setVisibilityType('secret'),
[],
);
const onAnnouncementSelected = React.useCallback(
() => setAnnouncement(!announcement),
[announcement],
);
const toggleUserSelection = React.useCallback((userID: string) => {
setSelectedUsers((users: $ReadOnlySet) => {
const newUsers = new Set(users);
if (newUsers.has(userID)) {
newUsers.delete(userID);
} else {
newUsers.add(userID);
}
return newUsers;
});
}, []);
const subchannelSettings = React.useMemo(
() => (
),
[
channelName,
visibilityType,
announcement,
onChangeChannelName,
onOpenVisibilityTypeSelected,
onSecretVisibilityTypeSelected,
onAnnouncementSelected,
],
);
const stepperButtons = React.useMemo(
() => ({
settings: {
nextProps: {
content: 'Next',
disabled: !channelName.trim(),
onClick: () => {
setErrorMessage('');
setChannelName(channelName.trim());
setActiveStep('members');
},
},
},
members: {
prevProps: {
content: 'Back',
onClick: () => setActiveStep('settings'),
},
nextProps: {
content: 'Create',
loading: loadingState === 'loading',
disabled: selectedUsers.size === 0,
onClick: () => {
dispatchCreateSubchannel();
},
},
},
}),
[channelName, dispatchCreateSubchannel, loadingState, selectedUsers],
);
const subchannelMembers = React.useMemo(
() => (
),
[
selectedUsers,
toggleUserSelection,
parentThreadInfo,
searchUserText,
setSearchUserText,
],
);
const modalName =
activeStep === 'members'
? `Create channel - ${trimText(channelName, 11)}`
: 'Create channel';
return (
);
}
export default ComposeSubchannelModal;
diff --git a/web/modals/threads/create/steps/subchannel-members-list.react.js b/web/modals/threads/create/steps/subchannel-members-list.react.js
index 2b3ebe69c..397e1a24f 100644
--- a/web/modals/threads/create/steps/subchannel-members-list.react.js
+++ b/web/modals/threads/create/steps/subchannel-members-list.react.js
@@ -1,109 +1,105 @@
// @flow
import * as React from 'react';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { stringForUser } from 'lib/shared/user-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- RelativeMemberInfo,
- LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import type { RelativeMemberInfo, ThreadInfo } from 'lib/types/thread-types.js';
import type { UserListItem } from 'lib/types/user-types.js';
import { useSelector } from '../../../../redux/redux-utils.js';
import AddMembersList from '../../../components/add-members-list.react.js';
type Props = {
+searchText: string,
+searchResult: $ReadOnlySet,
- +communityThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
- +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +communityThreadInfo: ThreadInfo,
+ +parentThreadInfo: ThreadInfo,
+selectedUsers: $ReadOnlySet,
+toggleUserSelection: (userID: string) => void,
};
function SubchannelMembersList(props: Props): React.Node {
const {
searchText,
searchResult,
communityThreadInfo,
parentThreadInfo,
selectedUsers,
toggleUserSelection,
} = props;
const { name: communityName } = communityThreadInfo;
const currentUserId = useSelector(state => state.currentUserInfo?.id);
const parentMembersSet = React.useMemo(
() => new Set(parentThreadInfo.members.map(user => user.id)),
[parentThreadInfo],
);
const filterOutParentMembersWithENSNames = React.useCallback(
(members: $ReadOnlyArray) =>
members
.filter(
user =>
user.id !== currentUserId &&
(searchResult.has(user.id) || searchText.length === 0),
)
.map(user => ({ id: user.id, username: stringForUser(user) })),
[currentUserId, searchResult, searchText.length],
);
const parentMemberListWithoutENSNames = React.useMemo(
() => filterOutParentMembersWithENSNames(parentThreadInfo.members),
[filterOutParentMembersWithENSNames, parentThreadInfo.members],
);
const parentMemberList = useENSNames(
parentMemberListWithoutENSNames,
);
const filterOutOtherMembersWithENSNames = React.useCallback(
(members: $ReadOnlyArray) =>
members
.filter(
user =>
!parentMembersSet.has(user.id) &&
user.id !== currentUserId &&
(searchResult.has(user.id) || searchText.length === 0),
)
.map(user => ({ id: user.id, username: stringForUser(user) })),
[currentUserId, parentMembersSet, searchResult, searchText.length],
);
const otherMemberListWithoutENSNames = React.useMemo(
() => filterOutOtherMembersWithENSNames(communityThreadInfo.members),
[communityThreadInfo.members, filterOutOtherMembersWithENSNames],
);
const otherMemberList = useENSNames(
otherMemberListWithoutENSNames,
);
const sortedGroupedUserList = React.useMemo(
() =>
[
{ header: 'Users in parent channel', userInfos: parentMemberList },
{
header: `All users in ${communityName ?? 'community'}`,
userInfos: otherMemberList,
},
].filter(item => item.userInfos.length),
[parentMemberList, otherMemberList, communityName],
);
return (
);
}
export default SubchannelMembersList;
diff --git a/web/modals/threads/gallery/thread-settings-media-gallery.react.js b/web/modals/threads/gallery/thread-settings-media-gallery.react.js
index dd85f9b63..4ea81a680 100644
--- a/web/modals/threads/gallery/thread-settings-media-gallery.react.js
+++ b/web/modals/threads/gallery/thread-settings-media-gallery.react.js
@@ -1,193 +1,192 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { useFetchThreadMedia } from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import {
encryptedMediaBlobURI,
encryptedVideoThumbnailBlobURI,
} from 'lib/media/media-utils.js';
import type { Media } from 'lib/types/media-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import GalleryItem from './thread-settings-media-gallery-item.react.js';
import css from './thread-settings-media-gallery.css';
import Tabs from '../../../components/tabs.react.js';
import MultimediaModal from '../../../media/multimedia-modal.react.js';
import Modal from '../../modal.react.js';
type MediaGalleryTab = 'All' | 'Images' | 'Videos';
type ThreadSettingsMediaGalleryModalProps = {
+onClose: () => void,
- +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +parentThreadInfo: ThreadInfo,
+limit: number,
+activeTab: MediaGalleryTab,
};
function ThreadSettingsMediaGalleryModal(
props: ThreadSettingsMediaGalleryModalProps,
): React.Node {
const { pushModal } = useModalContext();
const { onClose, parentThreadInfo, limit, activeTab } = props;
const { id: threadID } = parentThreadInfo;
const modalName = 'Media';
const callFetchThreadMedia = useFetchThreadMedia();
const [mediaInfos, setMediaInfos] = React.useState<$ReadOnlyArray>([]);
const [tab, setTab] = React.useState(activeTab);
React.useEffect(() => {
const fetchData = async () => {
const result = await callFetchThreadMedia({
threadID,
limit,
offset: 0,
});
setMediaInfos(result.media);
};
fetchData();
}, [callFetchThreadMedia, threadID, limit]);
const onClick = React.useCallback(
(media: Media) => {
const thumbHash = media.thumbnailThumbHash ?? media.thumbHash;
let mediaInfo = {
thumbHash,
dimensions: media.dimensions,
};
if (media.type === 'photo' || media.type === 'video') {
const { uri, thumbnailURI } = media;
mediaInfo = {
...mediaInfo,
type: media.type,
uri,
thumbnailURI,
};
} else {
const { encryptionKey, thumbnailEncryptionKey } = media;
const thumbnailBlobURI =
media.type === 'encrypted_video'
? encryptedVideoThumbnailBlobURI(media)
: null;
mediaInfo = {
...mediaInfo,
type: media.type,
blobURI: encryptedMediaBlobURI(media),
encryptionKey,
thumbnailBlobURI,
thumbnailEncryptionKey,
};
}
pushModal( );
},
[pushModal],
);
const mediaGalleryItems = React.useMemo(() => {
let filteredMediaInfos = mediaInfos;
if (tab === 'Images') {
filteredMediaInfos = mediaInfos.filter(
mediaInfo =>
mediaInfo.type === 'photo' || mediaInfo.type === 'encrypted_photo',
);
} else if (tab === 'Videos') {
filteredMediaInfos = mediaInfos.filter(
mediaInfo =>
mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video',
);
}
return filteredMediaInfos.map((media, i) => {
let imageSource;
if (media.type === 'photo') {
imageSource = {
kind: 'plain',
uri: media.uri,
thumbHash: media.thumbHash,
};
} else if (media.type === 'video') {
imageSource = {
kind: 'plain',
uri: media.thumbnailURI,
thumbHash: media.thumbnailThumbHash,
};
} else if (media.type === 'encrypted_photo') {
imageSource = {
kind: 'encrypted',
blobURI: encryptedMediaBlobURI(media),
encryptionKey: media.encryptionKey,
thumbHash: media.thumbHash,
};
} else {
imageSource = {
kind: 'encrypted',
blobURI: encryptedVideoThumbnailBlobURI(media),
encryptionKey: media.thumbnailEncryptionKey,
thumbHash: media.thumbnailThumbHash,
};
}
return (
onClick(media)}
imageSource={imageSource}
imageCSSClass={css.media}
imageContainerCSSClass={css.mediaContainer}
/>
);
});
}, [tab, mediaInfos, onClick]);
const handleScroll = React.useCallback(
async (event: SyntheticEvent) => {
const container = event.target;
invariant(container instanceof HTMLDivElement, 'target not div');
// Load more data when the user is within 1000 pixels of the end
const buffer = 1000;
if (
container.scrollHeight - container.scrollTop >
container.clientHeight + buffer
) {
return;
}
const result = await callFetchThreadMedia({
threadID,
limit,
offset: mediaInfos.length,
});
setMediaInfos([...mediaInfos, ...result.media]);
},
[callFetchThreadMedia, threadID, limit, mediaInfos],
);
return (
{mediaGalleryItems}
{mediaGalleryItems}
{mediaGalleryItems}
);
}
export default ThreadSettingsMediaGalleryModal;
diff --git a/web/modals/threads/members/change-member-role-modal.react.js b/web/modals/threads/members/change-member-role-modal.react.js
index a6c69d676..aeee7020a 100644
--- a/web/modals/threads/members/change-member-role-modal.react.js
+++ b/web/modals/threads/members/change-member-role-modal.react.js
@@ -1,157 +1,153 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
useChangeThreadMemberRoles,
changeThreadMemberRolesActionTypes,
} from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors.js';
import { roleIsAdminRole } from 'lib/shared/thread-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- LegacyThreadInfo,
- RelativeMemberInfo,
-} from 'lib/types/thread-types';
+import type { RelativeMemberInfo, ThreadInfo } from 'lib/types/thread-types';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import { values } from 'lib/utils/objects.js';
import css from './change-member-role-modal.css';
import UserAvatar from '../../../avatars/user-avatar.react.js';
import Button, { buttonThemes } from '../../../components/button.react.js';
import Dropdown from '../../../components/dropdown.react.js';
import { useSelector } from '../../../redux/redux-utils.js';
import Modal from '../../modal.react.js';
import UnsavedChangesModal from '../../unsaved-changes-modal.react.js';
type ChangeMemberRoleModalProps = {
+memberInfo: RelativeMemberInfo,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function ChangeMemberRoleModal(props: ChangeMemberRoleModalProps): React.Node {
const { memberInfo, threadInfo } = props;
const { pushModal, popModal } = useModalContext();
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadMemberRoles = useChangeThreadMemberRoles();
const otherUsersButNoOtherAdminsValue = useSelector(
otherUsersButNoOtherAdmins(threadInfo.id),
);
const roleOptions = React.useMemo(
() =>
values(threadInfo.roles).map(role => ({
id: role.id,
name: role.name,
})),
[threadInfo.roles],
);
const initialSelectedRole = memberInfo.role;
invariant(initialSelectedRole, "Member's role must be defined");
const [selectedRole, setSelectedRole] = React.useState(initialSelectedRole);
const onCloseModal = React.useCallback(() => {
if (selectedRole === initialSelectedRole) {
popModal();
return;
}
pushModal( );
}, [initialSelectedRole, popModal, pushModal, selectedRole]);
const disabledRoleChangeMessage = React.useMemo(() => {
const memberIsAdmin = roleIsAdminRole(
threadInfo.roles[initialSelectedRole],
);
if (!otherUsersButNoOtherAdminsValue || !memberIsAdmin) {
return null;
}
return (
There must be at least one admin at any given time in a community.
);
}, [initialSelectedRole, otherUsersButNoOtherAdminsValue, threadInfo.roles]);
const onSave = React.useCallback(() => {
if (selectedRole === initialSelectedRole) {
popModal();
return;
}
const createChangeThreadMemberRolesPromise = () =>
callChangeThreadMemberRoles({
threadID: threadInfo.id,
memberIDs: [memberInfo.id],
newRole: selectedRole,
});
dispatchActionPromise(
changeThreadMemberRolesActionTypes,
createChangeThreadMemberRolesPromise(),
);
popModal();
}, [
callChangeThreadMemberRoles,
dispatchActionPromise,
initialSelectedRole,
memberInfo.id,
popModal,
selectedRole,
threadInfo.id,
]);
return (
Members can only be assigned to one role at a time. Changing a
member’s role will replace their previously assigned role.
{disabledRoleChangeMessage}
Back
Save
);
}
export default ChangeMemberRoleModal;
diff --git a/web/modals/threads/members/member.react.js b/web/modals/threads/members/member.react.js
index 85bfe5742..6b44a0a37 100644
--- a/web/modals/threads/members/member.react.js
+++ b/web/modals/threads/members/member.react.js
@@ -1,139 +1,135 @@
// @flow
import * as React from 'react';
import { useRemoveUsersFromThread } from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import {
removeMemberFromThread,
getAvailableThreadMemberActions,
} from 'lib/shared/thread-utils.js';
import { stringForUser } from 'lib/shared/user-utils.js';
import type { SetState } from 'lib/types/hook-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- LegacyThreadInfo,
- RelativeMemberInfo,
-} from 'lib/types/thread-types.js';
+import type { RelativeMemberInfo, ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import { useRolesFromCommunityThreadInfo } from 'lib/utils/role-utils.js';
import ChangeMemberRoleModal from './change-member-role-modal.react.js';
import css from './members-modal.css';
import UserAvatar from '../../../avatars/user-avatar.react.js';
import CommIcon from '../../../CommIcon.react.js';
import Label from '../../../components/label.react.js';
import MenuItem from '../../../components/menu-item.react.js';
import Menu from '../../../components/menu.react.js';
import { usePushUserProfileModal } from '../../user-profile/user-profile-utils.js';
const commIconComponent = ;
type Props = {
+memberInfo: RelativeMemberInfo,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+setOpenMenu: SetState,
};
function ThreadMember(props: Props): React.Node {
const { memberInfo, threadInfo, setOpenMenu } = props;
const { pushModal } = useModalContext();
const userName = stringForUser(memberInfo);
const roles = useRolesFromCommunityThreadInfo(threadInfo, [memberInfo]);
const roleName = roles.get(memberInfo.id)?.name;
const onMenuChange = React.useCallback(
(menuOpen: boolean) => {
if (menuOpen) {
setOpenMenu(() => memberInfo.id);
} else {
setOpenMenu(menu => (menu === memberInfo.id ? null : menu));
}
},
[memberInfo.id, setOpenMenu],
);
const dispatchActionPromise = useDispatchActionPromise();
const boundRemoveUsersFromThread = useRemoveUsersFromThread();
const onClickRemoveUser = React.useCallback(
() =>
removeMemberFromThread(
threadInfo,
memberInfo,
dispatchActionPromise,
boundRemoveUsersFromThread,
),
[boundRemoveUsersFromThread, dispatchActionPromise, memberInfo, threadInfo],
);
const onClickChangeRole = React.useCallback(() => {
pushModal(
,
);
}, [memberInfo, pushModal, threadInfo]);
const menuItems = React.useMemo(
() =>
getAvailableThreadMemberActions(memberInfo, threadInfo).map(action => {
if (action === 'change_role') {
return (
);
}
if (action === 'remove_user') {
return (
);
}
return null;
}),
[memberInfo, onClickRemoveUser, onClickChangeRole, threadInfo],
);
const userSettingsIcon = React.useMemo(
() => ,
[],
);
const label = React.useMemo(
() => {roleName} ,
[roleName],
);
const pushUserProfileModal = usePushUserProfileModal(memberInfo.id);
return (
{userName}
{label}
{menuItems}
);
}
export default ThreadMember;
diff --git a/web/modals/threads/members/members-list.react.js b/web/modals/threads/members/members-list.react.js
index c4e888a60..e91ade69c 100644
--- a/web/modals/threads/members/members-list.react.js
+++ b/web/modals/threads/members/members-list.react.js
@@ -1,82 +1,81 @@
// @flow
import classNames from 'classnames';
import _groupBy from 'lodash/fp/groupBy.js';
import _toPairs from 'lodash/fp/toPairs.js';
import * as React from 'react';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { stringForUser } from 'lib/shared/user-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import {
- type LegacyThreadInfo,
type RelativeMemberInfo,
+ type ThreadInfo,
} from 'lib/types/thread-types.js';
import ThreadMember from './member.react.js';
import css from './members-modal.css';
type Props = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+threadMembers: $ReadOnlyArray,
};
function ThreadMembersList(props: Props): React.Node {
const { threadMembers, threadInfo } = props;
const [openMenu, setOpenMenu] = React.useState(null);
const hasMembers = threadMembers.length > 0;
const threadMembersWithENSNames = useENSNames(threadMembers);
const groupedByFirstLetterMembers = React.useMemo(
() =>
_groupBy(member => stringForUser(member)[0].toLowerCase())(
threadMembersWithENSNames,
),
[threadMembersWithENSNames],
);
const groupedMembersList = React.useMemo(
() =>
_toPairs(groupedByFirstLetterMembers)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([letter, users]) => {
const userList = users
.sort((a, b) => stringForUser(a).localeCompare(stringForUser(b)))
.map((user: RelativeMemberInfo) => (
));
const letterHeader = (
{letter.toUpperCase()}
);
return (
{letterHeader}
{userList}
);
}),
[groupedByFirstLetterMembers, threadInfo],
);
let content = groupedMembersList;
if (!hasMembers) {
content = (
No matching users were found in the chat!
);
}
const membersListClasses = classNames(css.membersList, {
[css.noScroll]: !!openMenu,
});
return {content}
;
}
export default ThreadMembersList;
diff --git a/web/modals/threads/settings/thread-settings-delete-confirmation-modal.react.js b/web/modals/threads/settings/thread-settings-delete-confirmation-modal.react.js
index 4a23b792c..8429734f4 100644
--- a/web/modals/threads/settings/thread-settings-delete-confirmation-modal.react.js
+++ b/web/modals/threads/settings/thread-settings-delete-confirmation-modal.react.js
@@ -1,55 +1,54 @@
// @flow
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { getThreadsToDeleteText } from 'lib/shared/thread-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types';
+import type { ThreadInfo } from 'lib/types/thread-types';
import css from './thread-settings-delete-confirmation-modal.css';
import Button from '../../../components/button.react.js';
import Modal from '../../modal.react.js';
type BaseProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+onConfirmation: () => mixed,
};
function ThreadDeleteConfirmationModal({
threadInfo,
onConfirmation,
}: BaseProps): React.Node {
const { popModal } = useModalContext();
const threadsToDeleteText = React.useMemo(
() => getThreadsToDeleteText(threadInfo),
[threadInfo],
);
return (
{threadsToDeleteText} will also be permanently deleted. Are you sure
you want to continue?
No
Yes
);
}
export default ThreadDeleteConfirmationModal;
diff --git a/web/modals/threads/settings/thread-settings-delete-tab.react.js b/web/modals/threads/settings/thread-settings-delete-tab.react.js
index b401d6ae5..7115774a7 100644
--- a/web/modals/threads/settings/thread-settings-delete-tab.react.js
+++ b/web/modals/threads/settings/thread-settings-delete-tab.react.js
@@ -1,132 +1,131 @@
// @flow
import * as React from 'react';
import {
deleteThreadActionTypes,
useDeleteThread,
} from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { containedThreadInfos } from 'lib/selectors/thread-selectors.js';
import { type SetState } from 'lib/types/hook-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import SubmitSection from './submit-section.react.js';
import ThreadDeleteConfirmationModal from './thread-settings-delete-confirmation-modal.react.js';
import css from './thread-settings-delete-tab.css';
import { buttonThemes } from '../../../components/button.react.js';
import { useSelector } from '../../../redux/redux-utils.js';
type ThreadSettingsDeleteTabProps = {
+threadSettingsOperationInProgress: boolean,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+setErrorMessage: SetState,
+errorMessage?: ?string,
};
function ThreadSettingsDeleteTab(
props: ThreadSettingsDeleteTabProps,
): React.Node {
const {
threadSettingsOperationInProgress,
threadInfo,
setErrorMessage,
errorMessage,
} = props;
const modalContext = useModalContext();
const dispatchActionPromise = useDispatchActionPromise();
const callDeleteThread = useDeleteThread();
const containedThreads = useSelector(
state => containedThreadInfos(state)[threadInfo.id],
);
const shouldUseDeleteConfirmationModal = React.useMemo(
() => containedThreads?.length > 0,
[containedThreads?.length],
);
const popThreadDeleteConfirmationModal = React.useCallback(() => {
if (shouldUseDeleteConfirmationModal) {
modalContext.popModal();
}
}, [modalContext, shouldUseDeleteConfirmationModal]);
const deleteThreadAction = React.useCallback(async () => {
try {
setErrorMessage('');
const response = await callDeleteThread({ threadID: threadInfo.id });
popThreadDeleteConfirmationModal();
modalContext.popModal();
return response;
} catch (e) {
popThreadDeleteConfirmationModal();
setErrorMessage(
e.message === 'invalid_credentials'
? 'permission not granted'
: 'unknown error',
);
throw e;
}
}, [
callDeleteThread,
modalContext,
popThreadDeleteConfirmationModal,
setErrorMessage,
threadInfo.id,
]);
const dispatchDeleteThreadAction = React.useCallback(() => {
dispatchActionPromise(deleteThreadActionTypes, deleteThreadAction());
}, [dispatchActionPromise, deleteThreadAction]);
const onDelete = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
if (shouldUseDeleteConfirmationModal) {
modalContext.pushModal(
,
);
} else {
dispatchDeleteThreadAction();
}
},
[
dispatchDeleteThreadAction,
modalContext,
shouldUseDeleteConfirmationModal,
threadInfo,
],
);
return (
);
}
export default ThreadSettingsDeleteTab;
diff --git a/web/modals/threads/settings/thread-settings-general-tab.react.js b/web/modals/threads/settings/thread-settings-general-tab.react.js
index 67ed75d5d..50af1e643 100644
--- a/web/modals/threads/settings/thread-settings-general-tab.react.js
+++ b/web/modals/threads/settings/thread-settings-general-tab.react.js
@@ -1,203 +1,199 @@
// @flow
import * as React from 'react';
import tinycolor from 'tinycolor2';
import {
changeThreadSettingsActionTypes,
useChangeThreadSettings,
} from 'lib/actions/thread-actions.js';
import { threadHasPermission } from 'lib/shared/thread-utils.js';
import { type SetState } from 'lib/types/hook-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
-import {
- type LegacyThreadInfo,
- type ThreadChanges,
-} from 'lib/types/thread-types.js';
+import { type ThreadChanges, type ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import { firstLine } from 'lib/utils/string-utils.js';
import { chatNameMaxLength } from 'lib/utils/validation-utils.js';
import SubmitSection from './submit-section.react.js';
import css from './thread-settings-general-tab.css';
import EditThreadAvatar from '../../../avatars/edit-thread-avatar.react.js';
import LoadingIndicator from '../../../loading-indicator.react.js';
import Input from '../../input.react.js';
import ColorSelector from '../color-selector.react.js';
type ThreadSettingsGeneralTabProps = {
+threadSettingsOperationInProgress: boolean,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+threadNamePlaceholder: string,
+queuedChanges: ThreadChanges,
+setQueuedChanges: SetState,
+setErrorMessage: SetState,
+errorMessage?: ?string,
};
function ThreadSettingsGeneralTab(
props: ThreadSettingsGeneralTabProps,
): React.Node {
const {
threadSettingsOperationInProgress,
threadInfo,
threadNamePlaceholder,
queuedChanges,
setQueuedChanges,
setErrorMessage,
errorMessage,
} = props;
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadSettings = useChangeThreadSettings();
const nameInputRef = React.useRef();
React.useEffect(() => {
nameInputRef.current?.focus();
}, [threadSettingsOperationInProgress]);
const changeQueued: boolean = React.useMemo(
() => Object.values(queuedChanges).some(v => v !== null && v !== undefined),
[queuedChanges],
);
const onChangeName = React.useCallback(
(event: SyntheticEvent) => {
const target = event.currentTarget;
const newName = firstLine(target.value);
setQueuedChanges(prevQueuedChanges =>
Object.freeze({
...prevQueuedChanges,
name: newName !== threadInfo.name ? newName : undefined,
}),
);
},
[setQueuedChanges, threadInfo.name],
);
const onChangeDescription = React.useCallback(
(event: SyntheticEvent) => {
const target = event.currentTarget;
setQueuedChanges(prevQueuedChanges =>
Object.freeze({
...prevQueuedChanges,
description:
target.value !== threadInfo.description ? target.value : undefined,
}),
);
},
[setQueuedChanges, threadInfo.description],
);
const onChangeColor = React.useCallback(
(color: string) => {
setQueuedChanges(prevQueuedChanges =>
Object.freeze({
...prevQueuedChanges,
color: !tinycolor.equals(color, threadInfo.color) ? color : undefined,
}),
);
},
[setQueuedChanges, threadInfo.color],
);
const changeThreadSettingsAction = React.useCallback(async () => {
try {
setErrorMessage('');
return await callChangeThreadSettings({
threadID: threadInfo.id,
changes: queuedChanges,
});
} catch (e) {
setErrorMessage('unknown_error');
throw e;
} finally {
setQueuedChanges(Object.freeze({}));
}
}, [
callChangeThreadSettings,
queuedChanges,
setErrorMessage,
setQueuedChanges,
threadInfo.id,
]);
const onSubmit = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatchActionPromise(
changeThreadSettingsActionTypes,
changeThreadSettingsAction(),
);
},
[changeThreadSettingsAction, dispatchActionPromise],
);
const threadNameInputDisabled = !threadHasPermission(
threadInfo,
threadPermissions.EDIT_THREAD_NAME,
);
const saveButtonContent = React.useMemo(() => {
if (threadSettingsOperationInProgress) {
return ;
}
return 'Save';
}, [threadSettingsOperationInProgress]);
return (
);
}
export default ThreadSettingsGeneralTab;
diff --git a/web/modals/threads/settings/thread-settings-modal.react.js b/web/modals/threads/settings/thread-settings-modal.react.js
index f465cd6bd..c02fac08a 100644
--- a/web/modals/threads/settings/thread-settings-modal.react.js
+++ b/web/modals/threads/settings/thread-settings-modal.react.js
@@ -1,240 +1,240 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
deleteThreadActionTypes,
changeThreadSettingsActionTypes,
} from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils.js';
import {
threadHasPermission,
getSingleOtherUser,
threadUIName,
} from 'lib/shared/thread-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { RelationshipButton } from 'lib/types/relationship-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
import {
type LegacyThreadInfo,
type ThreadChanges,
+ type ThreadInfo,
} from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import ThreadSettingsDeleteTab from './thread-settings-delete-tab.react.js';
import ThreadSettingsGeneralTab from './thread-settings-general-tab.react.js';
import css from './thread-settings-modal.css';
import ThreadSettingsPrivacyTab from './thread-settings-privacy-tab.react.js';
import ThreadSettingsRelationshipTab from './thread-settings-relationship-tab.react.js';
import Tabs from '../../../components/tabs.react.js';
import { useSelector } from '../../../redux/redux-utils.js';
import Modal from '../../modal.react.js';
type TabType = 'general' | 'privacy' | 'delete' | 'relationship';
type BaseProps = {
+threadID: string,
};
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 threadInfo: ?LegacyThreadInfo = useSelector(
state => threadInfoSelector(state)[props.threadID],
);
const modalContext = useModalContext();
const [errorMessage, setErrorMessage] = React.useState('');
const [currentTabType, setCurrentTabType] =
React.useState('general');
const [queuedChanges, setQueuedChanges] = React.useState(
Object.freeze({}),
);
const threadInfoWithNoName = React.useMemo(() => {
invariant(threadInfo, 'threadInfo should exist in threadInfoWithNoName');
if (threadInfo.name === null || threadInfo.name === undefined) {
return threadInfo;
}
const withNoName = { ...threadInfo, name: undefined };
return {
...withNoName,
uiName: threadUIName(withNoName),
};
}, [threadInfo]);
const resolvedThreadInfo = useResolvedThreadInfo(threadInfoWithNoName);
const namePlaceholder = resolvedThreadInfo.uiName;
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const userInfos = useSelector(state => state.userStore.userInfos);
const otherMemberID = React.useMemo(() => {
if (!threadInfo) {
return null;
}
return getSingleOtherUser(threadInfo, viewerID);
}, [threadInfo, viewerID]);
const otherUserInfo = otherMemberID ? userInfos[otherMemberID] : null;
const availableRelationshipActions = React.useMemo(() => {
if (!otherUserInfo) {
return ([]: RelationshipButton[]);
}
return getAvailableRelationshipButtons(otherUserInfo);
}, [otherUserInfo]);
const hasPermissionForTab = React.useCallback(
// ESLint doesn't recognize that invariant always throws
// eslint-disable-next-line consistent-return
- (thread: LegacyThreadInfo | MinimallyEncodedThreadInfo, tab: TabType) => {
+ (thread: ThreadInfo, tab: TabType) => {
if (tab === 'general') {
return (
threadHasPermission(thread, threadPermissions.EDIT_THREAD_NAME) ||
threadHasPermission(thread, threadPermissions.EDIT_THREAD_COLOR) ||
threadHasPermission(
thread,
threadPermissions.EDIT_THREAD_DESCRIPTION,
)
);
} else if (tab === 'privacy') {
return threadHasPermission(
thread,
threadPermissions.EDIT_PERMISSIONS,
);
} else if (tab === 'delete') {
return threadHasPermission(thread, threadPermissions.DELETE_THREAD);
} else if (tab === 'relationship') {
return true;
}
invariant(false, `invalid tab: ${tab}`);
},
[],
);
React.useEffect(() => {
if (
threadInfo &&
currentTabType !== 'general' &&
!hasPermissionForTab(threadInfo, currentTabType)
) {
setCurrentTabType('general');
}
}, [currentTabType, hasPermissionForTab, threadInfo]);
React.useEffect(() => () => setErrorMessage(''), [currentTabType]);
if (!threadInfo) {
return (
You no longer have permission to view this chat
);
}
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 =
(queuedChanges['parentThreadID'] ?? threadInfo['parentThreadID']) &&
!threadInfo.sourceMessageID &&
(threadInfo.type === threadTypes.COMMUNITY_OPEN_SUBTHREAD ||
threadInfo.type === threadTypes.COMMUNITY_SECRET_SUBTHREAD);
if (canSeePrivacyTab) {
tabs.push(
,
);
}
if (availableRelationshipActions.length > 0 && otherUserInfo) {
tabs.push(
,
);
}
const canDeleteThread = hasPermissionForTab(threadInfo, 'delete');
if (canDeleteThread) {
tabs.push(
,
);
}
return (
{tabs}
);
});
export default ConnectedThreadSettingsModal;
diff --git a/web/modals/threads/settings/thread-settings-privacy-tab.react.js b/web/modals/threads/settings/thread-settings-privacy-tab.react.js
index d316c2bf7..76439b4f7 100644
--- a/web/modals/threads/settings/thread-settings-privacy-tab.react.js
+++ b/web/modals/threads/settings/thread-settings-privacy-tab.react.js
@@ -1,177 +1,173 @@
// @flow
import * as React from 'react';
import {
useChangeThreadSettings,
changeThreadSettingsActionTypes,
} from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { threadTypeDescriptions } from 'lib/shared/thread-utils.js';
import { type SetState } from 'lib/types/hook-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
-import {
- type LegacyThreadInfo,
- type ThreadChanges,
-} from 'lib/types/thread-types.js';
+import { type ThreadChanges, type ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import SubmitSection from './submit-section.react.js';
import css from './thread-settings-privacy-tab.css';
import EnumSettingsOption from '../../../components/enum-settings-option.react.js';
const { COMMUNITY_OPEN_SUBTHREAD, COMMUNITY_SECRET_SUBTHREAD } = threadTypes;
const openStatements = [
{
statement: threadTypeDescriptions[COMMUNITY_OPEN_SUBTHREAD],
isStatementValid: true,
styleStatementBasedOnValidity: false,
},
];
const secretStatements = [
{
statement: threadTypeDescriptions[COMMUNITY_SECRET_SUBTHREAD],
isStatementValid: true,
styleStatementBasedOnValidity: false,
},
];
type ThreadSettingsPrivacyTabProps = {
+threadSettingsOperationInProgress: boolean,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
+queuedChanges: ThreadChanges,
+setQueuedChanges: SetState,
+setErrorMessage: SetState,
+errorMessage?: ?string,
};
function ThreadSettingsPrivacyTab(
props: ThreadSettingsPrivacyTabProps,
): React.Node {
const {
threadSettingsOperationInProgress,
threadInfo,
queuedChanges,
setQueuedChanges,
setErrorMessage,
errorMessage,
} = props;
const modalContext = useModalContext();
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadSettings = useChangeThreadSettings();
const changeQueued: boolean = React.useMemo(
() => Object.values(queuedChanges).some(v => v !== null && v !== undefined),
[queuedChanges],
);
const changeThreadSettingsAction = React.useCallback(async () => {
try {
setErrorMessage('');
const response = await callChangeThreadSettings({
threadID: threadInfo.id,
changes: queuedChanges,
});
modalContext.popModal();
return response;
} catch (e) {
setErrorMessage('unknown_error');
setQueuedChanges(Object.freeze({}));
throw e;
}
}, [
callChangeThreadSettings,
modalContext,
queuedChanges,
setErrorMessage,
setQueuedChanges,
threadInfo.id,
]);
const onSubmit = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatchActionPromise(
changeThreadSettingsActionTypes,
changeThreadSettingsAction(),
);
},
[changeThreadSettingsAction, dispatchActionPromise],
);
const onOpenSelected = React.useCallback(() => {
setQueuedChanges(prevQueuedChanges =>
Object.freeze({
...prevQueuedChanges,
type:
COMMUNITY_OPEN_SUBTHREAD !== threadInfo.type
? COMMUNITY_OPEN_SUBTHREAD
: undefined,
}),
);
}, [setQueuedChanges, threadInfo.type]);
const onSecretSelected = React.useCallback(() => {
setQueuedChanges(prevQueuedChanges =>
Object.freeze({
...prevQueuedChanges,
type:
COMMUNITY_SECRET_SUBTHREAD !== threadInfo.type
? COMMUNITY_SECRET_SUBTHREAD
: undefined,
}),
);
}, [setQueuedChanges, threadInfo.type]);
const globeIcon = React.useMemo(
() => ,
[],
);
const lockIcon = React.useMemo(
() => ,
[],
);
return (
Chat type
Save
);
}
export default ThreadSettingsPrivacyTab;
diff --git a/web/modals/user-profile/user-profile-menu.react.js b/web/modals/user-profile/user-profile-menu.react.js
index 89f1feb17..7500024f2 100644
--- a/web/modals/user-profile/user-profile-menu.react.js
+++ b/web/modals/user-profile/user-profile-menu.react.js
@@ -1,112 +1,111 @@
// @flow
import { faUserMinus, faUserShield } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { userRelationshipStatus } from 'lib/types/relationship-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types';
+import type { ThreadInfo } from 'lib/types/thread-types';
import MenuItem from '../../components/menu-item.react.js';
import Menu from '../../components/menu.react.js';
const menuIcon = ;
const unfriendIcon = ;
const blockIcon = ;
const unblockIcon = ;
type Props = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function UserProfileMenu(props: Props): React.Node {
const { threadInfo } = props;
const {
otherUserInfo,
callbacks: { unfriendUser, blockUser, unblockUser },
} = useRelationshipPrompt(threadInfo);
const unfriendMenuIcon = React.useMemo(
() => (
),
[unfriendUser],
);
const blockMenuItem = React.useMemo(
() => (
),
[blockUser],
);
const unblockMenuItem = React.useMemo(
() => (
),
[unblockUser],
);
const menuItems = React.useMemo(() => {
const items = [];
if (otherUserInfo?.relationshipStatus === userRelationshipStatus.FRIEND) {
items.push(unfriendMenuIcon);
items.push(blockMenuItem);
} else if (
otherUserInfo?.relationshipStatus ===
userRelationshipStatus.BOTH_BLOCKED ||
otherUserInfo?.relationshipStatus ===
userRelationshipStatus.BLOCKED_BY_VIEWER
) {
items.push(unblockMenuItem);
} else {
items.push(blockMenuItem);
}
return items;
}, [
blockMenuItem,
otherUserInfo?.relationshipStatus,
unblockMenuItem,
unfriendMenuIcon,
]);
const userProfileMenu = React.useMemo(() => {
if (!otherUserInfo) {
return null;
}
return (
{menuItems}
);
}, [menuItems, otherUserInfo]);
return userProfileMenu;
}
export default UserProfileMenu;
diff --git a/web/modals/user-profile/user-profile-message-button.react.js b/web/modals/user-profile/user-profile-message-button.react.js
index 9ef3d190b..8e1482de8 100644
--- a/web/modals/user-profile/user-profile-message-button.react.js
+++ b/web/modals/user-profile/user-profile-message-button.react.js
@@ -1,49 +1,48 @@
// @flow
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import css from './user-profile.css';
import Button from '../../components/button.react.js';
import { useOnClickThread } from '../../selectors/thread-selectors.js';
type Props = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function UserProfileMessageButton(props: Props): React.Node {
const { threadInfo } = props;
const { clearModals } = useModalContext();
const onClickThread = useOnClickThread(threadInfo);
const onClickMessageButton = React.useCallback(
(event: SyntheticEvent) => {
clearModals();
onClickThread(event);
},
[clearModals, onClickThread],
);
const userProfileMessageButton = React.useMemo(
() => (
Message
),
[onClickMessageButton],
);
return userProfileMessageButton;
}
export default UserProfileMessageButton;
diff --git a/web/navigation-panels/chat-thread-ancestors.react.js b/web/navigation-panels/chat-thread-ancestors.react.js
index e72a7202a..75a136bf3 100644
--- a/web/navigation-panels/chat-thread-ancestors.react.js
+++ b/web/navigation-panels/chat-thread-ancestors.react.js
@@ -1,74 +1,73 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import { ChevronRight } from 'react-feather';
import { useAncestorThreads } from 'lib/shared/ancestor-threads.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './chat-thread-ancestors.css';
type ThreadAncestorsProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function ThreadAncestors(props: ThreadAncestorsProps): React.Node {
const { threadInfo } = props;
const ancestorThreadsWithCommunity = useAncestorThreads(threadInfo);
const community = ancestorThreadsWithCommunity[0] ?? threadInfo;
const resolvedCommunity = useResolvedThreadInfo(community);
const threadHasNoAncestors = community === threadInfo;
const ancestorThreads = ancestorThreadsWithCommunity.slice(1);
const chevronRight = React.useMemo(() => {
if (threadHasNoAncestors) {
return null;
}
return ;
}, [threadHasNoAncestors]);
const { uiName } = useResolvedThreadInfo(threadInfo);
const path = React.useMemo(() => {
if (threadHasNoAncestors) {
return null;
}
const ancestors = ancestorThreads.map(ancestor => (
));
const chatNameClasses = classnames(css.ancestorName, css.chatName);
return (
);
}, [ancestorThreads, threadHasNoAncestors, uiName]);
return (
{resolvedCommunity.uiName}
{chevronRight}
{path}
);
}
function ThreadAncestor(props: ThreadAncestorsProps): React.Node {
const { uiName } = useResolvedThreadInfo(props.threadInfo);
const chevronClasses = classnames(css.ancestorSeparator, css.chevronRight);
return (
<>
{uiName}
>
);
}
export default ThreadAncestors;
diff --git a/web/navigation-panels/nav-state-info-bar.react.js b/web/navigation-panels/nav-state-info-bar.react.js
index 336e7b6c3..10709b3e7 100644
--- a/web/navigation-panels/nav-state-info-bar.react.js
+++ b/web/navigation-panels/nav-state-info-bar.react.js
@@ -1,68 +1,67 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import ThreadAncestors from './chat-thread-ancestors.react.js';
import css from './nav-state-info-bar.css';
import ThreadAvatar from '../avatars/thread-avatar.react.js';
type NavStateInfoBarProps = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function NavStateInfoBar(props: NavStateInfoBarProps): React.Node {
const { threadInfo } = props;
return (
<>
>
);
}
type PossiblyEmptyNavStateInfoBarProps = {
- +threadInfoInput: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo,
+ +threadInfoInput: ?ThreadInfo,
};
function PossiblyEmptyNavStateInfoBar(
props: PossiblyEmptyNavStateInfoBarProps,
): React.Node {
const { threadInfoInput } = props;
const [threadInfo, setThreadInfo] = React.useState(threadInfoInput);
React.useEffect(() => {
if (threadInfoInput !== threadInfo) {
if (threadInfoInput) {
setThreadInfo(threadInfoInput);
} else {
const timeout = setTimeout(() => {
setThreadInfo(null);
}, 200);
return () => clearTimeout(timeout);
}
}
return undefined;
}, [threadInfoInput, threadInfo]);
const content = React.useMemo(() => {
if (threadInfo) {
return ;
} else {
return null;
}
}, [threadInfo]);
const classes = classnames(css.topBarContainer, {
[css.hide]: !threadInfoInput,
[css.show]: threadInfoInput,
});
return {content}
;
}
export default PossiblyEmptyNavStateInfoBar;
diff --git a/web/selectors/thread-selectors.js b/web/selectors/thread-selectors.js
index 51141b5bb..6a46e4f09 100644
--- a/web/selectors/thread-selectors.js
+++ b/web/selectors/thread-selectors.js
@@ -1,163 +1,162 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { createSelector } from 'reselect';
import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js';
import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
import {
createPendingSidebar,
threadInHomeChatList,
} from 'lib/shared/thread-utils.js';
import type {
ComposableMessageInfo,
RobotextMessageInfo,
} from 'lib/types/message-types.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type {
- LegacyThreadInfo,
LegacyRawThreadInfos,
+ ThreadInfo,
} from 'lib/types/thread-types.js';
import { values } from 'lib/utils/objects.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { getDefaultTextMessageRules } from '../markdown/rules.react.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import type { AppState } from '../redux/redux-setup.js';
import { useSelector } from '../redux/redux-utils.js';
function useOnClickThread(
- thread: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo,
+ thread: ?ThreadInfo,
): (event: SyntheticEvent) => void {
const dispatch = useDispatch();
return React.useCallback(
(event: SyntheticEvent) => {
invariant(
thread?.id,
'useOnClickThread should be called with threadID set',
);
event.preventDefault();
const { id: threadID } = thread;
let payload;
if (threadID.includes('pending')) {
payload = {
chatMode: 'view',
activeChatThreadID: threadID,
pendingThread: thread,
tab: 'chat',
};
} else {
payload = {
chatMode: 'view',
activeChatThreadID: threadID,
tab: 'chat',
};
}
dispatch({ type: updateNavInfoActionType, payload });
},
[dispatch, thread],
);
}
function useThreadIsActive(threadID: string): boolean {
return useSelector(state => threadID === state.navInfo.activeChatThreadID);
}
function useOnClickPendingSidebar(
messageInfo: ComposableMessageInfo | RobotextMessageInfo,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
): (event: SyntheticEvent) => mixed {
const dispatch = useDispatch();
const loggedInUserInfo = useLoggedInUserInfo();
const cacheContext = React.useContext(ENSCacheContext);
const { getENSNames } = cacheContext;
const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo);
return React.useCallback(
async (event: SyntheticEvent) => {
event.preventDefault();
if (!loggedInUserInfo) {
return;
}
const pendingSidebarInfo = await createPendingSidebar({
sourceMessageInfo: messageInfo,
parentThreadInfo: threadInfo,
loggedInUserInfo,
markdownRules: getDefaultTextMessageRules(chatMentionCandidates)
.simpleMarkdownRules,
getENSNames,
});
dispatch({
type: updateNavInfoActionType,
payload: {
activeChatThreadID: pendingSidebarInfo.id,
pendingThread: pendingSidebarInfo,
},
});
},
[
loggedInUserInfo,
chatMentionCandidates,
threadInfo,
messageInfo,
getENSNames,
dispatch,
],
);
}
function useOnClickNewThread(): (event: SyntheticEvent) => void {
const dispatch = useDispatch();
return React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatch({
type: updateNavInfoActionType,
payload: {
chatMode: 'create',
selectedUserList: [],
},
});
},
[dispatch],
);
}
function useDrawerSelectedThreadID(): ?string {
const activeChatThreadID = useSelector(
state => state.navInfo.activeChatThreadID,
);
const pickedCommunityID = useSelector(
state => state.communityPickerStore.calendar,
);
const inCalendar = useSelector(state => state.navInfo.tab === 'calendar');
return inCalendar ? pickedCommunityID : activeChatThreadID;
}
const unreadCountInSelectedCommunity: (state: AppState) => number =
createSelector(
(state: AppState) => state.threadStore.threadInfos,
(state: AppState) => state.communityPickerStore.chat,
(threadInfos: LegacyRawThreadInfos, communityID: ?string): number =>
values(threadInfos).filter(
threadInfo =>
threadInHomeChatList(threadInfo) &&
threadInfo.currentUser.unread &&
(!communityID || communityID === threadInfo.community),
).length,
);
export {
useOnClickThread,
useThreadIsActive,
useOnClickPendingSidebar,
useOnClickNewThread,
useDrawerSelectedThreadID,
unreadCountInSelectedCommunity,
};
diff --git a/web/sidebar/community-drawer-item-community-handlers.react.js b/web/sidebar/community-drawer-item-community-handlers.react.js
index 580b26d48..7887037a3 100644
--- a/web/sidebar/community-drawer-item-community-handlers.react.js
+++ b/web/sidebar/community-drawer-item-community-handlers.react.js
@@ -1,117 +1,116 @@
// @flow
import * as React from 'react';
import {
updateCalendarCommunityFilter,
updateChatCommunityFilter,
clearChatCommunityFilter,
} from 'lib/actions/community-actions.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import type { CommunityDrawerItemCommunityHandler } from './community-drawer-item-handler.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { useCommunityIsPickedCalendar } from '../selectors/calendar-selectors.js';
import {
useOnClickThread,
useThreadIsActive,
} from '../selectors/thread-selectors.js';
import type { NavigationTab } from '../types/nav-types.js';
export type HandlerProps = {
+setHandler: (handler: CommunityDrawerItemCommunityHandler) => void,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function ChatDrawerItemCommunityHandler(props: HandlerProps): React.Node {
const { setHandler, threadInfo } = props;
const onClickThread = useOnClickThread(threadInfo);
const isActive = useThreadIsActive(threadInfo.id);
const dispatch = useDispatch();
const openCommunityID = useSelector(state => state.communityPickerStore.chat);
const expanded = openCommunityID === threadInfo.id;
const onClick = React.useCallback(
(event: SyntheticEvent) => {
if (!isActive) {
onClickThread(event);
}
if (openCommunityID === threadInfo.id && isActive) {
dispatch({
type: clearChatCommunityFilter,
});
return;
}
const community = threadInfo.community ?? threadInfo.id;
dispatch({
type: updateChatCommunityFilter,
payload: community,
});
},
[
dispatch,
isActive,
onClickThread,
openCommunityID,
threadInfo.community,
threadInfo.id,
],
);
const handler = React.useMemo(
() => ({ onClick, isActive, expanded }),
[expanded, isActive, onClick],
);
React.useEffect(() => {
setHandler(handler);
}, [handler, setHandler]);
return null;
}
function CalendarDrawerItemCommunityHandler(props: HandlerProps): React.Node {
const { setHandler, threadInfo } = props;
const dispatch = useDispatch();
const onClick = React.useCallback(() => {
dispatch({
type: updateCalendarCommunityFilter,
payload: threadInfo.id,
});
}, [dispatch, threadInfo.id]);
const isActive = useCommunityIsPickedCalendar(threadInfo.id);
const expanded = false;
const handler = React.useMemo(
() => ({ onClick, isActive, expanded }),
[onClick, isActive, expanded],
);
React.useEffect(() => {
setHandler(handler);
}, [handler, setHandler]);
return null;
}
const communityDrawerItemCommunityHandlers: {
+[tab: NavigationTab]: React.ComponentType,
} = Object.freeze({
chat: ChatDrawerItemCommunityHandler,
calendar: CalendarDrawerItemCommunityHandler,
});
function getCommunityDrawerItemCommunityHandler(
tab: NavigationTab,
): React.ComponentType {
return (
communityDrawerItemCommunityHandlers[tab] ?? ChatDrawerItemCommunityHandler
);
}
export { getCommunityDrawerItemCommunityHandler };
diff --git a/web/sidebar/community-drawer-item-handlers.react.js b/web/sidebar/community-drawer-item-handlers.react.js
index 6b182ff14..5db2946b5 100644
--- a/web/sidebar/community-drawer-item-handlers.react.js
+++ b/web/sidebar/community-drawer-item-handlers.react.js
@@ -1,75 +1,74 @@
// @flow
import * as React from 'react';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import type { CommunityDrawerItemHandler } from './community-drawer-item-handler.react.js';
import { useCommunityIsPickedCalendar } from '../selectors/calendar-selectors.js';
import {
useOnClickThread,
useThreadIsActive,
} from '../selectors/thread-selectors.js';
import type { NavigationTab } from '../types/nav-types.js';
export type HandlerProps = {
+setHandler: (handler: CommunityDrawerItemHandler) => void,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function ChatDrawerItemHandler(props: HandlerProps): React.Node {
const { setHandler, threadInfo } = props;
const onClick = useOnClickThread(threadInfo);
const isActive = useThreadIsActive(threadInfo.id);
const [expanded, setExpanded] = React.useState(false);
const toggleExpanded = React.useCallback(() => {
setExpanded(isExpanded => !isExpanded);
}, []);
const handler = React.useMemo(
() => ({ onClick, isActive, expanded, toggleExpanded }),
[expanded, isActive, onClick, toggleExpanded],
);
React.useEffect(() => {
setHandler(handler);
}, [handler, setHandler]);
return null;
}
const onClick = () => {};
const expanded = false;
const toggleExpanded = () => {};
function CalendarDrawerItemHandler(props: HandlerProps): React.Node {
const { setHandler, threadInfo } = props;
const isActive = useCommunityIsPickedCalendar(threadInfo.id);
const handler = React.useMemo(
() => ({ onClick, isActive, expanded, toggleExpanded }),
[isActive],
);
React.useEffect(() => {
setHandler(handler);
}, [handler, setHandler]);
return null;
}
const communityDrawerItemHandlers: {
+[tab: NavigationTab]: React.ComponentType,
} = Object.freeze({
chat: ChatDrawerItemHandler,
calendar: CalendarDrawerItemHandler,
});
function getCommunityDrawerItemHandler(
tab: NavigationTab,
): React.ComponentType {
return communityDrawerItemHandlers[tab] ?? ChatDrawerItemHandler;
}
export { getCommunityDrawerItemHandler };
diff --git a/web/sidebar/community-drawer-utils.react.js b/web/sidebar/community-drawer-utils.react.js
index 13ff27f7b..df9cf6cc0 100644
--- a/web/sidebar/community-drawer-utils.react.js
+++ b/web/sidebar/community-drawer-utils.react.js
@@ -1,90 +1,89 @@
// @flow
import * as React from 'react';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types';
+import type { ThreadInfo } from 'lib/types/thread-types';
import type { CommunityDrawerItemData } from 'lib/utils/drawer-utils.react';
import css from './community-drawer-item.css';
import CommunityDrawerItemChat from './community-drawer-item.react.js';
import { ExpandButton } from './expand-buttons.react.js';
import SubchannelsButton from './subchannels-button.react.js';
import type { NavigationTab } from '../types/nav-types.js';
const indentation = 14;
const subchannelsButtonIndentation = 24;
function getChildren({
expanded,
hasSubchannelsButton,
itemChildren,
paddingLeft,
threadInfo,
expandable,
handlerType,
}: {
expanded: boolean,
hasSubchannelsButton: boolean,
itemChildren: $ReadOnlyArray>,
paddingLeft: number,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
expandable: boolean,
handlerType: NavigationTab,
}): React.Node {
if (!expanded) {
return null;
}
if (hasSubchannelsButton) {
const buttonPaddingLeft = paddingLeft + subchannelsButtonIndentation;
return (
);
}
return itemChildren.map(item => (
));
}
function getExpandButton({
expandable,
childrenLength,
hasSubchannelsButton,
onExpandToggled,
expanded,
}: {
+expandable: boolean,
+childrenLength: ?number,
+hasSubchannelsButton: boolean,
+onExpandToggled?: ?() => ?void,
+expanded: boolean,
}): React.Node {
if (!expandable) {
return null;
}
if (childrenLength === 0 && !hasSubchannelsButton) {
return (
);
}
return (
);
}
export { getChildren, getExpandButton };
diff --git a/web/sidebar/subchannels-button.react.js b/web/sidebar/subchannels-button.react.js
index c443a8296..8d6662565 100644
--- a/web/sidebar/subchannels-button.react.js
+++ b/web/sidebar/subchannels-button.react.js
@@ -1,40 +1,39 @@
// @flow
import * as React from 'react';
import { CornerDownRight } from 'react-feather';
import { useModalContext } from 'lib/components/modal-provider.react.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import css from './subchannels-button.css';
import Button from '../components/button.react.js';
import SubchannelsModal from '../modals/threads/subchannels/subchannels-modal.react.js';
type Props = {
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function SubchannelsButton(props: Props): React.Node {
const { threadInfo } = props;
const { pushModal, popModal } = useModalContext();
const onClick = React.useCallback(
() =>
pushModal(
,
),
[popModal, pushModal, threadInfo.id],
);
return (
Subchannels
);
}
export default SubchannelsButton;
diff --git a/web/utils/thread-utils.js b/web/utils/thread-utils.js
index 6ff81309c..94cc32116 100644
--- a/web/utils/thread-utils.js
+++ b/web/utils/thread-utils.js
@@ -1,111 +1,110 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js';
import {
createPendingThread,
useExistingThreadInfoFinder,
} from 'lib/shared/thread-utils.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
-import { type LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import type { AccountUserInfo } from 'lib/types/user-types.js';
import { useSelector } from '../redux/redux-utils.js';
type InfosForPendingThread = {
+isChatCreation: boolean,
+selectedUserInfos: $ReadOnlyArray,
+otherUserInfos: { [id: string]: AccountUserInfo },
};
function useInfosForPendingThread(): InfosForPendingThread {
const isChatCreation = useSelector(
state => state.navInfo.chatMode === 'create',
);
const selectedUserInfos = useSelector(
state => state.navInfo.selectedUserList ?? [],
);
const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers);
return {
isChatCreation,
selectedUserInfos,
otherUserInfos,
};
}
function useThreadInfoForPossiblyPendingThread(
activeChatThreadID: ?string,
-): ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo {
+): ?ThreadInfo {
const { isChatCreation, selectedUserInfos } = useInfosForPendingThread();
const loggedInUserInfo = useLoggedInUserInfo();
invariant(loggedInUserInfo, 'loggedInUserInfo should be set');
const pendingPrivateThread = React.useRef(
createPendingThread({
viewerID: loggedInUserInfo.id,
threadType: threadTypes.PRIVATE,
members: [loggedInUserInfo],
}),
);
const newThreadID = 'pending/new_thread';
const pendingNewThread = React.useMemo(
() => ({
...createPendingThread({
viewerID: loggedInUserInfo.id,
threadType: threadTypes.PRIVATE,
members: [loggedInUserInfo],
name: 'New thread',
}),
id: newThreadID,
}),
[loggedInUserInfo],
);
const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder(
pendingPrivateThread.current,
);
const baseThreadInfo = useSelector(state => {
if (activeChatThreadID) {
const activeThreadInfo = threadInfoSelector(state)[activeChatThreadID];
if (activeThreadInfo) {
return activeThreadInfo;
}
}
return state.navInfo.pendingThread;
});
const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo);
const threadInfo = React.useMemo(() => {
if (isChatCreation) {
if (selectedUserInfos.length === 0) {
return pendingNewThread;
}
return existingThreadInfoFinderForCreatingThread({
searching: true,
userInfoInputArray: selectedUserInfos,
});
}
return existingThreadInfoFinder({
searching: false,
userInfoInputArray: [],
});
}, [
existingThreadInfoFinder,
existingThreadInfoFinderForCreatingThread,
isChatCreation,
pendingNewThread,
selectedUserInfos,
]);
return threadInfo;
}
export { useThreadInfoForPossiblyPendingThread, useInfosForPendingThread };
diff --git a/web/utils/tooltip-action-utils.js b/web/utils/tooltip-action-utils.js
index 2125e9a7c..fda145e1b 100644
--- a/web/utils/tooltip-action-utils.js
+++ b/web/utils/tooltip-action-utils.js
@@ -1,488 +1,487 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { useResettingState } from 'lib/hooks/use-resetting-state.js';
import type {
ReactionInfo,
ChatMessageInfoItem,
} from 'lib/selectors/chat-selectors.js';
import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js';
import { createMessageReply } from 'lib/shared/message-utils.js';
import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js';
import {
threadHasPermission,
useSidebarExistsOrCanBeCreated,
} from 'lib/shared/thread-utils.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
-import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
+import type { ThreadInfo } from 'lib/types/thread-types.js';
import { longAbsoluteDate } from 'lib/utils/date-utils.js';
import { canToggleMessagePin } from 'lib/utils/toggle-pin-utils.js';
import {
type MessageTooltipAction,
getTooltipPositionStyle,
calculateMessageTooltipSize,
calculateReactionTooltipSize,
type TooltipPosition,
type TooltipPositionStyle,
type TooltipSize,
} from './tooltip-utils.js';
import { getComposedMessageID } from '../chat/chat-constants.js';
import { useEditModalContext } from '../chat/edit-message-provider.js';
import MessageTooltip from '../chat/message-tooltip.react.js';
import type { PositionInfo } from '../chat/position-types.js';
import ReactionTooltip from '../chat/reaction-tooltip.react.js';
import { useTooltipContext } from '../chat/tooltip-provider.js';
import CommIcon from '../CommIcon.react.js';
import { InputStateContext } from '../input/input-state.js';
import TogglePinModal from '../modals/chat/toggle-pin-modal.react.js';
import {
useOnClickPendingSidebar,
useOnClickThread,
} from '../selectors/thread-selectors.js';
type UseTooltipArgs = {
+createTooltip: (tooltipPositionStyle: TooltipPositionStyle) => React.Node,
+tooltipSize: TooltipSize,
+availablePositions: $ReadOnlyArray,
};
type UseTooltipResult = {
+onMouseEnter: (event: SyntheticEvent) => mixed,
+onMouseLeave: ?() => mixed,
};
function useTooltip({
createTooltip,
tooltipSize,
availablePositions,
}: UseTooltipArgs): UseTooltipResult {
const [onMouseLeave, setOnMouseLeave] = React.useState() => mixed>(null);
const [tooltipSourcePosition, setTooltipSourcePosition] =
React.useState();
const { renderTooltip } = useTooltipContext();
const updateTooltip = React.useRef(React.Node) => mixed>();
const onMouseEnter = React.useCallback(
(event: SyntheticEvent) => {
if (!renderTooltip) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const { top, bottom, left, right, height, width } = rect;
const sourcePosition = { top, bottom, left, right, height, width };
setTooltipSourcePosition(sourcePosition);
const tooltipPositionStyle = getTooltipPositionStyle({
tooltipSourcePosition: sourcePosition,
tooltipSize,
availablePositions,
});
if (!tooltipPositionStyle) {
return;
}
const tooltip = createTooltip(tooltipPositionStyle);
const renderTooltipResult = renderTooltip({
newNode: tooltip,
tooltipPositionStyle,
});
if (renderTooltipResult) {
const { onMouseLeaveCallback: callback } = renderTooltipResult;
setOnMouseLeave((() => callback: () => () => mixed));
updateTooltip.current = renderTooltipResult.updateTooltip;
}
},
[availablePositions, createTooltip, renderTooltip, tooltipSize],
);
React.useEffect(() => {
if (!updateTooltip.current) {
return;
}
const tooltipPositionStyle = getTooltipPositionStyle({
tooltipSourcePosition,
tooltipSize,
availablePositions,
});
if (!tooltipPositionStyle) {
return;
}
const tooltip = createTooltip(tooltipPositionStyle);
updateTooltip.current?.(tooltip);
}, [availablePositions, createTooltip, tooltipSize, tooltipSourcePosition]);
return {
onMouseEnter,
onMouseLeave,
};
}
function useMessageTooltipSidebarAction(
item: ChatMessageInfoItem,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
): ?MessageTooltipAction {
const { threadCreatedFromMessage, messageInfo } = item;
const { popModal } = useModalContext();
const sidebarExists = !!threadCreatedFromMessage;
const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated(
threadInfo,
item,
);
const openThread = useOnClickThread(threadCreatedFromMessage);
const openPendingSidebar = useOnClickPendingSidebar(messageInfo, threadInfo);
return React.useMemo(() => {
if (!sidebarExistsOrCanBeCreated) {
return null;
}
const buttonContent = ;
const onClick = (event: SyntheticEvent) => {
popModal();
if (threadCreatedFromMessage) {
openThread(event);
} else {
openPendingSidebar(event);
}
};
return {
actionButtonContent: buttonContent,
onClick,
label: sidebarExists ? 'Go to thread' : 'Create thread',
};
}, [
popModal,
openPendingSidebar,
openThread,
sidebarExists,
sidebarExistsOrCanBeCreated,
threadCreatedFromMessage,
]);
}
function useMessageTooltipReplyAction(
item: ChatMessageInfoItem,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
): ?MessageTooltipAction {
const { messageInfo } = item;
const { popModal } = useModalContext();
const inputState = React.useContext(InputStateContext);
invariant(inputState, 'inputState is required');
const { addReply } = inputState;
return React.useMemo(() => {
if (
item.messageInfo.type !== messageTypes.TEXT ||
!threadHasPermission(threadInfo, threadPermissions.VOICED)
) {
return null;
}
const buttonContent = ;
const onClick = () => {
popModal();
if (!messageInfo.text) {
return;
}
addReply(createMessageReply(messageInfo.text));
};
return {
actionButtonContent: buttonContent,
onClick,
label: 'Reply',
};
}, [popModal, addReply, item.messageInfo.type, messageInfo, threadInfo]);
}
const copiedMessageDurationMs = 2000;
function useMessageCopyAction(
item: ChatMessageInfoItem,
): ?MessageTooltipAction {
const { messageInfo } = item;
const [successful, setSuccessful] = useResettingState(
false,
copiedMessageDurationMs,
);
return React.useMemo(() => {
if (messageInfo.type !== messageTypes.TEXT) {
return null;
}
const buttonContent = ;
const onClick = async () => {
try {
await navigator.clipboard.writeText(messageInfo.text);
setSuccessful(true);
} catch (e) {
setSuccessful(false);
}
};
return {
actionButtonContent: buttonContent,
onClick,
label: successful ? 'Copied!' : 'Copy',
};
}, [messageInfo.text, messageInfo.type, setSuccessful, successful]);
}
function useMessageReactAction(
item: ChatMessageInfoItem,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
): ?MessageTooltipAction {
const { messageInfo } = item;
const { setShouldRenderEmojiKeyboard } = useTooltipContext();
const canCreateReactionFromMessage = useCanCreateReactionFromMessage(
threadInfo,
messageInfo,
);
return React.useMemo(() => {
if (!canCreateReactionFromMessage) {
return null;
}
const buttonContent = ;
const onClickReact = () => {
if (!setShouldRenderEmojiKeyboard) {
return;
}
setShouldRenderEmojiKeyboard(true);
};
return {
actionButtonContent: buttonContent,
onClick: onClickReact,
label: 'React',
};
}, [canCreateReactionFromMessage, setShouldRenderEmojiKeyboard]);
}
function useMessageTogglePinAction(
item: ChatMessageInfoItem,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
): ?MessageTooltipAction {
const { pushModal } = useModalContext();
const { messageInfo, isPinned } = item;
const canTogglePin = canToggleMessagePin(messageInfo, threadInfo);
const inputState = React.useContext(InputStateContext);
return React.useMemo(() => {
if (!canTogglePin) {
return null;
}
const iconName = isPinned ? 'unpin' : 'pin';
const buttonContent = ;
const onClickTogglePin = () => {
pushModal(
,
);
};
return {
actionButtonContent: buttonContent,
onClick: onClickTogglePin,
label: isPinned ? 'Unpin' : 'Pin',
};
}, [canTogglePin, inputState, isPinned, pushModal, item, threadInfo]);
}
function useMessageEditAction(
item: ChatMessageInfoItem,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
): ?MessageTooltipAction {
const { messageInfo } = item;
const canEditMessage = useCanEditMessage(threadInfo, messageInfo);
const { renderEditModal, scrollToMessage } = useEditModalContext();
const { clearTooltip } = useTooltipContext();
return React.useMemo(() => {
if (!canEditMessage) {
return null;
}
const buttonContent = ;
const onClickEdit = () => {
const callback = (maxHeight: number) =>
renderEditModal({
messageInfo: item,
threadInfo,
isError: false,
editedMessageDraft: messageInfo.text,
maxHeight: maxHeight,
});
clearTooltip();
scrollToMessage(getComposedMessageID(messageInfo), callback);
};
return {
actionButtonContent: buttonContent,
onClick: onClickEdit,
label: 'Edit',
};
}, [
canEditMessage,
clearTooltip,
item,
messageInfo,
renderEditModal,
scrollToMessage,
threadInfo,
]);
}
function useMessageTooltipActions(
item: ChatMessageInfoItem,
- threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ threadInfo: ThreadInfo,
): $ReadOnlyArray {
const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo);
const replyAction = useMessageTooltipReplyAction(item, threadInfo);
const copyAction = useMessageCopyAction(item);
const reactAction = useMessageReactAction(item, threadInfo);
const togglePinAction = useMessageTogglePinAction(item, threadInfo);
const editAction = useMessageEditAction(item, threadInfo);
return React.useMemo(
() =>
[
replyAction,
sidebarAction,
copyAction,
reactAction,
togglePinAction,
editAction,
].filter(Boolean),
[
replyAction,
sidebarAction,
copyAction,
reactAction,
togglePinAction,
editAction,
],
);
}
const undefinedTooltipSize = {
width: 0,
height: 0,
};
type UseMessageTooltipArgs = {
+availablePositions: $ReadOnlyArray,
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo,
+ +threadInfo: ThreadInfo,
};
function useMessageTooltip({
availablePositions,
item,
threadInfo,
}: UseMessageTooltipArgs): UseTooltipResult {
const tooltipActions = useMessageTooltipActions(item, threadInfo);
const messageTimestamp = React.useMemo(() => {
const time = item.messageInfo.time;
return longAbsoluteDate(time);
}, [item.messageInfo.time]);
const tooltipSize = React.useMemo(() => {
if (typeof document === 'undefined') {
return undefinedTooltipSize;
}
const tooltipLabels = tooltipActions.map(action => action.label);
return calculateMessageTooltipSize({
tooltipLabels,
timestamp: messageTimestamp,
});
}, [messageTimestamp, tooltipActions]);
const createMessageTooltip = React.useCallback(
(tooltipPositionStyle: TooltipPositionStyle) => (
),
[item, messageTimestamp, threadInfo, tooltipActions, tooltipSize],
);
const { onMouseEnter, onMouseLeave } = useTooltip({
createTooltip: createMessageTooltip,
tooltipSize,
availablePositions,
});
return {
onMouseEnter,
onMouseLeave,
};
}
type UseReactionTooltipArgs = {
+reaction: string,
+reactions: ReactionInfo,
+availablePositions: $ReadOnlyArray,
};
function useReactionTooltip({
reaction,
reactions,
availablePositions,
}: UseReactionTooltipArgs): UseTooltipResult {
const { users } = reactions[reaction];
const tooltipSize = React.useMemo(() => {
if (typeof document === 'undefined') {
return undefinedTooltipSize;
}
const usernames = users.map(user => user.username).filter(Boolean);
return calculateReactionTooltipSize(usernames);
}, [users]);
const createReactionTooltip = React.useCallback(
() => ,
[reaction, reactions],
);
const { onMouseEnter, onMouseLeave } = useTooltip({
createTooltip: createReactionTooltip,
tooltipSize,
availablePositions,
});
return {
onMouseEnter,
onMouseLeave,
};
}
export {
useMessageTooltipSidebarAction,
useMessageTooltipReplyAction,
useMessageReactAction,
useMessageTooltipActions,
useMessageTooltip,
useReactionTooltip,
};