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()}

{entries}
{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 (
); } 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 (
See more...
); } 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
); } 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}
{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.
{memberInfo.username}
{disabledRoleChangeMessage}
); } 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], ); 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?

); } 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 (

Your chat will be permanently deleted. There is no way to reverse this.

Delete
); } 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 (
Chat name
Description