diff --git a/web/avatars/edit-thread-avatar-menu.react.js b/web/avatars/edit-thread-avatar-menu.react.js
index 401fad767..ba321f385 100644
--- a/web/avatars/edit-thread-avatar-menu.react.js
+++ b/web/avatars/edit-thread-avatar-menu.react.js
@@ -1,125 +1,124 @@
// @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 {
ThreadInfo,
RawThreadInfo,
} from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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]);
await 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 d511dddd5..ada11e273 100644
--- a/web/avatars/edit-thread-avatar.react.js
+++ b/web/avatars/edit-thread-avatar.react.js
@@ -1,52 +1,51 @@
// @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 {
ThreadInfo,
RawThreadInfo,
} 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 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 | ThreadInfo,
+ +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 78378363f..ebfa23d9c 100644
--- a/web/avatars/thread-avatar.react.js
+++ b/web/avatars/thread-avatar.react.js
@@ -1,60 +1,59 @@
// @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 {
ThreadInfo,
RawThreadInfo,
} 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 Avatar from './avatar.react.js';
import { useSelector } from '../redux/redux-utils.js';
type Props = {
- +threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo,
+ +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 95fdb23a2..528b26254 100644
--- a/web/avatars/thread-emoji-avatar-selection-modal.react.js
+++ b/web/avatars/thread-emoji-avatar-selection-modal.react.js
@@ -1,57 +1,56 @@
// @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 {
ThreadInfo,
RawThreadInfo,
} from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import EmojiAvatarSelectionModal from './emoji-avatar-selection-modal.react.js';
type Props = {
- +threadInfo: RawThreadInfo | LegacyThreadInfo | ThreadInfo,
+ +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 5e06b2956..d62b85bd3 100644
--- a/web/calendar/day.react.js
+++ b/web/calendar/day.react.js
@@ -1,259 +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 {
type PushModal,
useModalContext,
} 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 { ThreadInfo } 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 { dateFromString, dateString } 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,
+ +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-thread-list-item-menu.react.js b/web/chat/chat-thread-list-item-menu.react.js
index 7ad4a10cc..9239f6454 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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 b8e098921..1600c503a 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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/inline-engagement.react.js b/web/chat/inline-engagement.react.js
index 7a7bb8016..1c9a76f91 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
- +sidebarThreadInfo: ?LegacyThreadInfo | ?ThreadInfo,
+ +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-preview.react.js b/web/chat/message-preview.react.js
index 4af856c58..ae3d1fc61 100644
--- a/web/chat/message-preview.react.js
+++ b/web/chat/message-preview.react.js
@@ -1,82 +1,81 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
import { useMessagePreview } from 'lib/shared/message-utils.js';
import { type MessageInfo } from 'lib/types/message-types.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-thread-list.css';
import { getDefaultTextMessageRules } from '../markdown/rules.react.js';
type Props = {
+messageInfo: ?MessageInfo,
- +threadInfo: LegacyThreadInfo | ThreadInfo,
+ +threadInfo: ThreadInfo,
};
function MessagePreview(props: Props): React.Node {
const { messageInfo, threadInfo } = props;
const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo);
const messagePreviewResult = useMessagePreview(
messageInfo,
threadInfo,
getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules,
);
if (!messageInfo) {
return (
No messages
);
}
invariant(
messagePreviewResult,
'useMessagePreview should only return falsey if pass null or undefined',
);
const { message, username } = messagePreviewResult;
let usernameText = null;
if (username) {
let usernameStyle;
if (username.style === 'unread') {
usernameStyle = css.unread;
} else if (username.style === 'secondary') {
usernameStyle = css.messagePreviewSecondary;
}
invariant(
usernameStyle,
`MessagePreview doesn't support ${username.style} style for username, ` +
'only unread and secondary',
);
usernameText = (
{`${username.text}: `}
);
}
let messageStyle;
if (message.style === 'unread') {
messageStyle = css.unread;
} else if (message.style === 'primary') {
messageStyle = css.messagePreviewPrimary;
} else if (message.style === 'secondary') {
messageStyle = css.messagePreviewSecondary;
}
invariant(
messageStyle,
`MessagePreview doesn't support ${message.style} style for message, ` +
'only unread, primary, and secondary',
);
return (
{usernameText}
{message.text}
);
}
export default MessagePreview;
diff --git a/web/chat/relationship-prompt/relationship-prompt.js b/web/chat/relationship-prompt/relationship-prompt.js
index 5f58ffc05..a67482ce0 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 { ThreadInfo } 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 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 | ThreadInfo };
+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 ce5b75e48..4bc2d0f96 100644
--- a/web/chat/robotext-message.react.js
+++ b/web/chat/robotext-message.react.js
@@ -1,160 +1,159 @@
// @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 { ThreadInfo } 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 {
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 '../tooltips/tooltip-action-utils.js';
import { tooltipPositions } from '../tooltips/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: 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, {
renderText: ({ text }) => (
{text}
),
renderThread: ({ id, name }) => ,
renderUser: ({ userID, usernameText }) => (
),
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 | ThreadInfo,
+ +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/input/input-state-container.react.js b/web/input/input-state-container.react.js
index 3386b6335..505fcdf35 100644
--- a/web/input/input-state-container.react.js
+++ b/web/input/input-state-container.react.js
@@ -1,1713 +1,1707 @@
// @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 type {
LegacySendMultimediaMessageInput,
SendTextMessageInput,
} from 'lib/actions/message-actions.js';
import {
createLocalMessageActionType,
sendMultimediaMessageActionTypes,
sendTextMessageActionTypes,
useLegacySendMultimediaMessage,
useSendTextMessage,
} from 'lib/actions/message-actions.js';
import { queueReportsActionType } from 'lib/actions/report-actions.js';
import { useNewThread } from 'lib/actions/thread-actions.js';
import {
type BlobServiceUploadAction,
type DeleteUploadInput,
type MultimediaUploadCallbacks,
type MultimediaUploadExtras,
updateMultimediaMessageMediaActionType,
uploadMultimedia,
useBlobServiceUpload,
useDeleteUpload,
} from 'lib/actions/upload-actions.js';
import {
type PushModal,
useModalContext,
} 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,
patchThreadInfoToIncludeMentionedMembersOfParent,
threadInfoInsideCommunity,
threadIsPending,
threadIsPendingSidebar,
} from 'lib/shared/thread-utils.js';
import type { CalendarQuery } from 'lib/types/entry-types.js';
import type {
MediaMission,
MediaMissionFailure,
MediaMissionResult,
MediaMissionStep,
UploadMultimediaResult,
} from 'lib/types/media-types.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import {
type RawMessageInfo,
type RawMultimediaMessageInfo,
type SendMessagePayload,
type SendMessageResult,
} 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 { ThreadInfo } 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,
} from 'lib/types/thread-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import {
blobHashFromBlobServiceURI,
isBlobServiceURI,
makeBlobServiceEndpointURL,
} from 'lib/utils/blob-service.js';
import { getConfig } from 'lib/utils/config.js';
import { cloneError, getMessageForException } from 'lib/utils/errors.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { generateReportID } from 'lib/utils/report-utils.js';
import {
type BaseInputState,
type InputState,
InputStateContext,
type PendingMultimediaUpload,
type TypeaheadInputState,
type TypeaheadState,
} from './input-state.js';
import { encryptFile } from '../media/encryption-utils.js';
import { generateThumbHash } from '../media/image-utils.js';
import {
preloadImage,
preloadMediaResource,
validateFile,
} 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);
}
void 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 | ThreadInfo): boolean {
+ shouldEncryptMedia(threadInfo: ThreadInfo): boolean {
return threadInfoInsideCommunity(threadInfo, commStaffCommunity.id);
}
async sendMultimediaMessage(
messageInfo: RawMultimediaMessageInfo,
): Promise {
if (!threadIsPending(messageInfo.threadID)) {
void 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(),
};
}
void 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 | ThreadInfo,
- ): 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 | ThreadInfo,
+ threadInfo: ThreadInfo,
files: $ReadOnlyArray,
) => this.appendFiles(threadInfo, files),
cancelPendingUpload: (localUploadID: string) =>
this.cancelPendingUpload(threadID, localUploadID),
sendTextMessage: (
messageInfo: RawTextMessageInfo,
threadInfo: ThreadInfo,
parentThreadInfo: ?ThreadInfo,
) =>
this.sendTextMessage(messageInfo, threadInfo, parentThreadInfo),
createMultimediaMessage: (
localID: number,
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
) => 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 | ThreadInfo,
+ threadInfo: ThreadInfo,
) =>
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,
}: BaseInputState);
},
),
);
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 | ThreadInfo,
+ threadInfo: ThreadInfo,
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 | ThreadInfo,
+ threadInfo: ThreadInfo,
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) {
void 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);
void 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: 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)) {
void 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,
};
void this.props.dispatchActionPromise(
sendTextMessageActionTypes,
this.sendTextMessageAction(
newMessageInfo,
newThreadInfo,
parentThreadInfo,
),
undefined,
newMessageInfo,
);
}
async sendTextMessageAction(
messageInfo: RawTextMessageInfo,
- threadInfo: LegacyThreadInfo | ThreadInfo,
- parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo,
+ 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 | ThreadInfo,
- ) {
+ createMultimediaMessage(localID: number, threadInfo: ThreadInfo) {
this.props.sendCallbacks.forEach(callback => callback());
const localMessageID = `${localIDPrefix}${localID}`;
void 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 | ThreadInfo,
+ threadInfo: ThreadInfo,
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);
}
void this.startThreadCreation(threadInfo);
if (threadIsPendingSidebar(threadInfo.id)) {
this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
}
const completed = InputStateContainer.completedMessageIDs(this.state);
if (completed.has(localMessageID)) {
void 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,
},
};
});
void 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 = useLegacyAshoatKeyserverCall(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 52b361c52..0a4c0e5ab 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 Dimensions,
type EncryptedMediaType,
type MediaMissionStep,
type MediaType,
} from 'lib/types/media-types.js';
import type { RawTextMessageInfo } from 'lib/types/messages/text.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type {
ChatMentionCandidates,
- LegacyThreadInfo,
RelativeMemberInfo,
} 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 | ThreadInfo,
+ threadInfo: ThreadInfo,
files: $ReadOnlyArray,
) => Promise,
+cancelPendingUpload: (localUploadID: string) => void,
+sendTextMessage: (
messageInfo: RawTextMessageInfo,
threadInfo: ThreadInfo,
parentThreadInfo: ?ThreadInfo,
) => mixed,
- +createMultimediaMessage: (
- localID: number,
- threadInfo: LegacyThreadInfo | ThreadInfo,
- ) => void,
+ +createMultimediaMessage: (localID: number, threadInfo: ThreadInfo) => void,
+setDraft: (draft: string) => void,
+setTextCursorPosition: (newPosition: number) => void,
+messageHasUploadFailure: (localMessageID: string) => boolean,
+retryMultimediaMessage: (
localMessageID: string,
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ 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 748c9e0ca..1801a6b83 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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 2b1707019..739ff22a6 100644
--- a/web/markdown/rules.react.js
+++ b/web/markdown/rules.react.js
@@ -1,249 +1,246 @@
// @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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- ChatMentionCandidates,
- LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import type { ChatMentionCandidates } 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,
react: (
node: SharedMarkdown.SingleASTNode,
output: SharedMarkdown.Output,
state: SharedMarkdown.State,
) => (
{output(node.content, state)}
),
},
paragraph: {
...SimpleMarkdown.defaultRules.paragraph,
match: SimpleMarkdown.blockRegex(SharedMarkdown.paragraphRegex),
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),
};
},
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 | ThreadInfo,
+ threadInfo: ThreadInfo,
chatMentionCandidates: ChatMentionCandidates,
): boolean => MarkdownRules {
const { members } = threadInfo;
const membersMap = SharedMarkdown.useMemberMapForUserMentions(members);
return React.useMemo(
() =>
_memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) =>
textMessageRules(chatMentionCandidates, useDarkStyle, membersMap),
),
[chatMentionCandidates, membersMap],
);
}
function textMessageRules(
chatMentionCandidates: ChatMentionCandidates,
useDarkStyle: boolean,
membersMap: $ReadOnlyMap,
): MarkdownRules {
const baseRules = markdownRules(useDarkStyle);
return {
...baseRules,
simpleMarkdownRules: {
...baseRules.simpleMarkdownRules,
userMention: {
...SimpleMarkdown.defaultRules.strong,
match: SharedMarkdown.matchUserMentions(membersMap),
parse: (capture: SharedMarkdown.Capture) =>
SharedMarkdown.parseUserMentions(membersMap, capture),
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),
react: (
node: SharedMarkdown.SingleASTNode,
output: SharedMarkdown.Output,
state: SharedMarkdown.State,
) => (
),
},
},
};
}
let defaultTextMessageRules = null;
const defaultMembersMap = new Map();
function getDefaultTextMessageRules(
overrideDefaultChatMentionCandidates: ChatMentionCandidates = {},
): MarkdownRules {
if (Object.keys(overrideDefaultChatMentionCandidates).length > 0) {
return textMessageRules(
overrideDefaultChatMentionCandidates,
false,
defaultMembersMap,
);
}
if (!defaultTextMessageRules) {
defaultTextMessageRules = textMessageRules({}, false, defaultMembersMap);
}
return defaultTextMessageRules;
}
export { linkRules, useTextMessageRulesFunc, getDefaultTextMessageRules };
diff --git a/web/modals/search/message-search-utils.react.js b/web/modals/search/message-search-utils.react.js
index d8ac6f220..a823e69ef 100644
--- a/web/modals/search/message-search-utils.react.js
+++ b/web/modals/search/message-search-utils.react.js
@@ -1,54 +1,53 @@
// @flow
import * as React from 'react';
import {
type ChatMessageInfoItem,
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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import { useSelector } from '../../redux/redux-utils.js';
function useParseSearchResults(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ 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/create/steps/subchannel-members-list.react.js b/web/modals/threads/create/steps/subchannel-members-list.react.js
index ea18c2ed1..d260e0eb1 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,106 @@
// @flow
import * as React from 'react';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { stringForUser } from 'lib/shared/user-utils.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- LegacyThreadInfo,
- RelativeMemberInfo,
-} from 'lib/types/thread-types.js';
+import type { RelativeMemberInfo } 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 | ThreadInfo,
- +parentThreadInfo: LegacyThreadInfo | ThreadInfo,
+ +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 db18c53dc..f6d19be52 100644
--- a/web/modals/threads/gallery/thread-settings-media-gallery.react.js
+++ b/web/modals/threads/gallery/thread-settings-media-gallery.react.js
@@ -1,212 +1,211 @@
// @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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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, { type TabData } from '../../../components/tabs.react.js';
import MultimediaModal from '../../../media/multimedia-modal.react.js';
import Modal from '../../modal.react.js';
type MediaGalleryTab = 'All' | 'Images' | 'Videos';
const tabsData: $ReadOnlyArray> = [
{
id: 'All',
header: 'All',
},
{
id: 'Images',
header: 'Images',
},
{
id: 'Videos',
header: 'Videos',
},
];
type ThreadSettingsMediaGalleryModalProps = {
+onClose: () => void,
- +parentThreadInfo: LegacyThreadInfo | ThreadInfo,
+ +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);
const tabs = React.useMemo(
() => ,
[tab],
);
React.useEffect(() => {
const fetchData = async () => {
const result = await callFetchThreadMedia({
threadID,
limit,
offset: 0,
});
setMediaInfos(result.media);
};
void 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],
);
const tabContent = React.useMemo(
() => (
{mediaGalleryItems}
),
[handleScroll, mediaGalleryItems],
);
const threadSettingsMediaGalleryModal = React.useMemo(
() => (
{tabs}
{tabContent}
),
[onClose, tabContent, tabs],
);
return threadSettingsMediaGalleryModal;
}
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 2da4b60a7..063b05a63 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,154 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
changeThreadMemberRolesActionTypes,
useChangeThreadMemberRoles,
} 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- LegacyThreadInfo,
- RelativeMemberInfo,
-} from 'lib/types/thread-types';
+import type { RelativeMemberInfo } from 'lib/types/thread-types';
import { values } from 'lib/utils/objects.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.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 | ThreadInfo,
+ +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,
});
void 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 009e45098..e8d7a2c1f 100644
--- a/web/modals/threads/members/member.react.js
+++ b/web/modals/threads/members/member.react.js
@@ -1,139 +1,136 @@
// @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 {
getAvailableThreadMemberActions,
removeMemberFromThread,
} 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- LegacyThreadInfo,
- RelativeMemberInfo,
-} from 'lib/types/thread-types.js';
+import type { RelativeMemberInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-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 | ThreadInfo,
+ +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 ae80e49fb..5ecd7a6df 100644
--- a/web/modals/threads/members/members-list.react.js
+++ b/web/modals/threads/members/members-list.react.js
@@ -1,82 +1,79 @@
// @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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import {
- type RelativeMemberInfo,
- type LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import { type RelativeMemberInfo } from 'lib/types/thread-types.js';
import ThreadMember from './member.react.js';
import css from './members-modal.css';
type Props = {
- +threadInfo: LegacyThreadInfo | ThreadInfo,
+ +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 6f7b4cfe8..34fbc9dae 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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 f82e1d08c..bf29ec7ab 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-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 | ThreadInfo,
+ +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(() => {
void 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 3262a6829..7d9b26129 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,200 @@
// @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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
-import {
- type ThreadChanges,
- type LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import { type ThreadChanges } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-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 | ThreadInfo,
+ +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();
void 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 54436b0a0..bdd242240 100644
--- a/web/modals/threads/settings/thread-settings-modal.react.js
+++ b/web/modals/threads/settings/thread-settings-modal.react.js
@@ -1,273 +1,270 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
changeThreadSettingsActionTypes,
deleteThreadActionTypes,
} 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 {
getSingleOtherUser,
threadHasPermission,
threadUIName,
} from 'lib/shared/thread-utils.js';
import type { ThreadInfo } 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 ThreadChanges,
- type LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import { type ThreadChanges } 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, { type TabData } 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: ?ThreadInfo = 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 | ThreadInfo, 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]);
const tabsData: $ReadOnlyArray> = React.useMemo(() => {
if (!threadInfo) {
return [];
}
const result = [{ id: 'general', header: 'General' }];
// 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) {
result.push({ id: 'privacy', header: 'Privacy' });
}
if (availableRelationshipActions.length > 0 && otherUserInfo) {
result.push({ id: 'relationship', header: 'Relationship' });
}
if (hasPermissionForTab(threadInfo, 'delete')) {
result.push({ id: 'delete', header: 'Delete' });
}
return result;
}, [
availableRelationshipActions.length,
hasPermissionForTab,
otherUserInfo,
queuedChanges,
threadInfo,
]);
const tabs = React.useMemo(
() => (
),
[currentTabType, tabsData],
);
const tabContent = React.useMemo(() => {
if (!threadInfo) {
return null;
}
if (currentTabType === 'general') {
return (
);
}
if (currentTabType === 'privacy') {
return (
);
}
if (currentTabType === 'relationship') {
invariant(otherUserInfo, 'otherUserInfo should be set');
return (
);
}
return (
);
}, [
availableRelationshipActions,
changeInProgress,
currentTabType,
errorMessage,
namePlaceholder,
otherUserInfo,
queuedChanges,
threadInfo,
]);
if (!threadInfo) {
return (
You no longer have permission to view this chat
);
}
return (
);
});
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 8f8511c9e..c15e775bb 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,174 @@
// @flow
import * as React from 'react';
import {
changeThreadSettingsActionTypes,
useChangeThreadSettings,
} 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
-import {
- type ThreadChanges,
- type LegacyThreadInfo,
-} from 'lib/types/thread-types.js';
+import { type ThreadChanges } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-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 | ThreadInfo,
+ +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();
void 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-action-buttons.react.js b/web/modals/user-profile/user-profile-action-buttons.react.js
index 099765063..a0cb4c0d0 100644
--- a/web/modals/user-profile/user-profile-action-buttons.react.js
+++ b/web/modals/user-profile/user-profile-action-buttons.react.js
@@ -1,101 +1,100 @@
// @flow
import { faUserMinus, faUserPlus } from '@fortawesome/free-solid-svg-icons';
import * as React from 'react';
import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js';
import type { ThreadInfo } 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 UserProfileMessageButton from './user-profile-message-button.react.js';
import css from './user-profile.css';
import RelationshipPromptButton from '../../chat/relationship-prompt/relationship-prompt-button.js';
import { buttonThemes } from '../../components/button.react.js';
type Props = {
- +threadInfo: LegacyThreadInfo | ThreadInfo,
+ +threadInfo: ThreadInfo,
};
function UserProfileActionButtons(props: Props): React.Node {
const { threadInfo } = props;
const {
otherUserInfo,
callbacks: { friendUser, unfriendUser },
} = useRelationshipPrompt(threadInfo);
const userProfileActionButtons = React.useMemo(() => {
if (
!otherUserInfo ||
otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND
) {
return ;
}
if (
otherUserInfo.relationshipStatus ===
userRelationshipStatus.REQUEST_RECEIVED
) {
return (
<>
>
);
}
if (
otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT
) {
return (
);
}
return (
);
}, [otherUserInfo, friendUser, threadInfo, unfriendUser]);
return userProfileActionButtons;
}
export default UserProfileActionButtons;
diff --git a/web/modals/user-profile/user-profile-menu.react.js b/web/modals/user-profile/user-profile-menu.react.js
index b0687596f..e5e2d59f0 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 { ThreadInfo } 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 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 | ThreadInfo,
+ +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 1d7aba5b2..ea4872756 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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/roles/community-roles-modal.react.js b/web/roles/community-roles-modal.react.js
index 1959af1bd..a7c006ca1 100644
--- a/web/roles/community-roles-modal.react.js
+++ b/web/roles/community-roles-modal.react.js
@@ -1,104 +1,101 @@
// @flow
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { useRoleMemberCountsForCommunity } from 'lib/shared/thread-utils.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { UserSurfacedPermission } from 'lib/types/thread-permission-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import css from './community-roles-modal.css';
import CreateRolesModal from './create-roles-modal.react.js';
import RolePanelEntry from './role-panel-entry.react.js';
import Button, { buttonThemes } from '../components/button.react.js';
import Modal from '../modals/modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
type CommunityRolesModalProps = {
- +community: LegacyThreadInfo | ThreadInfo,
+ +community: ThreadInfo,
};
function CommunityRolesModal(props: CommunityRolesModalProps): React.Node {
const { popModal, pushModal } = useModalContext();
const { community } = props;
- const [threadInfo, setThreadInfo] = React.useState<
- LegacyThreadInfo | ThreadInfo,
- >(community);
+ const [threadInfo, setThreadInfo] = React.useState(community);
const threadID = threadInfo.id;
- const reduxThreadInfo: ?LegacyThreadInfo | ?ThreadInfo = useSelector(
+ const reduxThreadInfo: ?ThreadInfo = useSelector(
state => threadInfoSelector(state)[threadID],
);
React.useEffect(() => {
if (reduxThreadInfo) {
setThreadInfo(reduxThreadInfo);
}
}, [reduxThreadInfo]);
const roleNamesToMembers = useRoleMemberCountsForCommunity(threadInfo);
const rolePanelList = React.useMemo(
() =>
Object.keys(roleNamesToMembers).map(roleName => (
)),
[roleNamesToMembers, threadInfo],
);
const rolePermissionsForNewRole = React.useMemo(
() => new Set(),
[],
);
const onClickCreateRole = React.useCallback(
() =>
pushModal(
,
),
[pushModal, threadInfo, rolePermissionsForNewRole],
);
return (
Roles help you group community members together and assign them certain
permissions. When people join the community, they are automatically
assigned the Members role.
Communities must always have the Admins and Members role.
{rolePanelList}
Create Role
);
}
export default CommunityRolesModal;
diff --git a/web/roles/create-roles-modal.react.js b/web/roles/create-roles-modal.react.js
index ef456f9d5..bcb1e0b78 100644
--- a/web/roles/create-roles-modal.react.js
+++ b/web/roles/create-roles-modal.react.js
@@ -1,287 +1,284 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
import {
modifyCommunityRoleActionTypes,
useModifyCommunityRole,
} from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import {
type UserSurfacedPermission,
type UserSurfacedPermissionOption,
userSurfacedPermissionOptions,
} from 'lib/types/thread-permission-types.js';
-import type {
- LegacyThreadInfo,
- RoleModificationRequest,
-} from 'lib/types/thread-types.js';
+import type { RoleModificationRequest } from 'lib/types/thread-types.js';
import { values } from 'lib/utils/objects.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import css from './create-roles-modal.css';
import Button, { buttonThemes } from '../components/button.react.js';
import EnumSettingsOption from '../components/enum-settings-option.react.js';
import LoadingIndicator from '../loading-indicator.react.js';
import Input from '../modals/input.react.js';
import Modal from '../modals/modal.react.js';
import UnsavedChangesModal from '../modals/unsaved-changes-modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
const createRolesLoadingStatusSelector = createLoadingStatusSelector(
modifyCommunityRoleActionTypes,
);
type CreateRolesModalProps = {
- +threadInfo: LegacyThreadInfo | ThreadInfo,
+ +threadInfo: ThreadInfo,
+action: 'create_role' | 'edit_role',
+existingRoleID?: string,
+roleName: string,
+rolePermissions: $ReadOnlySet,
};
type RoleCreationErrorVariant = 'already_exists' | 'unknown_error';
function CreateRolesModal(props: CreateRolesModalProps): React.Node {
const { pushModal, popModal } = useModalContext();
const { threadInfo, action, existingRoleID, roleName, rolePermissions } =
props;
const modalName = action === 'create_role' ? 'Create role' : 'Edit role';
const callModifyCommunityRole = useModifyCommunityRole();
const dispatchActionPromise = useDispatchActionPromise();
const createRolesLoadingStatus: LoadingStatus = useSelector(
createRolesLoadingStatusSelector,
);
const [pendingRoleName, setPendingRoleName] =
React.useState(roleName);
const [pendingRolePermissions, setPendingRolePermissions] =
React.useState<$ReadOnlySet>(rolePermissions);
const [roleCreationFailed, setRoleCreationFailed] =
React.useState();
const createButtonText = action === 'create_role' ? 'Create' : 'Save';
const onChangeRoleName = React.useCallback(
(event: SyntheticEvent) => {
setRoleCreationFailed(null);
setPendingRoleName(event.currentTarget.value);
},
[],
);
const onCloseModal = React.useCallback(() => {
const pendingSet = new Set(pendingRolePermissions);
const roleSet = new Set(rolePermissions);
let arePermissionsEqual = true;
if (pendingSet.size !== roleSet.size) {
arePermissionsEqual = false;
}
for (const permission of pendingSet) {
if (!roleSet.has(permission)) {
arePermissionsEqual = false;
break;
}
}
if (pendingRoleName === roleName && arePermissionsEqual) {
popModal();
return;
}
pushModal( );
}, [
pendingRoleName,
roleName,
pendingRolePermissions,
rolePermissions,
pushModal,
popModal,
]);
const clearPermissionsClassNames = classNames({
[css.clearPermissions]: true,
[css.clearPermissionsDisabled]: pendingRolePermissions.size === 0,
[css.clearPermissionsEnabled]: pendingRolePermissions.size > 0,
});
const onClearPermissions = React.useCallback(
() => setPendingRolePermissions(new Set()),
[],
);
const isUserSurfacedPermissionSelected = React.useCallback(
(option: UserSurfacedPermissionOption) =>
pendingRolePermissions.has(option.userSurfacedPermission),
[pendingRolePermissions],
);
const onEnumValuePress = React.useCallback(
(option: UserSurfacedPermissionOption) =>
setPendingRolePermissions(currentPermissions => {
if (currentPermissions.has(option.userSurfacedPermission)) {
const newPermissions = new Set(currentPermissions);
newPermissions.delete(option.userSurfacedPermission);
return newPermissions;
} else {
return new Set([
...currentPermissions,
option.userSurfacedPermission,
]);
}
}),
[],
);
const permissionsList = React.useMemo(
() =>
[...userSurfacedPermissionOptions].map(permission => (
onEnumValuePress(permission)}
icon={null}
title={permission.title}
type="checkbox"
statements={[{ statement: permission.description }]}
/>
)),
[isUserSurfacedPermissionSelected, onEnumValuePress],
);
const errorMessageClassNames = classNames({
[css.errorMessage]: true,
[css.errorMessageVisible]: !!roleCreationFailed,
});
const threadRoleNames = React.useMemo(
() => values(threadInfo.roles).map(role => role.name),
[threadInfo],
);
const onClickCreateRole = React.useCallback(() => {
if (threadRoleNames.includes(pendingRoleName) && action === 'create_role') {
setRoleCreationFailed('already_exists');
return;
}
let callModifyCommunityRoleParams: RoleModificationRequest;
if (action === 'create_role') {
callModifyCommunityRoleParams = {
community: threadInfo.id,
action,
name: pendingRoleName,
permissions: [...pendingRolePermissions],
};
} else {
invariant(existingRoleID, 'existingRoleID should be defined');
callModifyCommunityRoleParams = {
community: threadInfo.id,
existingRoleID,
action,
name: pendingRoleName,
permissions: [...pendingRolePermissions],
};
}
void dispatchActionPromise(
modifyCommunityRoleActionTypes,
(async () => {
try {
const response = await callModifyCommunityRole(
callModifyCommunityRoleParams,
);
popModal();
return response;
} catch (e) {
setRoleCreationFailed('unknown_error');
throw e;
}
})(),
);
}, [
callModifyCommunityRole,
dispatchActionPromise,
threadInfo,
action,
existingRoleID,
pendingRoleName,
pendingRolePermissions,
popModal,
threadRoleNames,
]);
const errorMessage = React.useMemo(() => {
if (roleCreationFailed === 'already_exists') {
return 'There is already a role with this name in the community';
} else {
return 'An unknown error occurred. Please try again';
}
}, [roleCreationFailed]);
const saveButtonContent = React.useMemo(() => {
if (createRolesLoadingStatus === 'loading') {
return (
);
}
return createButtonText;
}, [createRolesLoadingStatus, createButtonText]);
return (
Role name
{errorMessage}
Permissions
Clear Permissions
{permissionsList}
Back
{saveButtonContent}
);
}
export default CreateRolesModal;
diff --git a/web/roles/delete-role-modal.react.js b/web/roles/delete-role-modal.react.js
index 5ca4c3830..196470740 100644
--- a/web/roles/delete-role-modal.react.js
+++ b/web/roles/delete-role-modal.react.js
@@ -1,95 +1,94 @@
// @flow
import * as React from 'react';
import {
deleteCommunityRoleActionTypes,
useDeleteCommunityRole,
} from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { useRoleMemberCountsForCommunity } from 'lib/shared/thread-utils.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import { constructRoleDeletionMessagePrompt } from 'lib/utils/role-utils.js';
import css from './delete-role-modal.css';
import LoadingIndicator from '../loading-indicator.react.js';
import ConfirmationAlert from '../modals/confirmation-alert.react.js';
import { useSelector } from '../redux/redux-utils.js';
const deleteRoleLoadingStatusSelector = createLoadingStatusSelector(
deleteCommunityRoleActionTypes,
);
type DeleteRoleModalProps = {
- +threadInfo: LegacyThreadInfo | ThreadInfo,
+ +threadInfo: ThreadInfo,
+defaultRoleID: string,
+roleID: string,
};
function DeleteRoleModal(props: DeleteRoleModalProps): React.Node {
const { threadInfo, defaultRoleID, roleID } = props;
const { popModal } = useModalContext();
const callDeleteCommunityRole = useDeleteCommunityRole();
const dispatchActionPromise = useDispatchActionPromise();
const deleteRoleLoadingStatus: LoadingStatus = useSelector(
deleteRoleLoadingStatusSelector,
);
const roleNamesToMemberCounts = useRoleMemberCountsForCommunity(threadInfo);
const roleName = threadInfo.roles[roleID].name;
const memberCount = roleNamesToMemberCounts[roleName];
const defaultRoleName = threadInfo.roles[defaultRoleID].name;
const message = constructRoleDeletionMessagePrompt(
defaultRoleName,
memberCount,
);
const onDeleteRole = React.useCallback(() => {
void dispatchActionPromise(
deleteCommunityRoleActionTypes,
(async () => {
const response = await callDeleteCommunityRole({
community: threadInfo.id,
roleID: roleID,
});
popModal();
return response;
})(),
);
}, [
callDeleteCommunityRole,
dispatchActionPromise,
roleID,
threadInfo.id,
popModal,
]);
const deleteButtonContent = React.useMemo(() => {
if (deleteRoleLoadingStatus === 'loading') {
return (
);
}
return 'Yes, delete role';
}, [deleteRoleLoadingStatus]);
return (
{message}
);
}
export default DeleteRoleModal;
diff --git a/web/roles/role-actions-menu.react.js b/web/roles/role-actions-menu.react.js
index 818c5dfe2..6bac3b43a 100644
--- a/web/roles/role-actions-menu.react.js
+++ b/web/roles/role-actions-menu.react.js
@@ -1,118 +1,117 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { useRoleUserSurfacedPermissions } from 'lib/shared/thread-utils.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import { useRoleDeletableAndEditableStatus } from 'lib/utils/role-utils.js';
import CreateRolesModal from './create-roles-modal.react.js';
import DeleteRoleModal from './delete-role-modal.react.js';
import css from './role-actions-menu.css';
import MenuItem from '../components/menu-item.react.js';
import Menu from '../components/menu.react.js';
const menuIcon = ;
type RoleActionsMenuProps = {
- +threadInfo: LegacyThreadInfo | ThreadInfo,
+ +threadInfo: ThreadInfo,
+roleName: string,
};
function RoleActionsMenu(props: RoleActionsMenuProps): React.Node {
const { threadInfo, roleName } = props;
const { pushModal } = useModalContext();
const defaultRoleID = Object.keys(threadInfo.roles).find(
roleID => threadInfo.roles[roleID].isDefault,
);
invariant(defaultRoleID, 'default role should exist');
const existingRoleID = Object.keys(threadInfo.roles).find(
roleID => threadInfo.roles[roleID].name === roleName,
);
invariant(existingRoleID, 'existing role should exist');
const roleOptions = useRoleDeletableAndEditableStatus(
roleName,
defaultRoleID,
existingRoleID,
);
const roleNamesToUserSurfacedPermissions =
useRoleUserSurfacedPermissions(threadInfo);
const openEditRoleModal = React.useCallback(
() =>
pushModal(
,
),
[
existingRoleID,
pushModal,
roleName,
roleNamesToUserSurfacedPermissions,
threadInfo,
],
);
const openDeleteRoleModal = React.useCallback(() => {
pushModal(
,
);
}, [existingRoleID, pushModal, threadInfo, defaultRoleID]);
const menuItems = React.useMemo(() => {
const availableOptions = [];
const { isDeletable, isEditable } = roleOptions;
if (isEditable) {
availableOptions.push(
,
);
}
if (isDeletable) {
availableOptions.push(
,
);
}
return availableOptions;
}, [roleOptions, openDeleteRoleModal, openEditRoleModal]);
return (
{menuItems}
);
}
export default RoleActionsMenu;
diff --git a/web/roles/role-panel-entry.react.js b/web/roles/role-panel-entry.react.js
index 378d46637..8b6e59b47 100644
--- a/web/roles/role-panel-entry.react.js
+++ b/web/roles/role-panel-entry.react.js
@@ -1,34 +1,33 @@
// @flow
import * as React from 'react';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import RoleActionsMenu from './role-actions-menu.react.js';
import css from './role-panel-entry.css';
import CommIcon from '../CommIcon.react.js';
type RolePanelEntryProps = {
- +threadInfo: LegacyThreadInfo | ThreadInfo,
+ +threadInfo: ThreadInfo,
+roleName: string,
+memberCount: number,
};
function RolePanelEntry(props: RolePanelEntryProps): React.Node {
const { threadInfo, roleName, memberCount } = props;
return (
);
}
export default RolePanelEntry;
diff --git a/web/selectors/calendar-selectors.js b/web/selectors/calendar-selectors.js
index 3b3463f67..6db7d0bf1 100644
--- a/web/selectors/calendar-selectors.js
+++ b/web/selectors/calendar-selectors.js
@@ -1,63 +1,62 @@
// @flow
import { createSelector } from 'reselect';
import {
useFilterThreadInfos as baseUseFilterThreadInfos,
useFilterThreadSearchIndex as baseUseFilterThreadSearchIndex,
} from 'lib/selectors/calendar-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import type SearchIndex from 'lib/shared/search-index.js';
import type { FilterThreadInfo } from 'lib/types/filter-types.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import { filterThreadIDsBelongingToCommunity } from 'lib/utils/drawer-utils.react.js';
import type { AppState } from '../redux/redux-setup.js';
import { useSelector } from '../redux/redux-utils.js';
function useFilterThreadInfos(): $ReadOnlyArray {
const calendarActive = useSelector(state => state.navInfo.tab === 'calendar');
return baseUseFilterThreadInfos(calendarActive);
}
function useFilterThreadSearchIndex(): SearchIndex {
const calendarActive = useSelector(state => state.navInfo.tab === 'calendar');
return baseUseFilterThreadSearchIndex(calendarActive);
}
const filterThreadIDsBelongingToCommunitySelector: (
state: AppState,
) => ?$ReadOnlySet = createSelector(
(state: AppState) => state.communityPickerStore.calendar,
threadInfoSelector,
(
calendarPickedCommunityID: ?string,
threadInfos: {
- +[id: string]: LegacyThreadInfo | ThreadInfo,
+ +[id: string]: ThreadInfo,
},
) => {
if (!calendarPickedCommunityID) {
return null;
}
return filterThreadIDsBelongingToCommunity(
calendarPickedCommunityID,
threadInfos,
);
},
);
function useCommunityIsPickedCalendar(communityID: string): boolean {
const calendarPickedCommunityID = useSelector(
state => state.communityPickerStore.calendar,
);
return communityID === calendarPickedCommunityID;
}
export {
useFilterThreadInfos,
useFilterThreadSearchIndex,
filterThreadIDsBelongingToCommunitySelector,
filterThreadIDsBelongingToCommunity,
useCommunityIsPickedCalendar,
};
diff --git a/web/selectors/thread-selectors.js b/web/selectors/thread-selectors.js
index 81d978663..3776f9414 100644
--- a/web/selectors/thread-selectors.js
+++ b/web/selectors/thread-selectors.js
@@ -1,163 +1,160 @@
// @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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type {
- LegacyThreadInfo,
- RawThreadInfos,
-} from 'lib/types/thread-types.js';
+import type { RawThreadInfos } 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 | ?ThreadInfo,
+ 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 | ThreadInfo,
+ 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: RawThreadInfos, 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 30657c78c..d3082a8b2 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 {
clearChatCommunityFilter,
updateCalendarCommunityFilter,
updateChatCommunityFilter,
} from 'lib/actions/community-actions.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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 a41c52f4c..97c312567 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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 d9a009a87..fec27951a 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ 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 6c1c3cd86..df109dc85 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } 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 | ThreadInfo,
+ +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/tooltips/message-tooltip.react.js b/web/tooltips/message-tooltip.react.js
index 240f81f55..4b687c178 100644
--- a/web/tooltips/message-tooltip.react.js
+++ b/web/tooltips/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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from 'lib/types/thread-types.js';
import css from './message-tooltip.css';
import {
tooltipButtonStyle,
tooltipLabelStyle,
tooltipStyle,
} from './tooltip-constants.js';
import { useTooltipContext } from './tooltip-provider.js';
import type {
MessageTooltipAction,
TooltipPositionStyle,
TooltipSize,
} from './tooltip-utils.js';
import {
getEmojiKeyboardPosition,
useSendReaction,
} from '../chat/reaction-message-utils.js';
type MessageTooltipProps = {
+actions: $ReadOnlyArray,
+messageTimestamp: string,
+tooltipPositionStyle: TooltipPositionStyle,
+tooltipSize: TooltipSize,
+item: ChatMessageInfoItem,
- +threadInfo: LegacyThreadInfo | ThreadInfo,
+ +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;