diff --git a/web/avatars/avatar.css b/web/avatars/avatar.css index 27ca9ccbe..a1c4ed648 100644 --- a/web/avatars/avatar.css +++ b/web/avatars/avatar.css @@ -1,72 +1,72 @@ .avatarContainer { position: relative; display: flex; justify-content: center; align-items: center; } .editAvatarLoadingSpinner { position: absolute; } .emojiContainer { display: flex; align-items: center; justify-content: center; } -.emojiMicro { +.emojiXSmall { font-size: 9px; height: 16px; line-height: 16px; } .emojiSmall { font-size: 14px; height: 24px; line-height: 24px; } -.emojiLarge { +.emojiMedium { font-size: 28px; height: 42px; line-height: 42px; } -.emojiProfile { +.emojiLarge { font-size: 80px; height: 112px; line-height: 112px; } -.micro { +.xSmall { border-radius: 8px; height: 16px; width: 16px; min-width: 16px; } .small { border-radius: 12px; height: 24px; width: 24px; min-width: 24px; } -.large { +.medium { border-radius: 21px; height: 42px; width: 42px; min-width: 42px; } -.profile { +.large { border-radius: 56px; height: 112px; width: 112px; min-width: 112px; } .imgContainer { object-fit: cover; } diff --git a/web/avatars/avatar.react.js b/web/avatars/avatar.react.js index 507be4ed5..7d4cc398f 100644 --- a/web/avatars/avatar.react.js +++ b/web/avatars/avatar.react.js @@ -1,96 +1,99 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; -import type { ResolvedClientAvatar } from 'lib/types/avatar-types.js'; +import type { + ResolvedClientAvatar, + AvatarSize, +} from 'lib/types/avatar-types.js'; import css from './avatar.css'; import LoadingIndicator from '../loading-indicator.react.js'; type Props = { +avatarInfo: ResolvedClientAvatar, - +size: 'micro' | 'small' | 'large' | 'profile', + +size: AvatarSize, +showSpinner?: boolean, }; function Avatar(props: Props): React.Node { const { avatarInfo, size, showSpinner } = props; const containerSizeClassName = classnames({ [css.imgContainer]: avatarInfo.type === 'image', - [css.micro]: size === 'micro', - [css.small]: size === 'small', - [css.large]: size === 'large', - [css.profile]: size === 'profile', + [css.xSmall]: size === 'XS', + [css.small]: size === 'S', + [css.medium]: size === 'M', + [css.large]: size === 'L', }); const emojiSizeClassName = classnames({ [css.emojiContainer]: true, - [css.emojiMicro]: size === 'micro', - [css.emojiSmall]: size === 'small', - [css.emojiLarge]: size === 'large', - [css.emojiProfile]: size === 'profile', + [css.emojiXSmall]: size === 'XS', + [css.emojiSmall]: size === 'S', + [css.emojiMedium]: size === 'M', + [css.emojiLarge]: size === 'L', }); const emojiContainerColorStyle = React.useMemo(() => { if (avatarInfo.type === 'emoji') { return { backgroundColor: `#${avatarInfo.color}` }; } return undefined; }, [avatarInfo.color, avatarInfo.type]); const avatar = React.useMemo(() => { if (avatarInfo.type === 'image') { return ( image avatar ); } return (
{avatarInfo.emoji}
); }, [ avatarInfo.emoji, avatarInfo.type, avatarInfo.uri, containerSizeClassName, emojiContainerColorStyle, emojiSizeClassName, ]); let loadingIndicatorSize; - if (size === 'micro') { + if (size === 'XS') { loadingIndicatorSize = 'small'; - } else if (size === 'small') { + } else if (size === 'S') { loadingIndicatorSize = 'small'; - } else if (size === 'large') { + } else if (size === 'M') { loadingIndicatorSize = 'medium'; } else { loadingIndicatorSize = 'large'; } const loadingIndicator = React.useMemo( () => (
), [loadingIndicatorSize], ); return (
{showSpinner ? loadingIndicator : null} {avatar}
); } export default Avatar; diff --git a/web/avatars/edit-thread-avatar.react.js b/web/avatars/edit-thread-avatar.react.js index 4723b9999..4433109cb 100644 --- a/web/avatars/edit-thread-avatar.react.js +++ b/web/avatars/edit-thread-avatar.react.js @@ -1,48 +1,48 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; import EditThreadAvatarMenu from './edit-thread-avatar-menu.react.js'; import css from './edit-thread-avatar.css'; import ThreadAvatar from './thread-avatar.react.js'; type Props = { +threadInfo: RawThreadInfo | 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/edit-user-avatar.react.js b/web/avatars/edit-user-avatar.react.js index b6f06ae2d..397b24ed0 100644 --- a/web/avatars/edit-user-avatar.react.js +++ b/web/avatars/edit-user-avatar.react.js @@ -1,36 +1,36 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js'; import EditUserAvatarMenu from './edit-user-avatar-menu.react.js'; import css from './edit-user-avatar.css'; import UserAvatar from './user-avatar.react.js'; type Props = { +userID: ?string, +disabled?: boolean, }; function EditUserAvatar(props: Props): React.Node { const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext should be set'); const { userAvatarSaveInProgress } = editUserAvatarContext; const { userID } = props; return (
{!userAvatarSaveInProgress ? : null}
); } export default EditUserAvatar; diff --git a/web/avatars/emoji-avatar-selection-modal.react.js b/web/avatars/emoji-avatar-selection-modal.react.js index 8de4338e8..0a2c932bc 100644 --- a/web/avatars/emoji-avatar-selection-modal.react.js +++ b/web/avatars/emoji-avatar-selection-modal.react.js @@ -1,155 +1,155 @@ // @flow import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import type { ClientAvatar, ClientEmojiAvatar, } from 'lib/types/avatar-types.js'; import Avatar from './avatar.react.js'; import css from './emoji-avatar-selection-modal.css'; import Button, { buttonThemes } from '../components/button.react.js'; import Tabs from '../components/tabs.react.js'; import LoadingIndicator from '../loading-indicator.react.js'; import Modal from '../modals/modal.react.js'; import ColorSelector from '../modals/threads/color-selector.react.js'; type TabType = 'emoji' | 'color'; type Props = { +currentAvatar: ClientAvatar, +defaultAvatar: ClientEmojiAvatar, +setEmojiAvatar: (pendingEmojiAvatar: ClientEmojiAvatar) => Promise, +avatarSaveInProgress: boolean, }; function EmojiAvatarSelectionModal(props: Props): React.Node { const { popModal } = useModalContext(); const { currentAvatar, defaultAvatar, setEmojiAvatar, avatarSaveInProgress } = props; const [updateAvatarStatus, setUpdateAvatarStatus] = React.useState(); const [pendingAvatarEmoji, setPendingAvatarEmoji] = React.useState( currentAvatar.type === 'emoji' ? currentAvatar.emoji : defaultAvatar.emoji, ); const [pendingAvatarColor, setPendingAvatarColor] = React.useState( currentAvatar.type === 'emoji' ? currentAvatar.color : defaultAvatar.color, ); const pendingEmojiAvatar: ClientEmojiAvatar = React.useMemo( () => ({ type: 'emoji', emoji: pendingAvatarEmoji, color: pendingAvatarColor, }), [pendingAvatarColor, pendingAvatarEmoji], ); const onEmojiSelect = React.useCallback(selection => { setUpdateAvatarStatus(); setPendingAvatarEmoji(selection.native); }, []); const onColorSelection = React.useCallback((hex: string) => { setUpdateAvatarStatus(); setPendingAvatarColor(hex); }, []); const onSaveAvatar = React.useCallback(async () => { try { await setEmojiAvatar(pendingEmojiAvatar); setUpdateAvatarStatus('success'); } catch { setUpdateAvatarStatus('failure'); } }, [setEmojiAvatar, pendingEmojiAvatar]); let saveButtonContent; let buttonColor; if (avatarSaveInProgress) { buttonColor = buttonThemes.standard; saveButtonContent = ; } else if (updateAvatarStatus === 'success') { buttonColor = buttonThemes.success; saveButtonContent = ( <> {'Avatar update succeeded.'} ); } else if (updateAvatarStatus === 'failure') { buttonColor = buttonThemes.danger; saveButtonContent = ( <> {'Avatar update failed. Please try again.'} ); } else { buttonColor = buttonThemes.standard; saveButtonContent = 'Save Avatar'; } const [currentTabType, setCurrentTabType] = React.useState('emoji'); return (
); } export default EmojiAvatarSelectionModal; diff --git a/web/avatars/thread-avatar.react.js b/web/avatars/thread-avatar.react.js index 1bcfd82c1..d4302820d 100644 --- a/web/avatars/thread-avatar.react.js +++ b/web/avatars/thread-avatar.react.js @@ -1,55 +1,56 @@ // @flow import * as React from 'react'; import { useAvatarForThread, useENSResolvedAvatar, } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; +import type { AvatarSize } from 'lib/types/avatar-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type RawThreadInfo, type ThreadInfo } from 'lib/types/thread-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +threadInfo: RawThreadInfo | ThreadInfo, - +size: 'micro' | 'small' | 'large' | 'profile', + +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/user-avatar.react.js b/web/avatars/user-avatar.react.js index 59ac3d7cc..8f21cb332 100644 --- a/web/avatars/user-avatar.react.js +++ b/web/avatars/user-avatar.react.js @@ -1,38 +1,39 @@ // @flow import * as React from 'react'; import { getAvatarForUser, useENSResolvedAvatar, } from 'lib/shared/avatar-utils.js'; +import type { AvatarSize } from 'lib/types/avatar-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +userID: ?string, - +size: 'micro' | 'small' | 'large' | 'profile', + +size: AvatarSize, +showSpinner?: boolean, }; function UserAvatar(props: Props): React.Node { const { userID, size, showSpinner } = props; const userInfo = useSelector(state => userID ? state.userStore.userInfos[userID] : null, ); const avatarInfo = getAvatarForUser(userInfo); const resolvedUserAvatar = useENSResolvedAvatar(avatarInfo, userInfo); return ( ); } export default UserAvatar; diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js index 9093c1035..b395b8073 100644 --- a/web/chat/chat-thread-composer.react.js +++ b/web/chat/chat-thread-composer.react.js @@ -1,263 +1,263 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { userSearchIndexForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems, useSearchUsers, notFriendNotice, } from 'lib/shared/search-utils.js'; import { createPendingThread, threadIsPending, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import css from './chat-thread-composer.css'; import UserAvatar from '../avatars/user-avatar.react.js'; import Button from '../components/button.react.js'; import Label from '../components/label.react.js'; import Search from '../components/search.react.js'; import type { InputState } from '../input/input-state.js'; import Alert from '../modals/alert.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +userInfoInputArray: $ReadOnlyArray, +otherUserInfos: { [id: string]: AccountUserInfo }, +threadID: string, +inputState: InputState, }; type ActiveThreadBehavior = | 'reset-active-thread-if-pending' | 'keep-active-thread'; function ChatThreadComposer(props: Props): React.Node { const { userInfoInputArray, otherUserInfos, threadID, inputState } = props; const [usernameInputText, setUsernameInputText] = React.useState(''); const dispatch = useDispatch(); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userInfoInputIDs = React.useMemo( () => userInfoInputArray.map(userInfo => userInfo.id), [userInfoInputArray], ); const serverSearchResults = useSearchUsers(usernameInputText); const userListItems = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, includeServerSearchUsers: serverSearchResults, }), [ usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, serverSearchResults, ], ); const userListItemsWithENSNames = useENSNames(userListItems); const { pushModal } = useModalContext(); const loggedInUserInfo = useLoggedInUserInfo(); invariant(loggedInUserInfo, 'loggedInUserInfo should be set'); const pendingPrivateThread = React.useRef( createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], }), ); const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder( pendingPrivateThread.current, ); const onSelectUserFromSearch = React.useCallback( (userListItem: UserListItem) => { const { alert, notice, disabled, ...user } = userListItem; setUsernameInputText(''); if (!alert) { dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: [...userInfoInputArray, user], }, }); } else if ( notice === notFriendNotice && userInfoInputArray.length === 0 ) { const newUserInfoInputArray = [ { id: userListItem.id, username: userListItem.username }, ]; const threadInfo = existingThreadInfoFinderForCreatingThread({ searching: true, userInfoInputArray: newUserInfoInputArray, }); dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadInfo?.id, pendingThread: threadInfo, }, }); } else { pushModal({alert.text}); } }, [ dispatch, existingThreadInfoFinderForCreatingThread, pushModal, userInfoInputArray, ], ); const onRemoveUserFromSelected = React.useCallback( (userID: string) => { const newSelectedUserList = userInfoInputArray.filter( ({ id }) => userID !== id, ); if (_isEqual(userInfoInputArray)(newSelectedUserList)) { return; } dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: newSelectedUserList, }, }); }, [dispatch, userInfoInputArray], ); const userSearchResultList = React.useMemo(() => { if ( !userListItemsWithENSNames.length || (!usernameInputText && userInfoInputArray.length) ) { return null; } const userItems = userListItemsWithENSNames.map( (userSearchResult: UserListItem) => { return (
  • ); }, ); return
      {userItems}
    ; }, [ onSelectUserFromSearch, userInfoInputArray.length, userListItemsWithENSNames, usernameInputText, ]); const hideSearch = React.useCallback( (threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadBehavior === 'keep-active-thread' || !threadIsPending(threadID) ? threadID : null, }, }); }, [dispatch, threadID], ); const onCloseSearch = React.useCallback(() => { hideSearch('reset-active-thread-if-pending'); }, [hideSearch]); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); const tagsList = React.useMemo(() => { if (!userInfoInputArrayWithENSNames?.length) { return null; } const labels = userInfoInputArrayWithENSNames.map(user => { return ( ); }); return
    {labels}
    ; }, [userInfoInputArrayWithENSNames, onRemoveUserFromSelected]); React.useEffect(() => { if (!inputState) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState]); const threadSearchContainerStyles = classNames(css.threadSearchContainer, { [css.fullHeight]: !userInfoInputArray.length, }); return (
    {tagsList} {userSearchResultList}
    ); } export default ChatThreadComposer; diff --git a/web/chat/chat-thread-list-item.react.js b/web/chat/chat-thread-list-item.react.js index c9b4dd53c..f71dd00f7 100644 --- a/web/chat/chat-thread-list-item.react.js +++ b/web/chat/chat-thread-list-item.react.js @@ -1,161 +1,161 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useAncestorThreads } from 'lib/shared/ancestor-threads.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo, useResolvedThreadInfos, } from 'lib/utils/entity-helpers.js'; import ChatThreadListItemMenu from './chat-thread-list-item-menu.react.js'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react.js'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react.js'; import css from './chat-thread-list.css'; import MessagePreview from './message-preview.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnClickThread, useThreadIsActive, } from '../selectors/thread-selectors.js'; type Props = { +item: ChatThreadItem, }; function ChatThreadListItem(props: Props): React.Node { const { item } = props; const { threadInfo, lastUpdatedTimeIncludingSidebars, mostRecentNonLocalMessage, mostRecentMessageInfo, } = item; const { id: threadID, currentUser } = threadInfo; const unresolvedAncestorThreads = useAncestorThreads(threadInfo); const ancestorThreads = useResolvedThreadInfos(unresolvedAncestorThreads); const lastActivity = shortAbsoluteDate(lastUpdatedTimeIncludingSidebars); const active = useThreadIsActive(threadID); const isCreateMode = useSelector( state => state.navInfo.chatMode === 'create', ); const onClick = useOnClickThread(item.threadInfo); const selectItemIfNotActiveCreation = React.useCallback( (event: SyntheticEvent) => { if (!isCreateMode || !active) { onClick(event); } }, [isCreateMode, active, onClick], ); const containerClassName = classNames({ [css.thread]: true, [css.activeThread]: active, }); const { unread } = currentUser; const titleClassName = classNames({ [css.title]: true, [css.unread]: unread, }); const lastActivityClassName = classNames({ [css.lastActivity]: true, [css.unread]: unread, [css.dark]: !unread, }); const breadCrumbsClassName = classNames(css.breadCrumbs, { [css.unread]: unread, }); let unreadDot; if (unread) { unreadDot =
    ; } const sidebars = item.sidebars.map((sidebarItem, index) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( 0} key={sidebarInfo.threadInfo.id} /> ); } else if (sidebarItem.type === 'seeMore') { return ( ); } else { return
    ; } }); const ancestorPath = ancestorThreads.map((thread, idx) => { const isNotLast = idx !== ancestorThreads.length - 1; const chevron = isNotLast && ( ); return ( {thread.uiName} {chevron} ); }); const { uiName } = useResolvedThreadInfo(threadInfo); return ( <>
    {unreadDot}
    - +

    {ancestorPath}

    {uiName}
    {lastActivity}
    {sidebars} ); } export default ChatThreadListItem; diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index 38cd8317a..1a4d43fcb 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,249 +1,249 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { Circle as CircleIcon, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon, } from 'react-feather'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { getComposedMessageID } from './chat-constants.js'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react.js'; import InlineEngagement from './inline-engagement.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import CommIcon from '../CommIcon.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { useMessageTooltip } from '../utils/tooltip-action-utils.js'; import { tooltipPositions } from '../utils/tooltip-utils.js'; export type ComposedMessageID = string; const availableTooltipPositionsForViewerMessage = [ tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; const availableTooltipPositionsForNonViewerMessage = [ tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; type BaseProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, +sendFailed: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius: number, }; type BaseConfig = React.Config; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, +onMouseLeave: ?() => mixed, +onMouseEnter: (event: SyntheticEvent) => mixed, +containsInlineEngagement: boolean, +stringForUser: ?string, }; class ComposedMessage extends React.PureComponent { static defaultProps: { +borderRadius: number } = { borderRadius: 8, }; render(): React.Node { assertComposableMessageType(this.props.item.messageInfo.type); const { borderRadius, item, threadInfo, shouldDisplayPinIndicator } = this.props; const { hasBeenEdited, isPinned } = item; const { id, creator } = item.messageInfo; const threadColor = threadInfo.color; const { isViewer } = creator; const contentClassName = classNames({ [css.content]: true, [css.viewerContent]: isViewer, [css.nonViewerContent]: !isViewer, }); const messageBoxContainerClassName = classNames({ [css.messageBoxContainer]: true, [css.fixedWidthMessageBoxContainer]: this.props.fixedWidth, }); const messageBoxClassName = classNames({ [css.messageBox]: true, [css.fixedWidthMessageBox]: this.props.fixedWidth, }); const messageBoxStyle = { borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius, borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius, }; let authorName = null; const { stringForUser } = this.props; if (stringForUser) { authorName = {stringForUser}; } let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconSpan; let deliveryIconColor = threadColor; if (id !== null && id !== undefined) { deliveryIconSpan = ; } else if (this.props.sendFailed) { deliveryIconSpan = ; deliveryIconColor = 'FF0000'; failedSendInfo = ; } else { deliveryIconSpan = ; } deliveryIcon = (
    {deliveryIconSpan}
    ); } let inlineEngagement = null; const label = getMessageLabel(hasBeenEdited, threadInfo.id); if ( (this.props.containsInlineEngagement && item.threadCreatedFromMessage) || Object.keys(item.reactions).length > 0 || label ) { const positioning = isViewer ? 'right' : 'left'; inlineEngagement = (
    ); } let avatar; if (!isViewer && item.endsCluster) { avatar = (
    - +
    ); } else if (!isViewer) { avatar =
    ; } const pinIconPositioning = isViewer ? 'left' : 'right'; const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin'; const pinIconContainerClassName = classNames({ [css.pinIconContainer]: true, [css.pinIconLeft]: pinIconPositioning === 'left', [css.pinIconRight]: pinIconPositioning === 'right', }); let pinIcon; if (isPinned && shouldDisplayPinIndicator) { pinIcon = (
    ); } return ( {authorName}
    {avatar}
    {pinIcon}
    {this.props.children}
    {deliveryIcon}
    {failedSendInfo} {inlineEngagement}
    ); } } type ConnectedConfig = React.Config< BaseProps, typeof ComposedMessage.defaultProps, >; const ConnectedComposedMessage: React.ComponentType = React.memo(function ConnectedComposedMessage(props) { const { item, threadInfo } = props; const inputState = React.useContext(InputStateContext); const { creator } = props.item.messageInfo; const { isViewer } = creator; const availablePositions = isViewer ? availableTooltipPositionsForViewerMessage : availableTooltipPositionsForNonViewerMessage; const containsInlineEngagement = !!item.threadCreatedFromMessage; const { onMouseLeave, onMouseEnter } = useMessageTooltip({ item, threadInfo, availablePositions, }); const shouldShowUsername = !isViewer && item.startsCluster; const stringForUser = useStringForUser(shouldShowUsername ? creator : null); return ( ); }); export default ConnectedComposedMessage; diff --git a/web/chat/thread-top-bar.react.js b/web/chat/thread-top-bar.react.js index 60bd08e19..bcd7c80f1 100644 --- a/web/chat/thread-top-bar.react.js +++ b/web/chat/thread-top-bar.react.js @@ -1,104 +1,104 @@ // @flow import * as React from 'react'; import { ChevronRight } from 'react-feather'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { threadIsPending } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import ThreadMenu from './thread-menu.react.js'; import css from './thread-top-bar.css'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import Button from '../components/button.react.js'; import { InputStateContext } from '../input/input-state.js'; import MessageResultsModal from '../modals/chat/message-results-modal.react.js'; import MessageSearchModal from '../modals/search/message-search-modal.react.js'; type ThreadTopBarProps = { +threadInfo: ThreadInfo, }; function ThreadTopBar(props: ThreadTopBarProps): React.Node { const { threadInfo } = props; const { pushModal } = useModalContext(); let threadMenu = null; if (!threadIsPending(threadInfo.id)) { threadMenu = ; } // To allow the pinned messages modal to be re-used by the message search // modal, it will be useful to make the modal accept a prop that defines it's // name, instead of setting it directly in the modal. const bannerText = React.useMemo(() => { if (!threadInfo.pinnedCount || threadInfo.pinnedCount === 0) { return ''; } const messageNoun = threadInfo.pinnedCount === 1 ? 'message' : 'messages'; return `${threadInfo.pinnedCount} pinned ${messageNoun}`; }, [threadInfo.pinnedCount]); const inputState = React.useContext(InputStateContext); const pushThreadPinsModal = React.useCallback(() => { pushModal( , ); }, [pushModal, inputState, threadInfo, bannerText]); const pinnedCountBanner = React.useMemo(() => { if (!bannerText) { return null; } return ( ); }, [bannerText, pushThreadPinsModal]); const onClickSearch = React.useCallback( () => pushModal( , ), [inputState, pushModal, threadInfo], ); const { uiName } = useResolvedThreadInfo(threadInfo); return ( <>
    - +
    {uiName}
    {threadMenu}
    {pinnedCountBanner} ); } export default ThreadTopBar; diff --git a/web/modals/chat/message-reactions-modal.react.js b/web/modals/chat/message-reactions-modal.react.js index 66b75d6c2..6cff5816e 100644 --- a/web/modals/chat/message-reactions-modal.react.js +++ b/web/modals/chat/message-reactions-modal.react.js @@ -1,43 +1,43 @@ // @flow import * as React from 'react'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { useMessageReactionsList } from 'lib/shared/reaction-utils.js'; import css from './message-reactions-modal.css'; import UserAvatar from '../../avatars/user-avatar.react.js'; import Modal from '../modal.react.js'; type Props = { +onClose: () => void, +reactions: ReactionInfo, }; function MessageReactionsModal(props: Props): React.Node { const { onClose, reactions } = props; const messageReactionsList = useMessageReactionsList(reactions); const reactionsList = React.useMemo( () => messageReactionsList.map(messageReactionUser => (
    - +
    {messageReactionUser.username}
    {messageReactionUser.reaction}
    )), [messageReactionsList], ); return (
    {reactionsList}
    ); } export default MessageReactionsModal; diff --git a/web/modals/components/add-members-item.react.js b/web/modals/components/add-members-item.react.js index cc50de2e0..fd04b16cb 100644 --- a/web/modals/components/add-members-item.react.js +++ b/web/modals/components/add-members-item.react.js @@ -1,55 +1,55 @@ // @flow import * as React from 'react'; import type { UserListItem } from 'lib/types/user-types.js'; import css from './add-members.css'; import UserAvatar from '../../avatars/user-avatar.react.js'; import Button from '../../components/button.react.js'; type AddMembersItemProps = { +userInfo: UserListItem, +onClick: (userID: string) => void, +userAdded: boolean, }; function AddMemberItem(props: AddMembersItemProps): React.Node { const { userInfo, onClick, userAdded = false } = props; const canBeAdded = !userInfo.alert; const onClickCallback = React.useCallback(() => { if (!canBeAdded) { return; } onClick(userInfo.id); }, [canBeAdded, onClick, userInfo.id]); const action = React.useMemo(() => { if (!canBeAdded) { return userInfo.alert?.title; } if (userAdded) { return Remove; } else { return 'Add'; } }, [canBeAdded, userAdded, userInfo.alert?.title]); return ( ); } export default AddMemberItem; 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 d4bfb8ca5..206bfaaa8 100644 --- a/web/modals/threads/members/change-member-role-modal.react.js +++ b/web/modals/threads/members/change-member-role-modal.react.js @@ -1,152 +1,152 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { changeThreadMemberRoles, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors.js'; import { roleIsAdminRole } from 'lib/shared/thread-utils.js'; import type { RelativeMemberInfo, ThreadInfo } from 'lib/types/thread-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import { values } from 'lib/utils/objects.js'; import css from './change-member-role-modal.css'; import UserAvatar from '../../../avatars/user-avatar.react.js'; import Button, { buttonThemes } from '../../../components/button.react.js'; import Dropdown from '../../../components/dropdown.react.js'; import { useSelector } from '../../../redux/redux-utils.js'; import Modal from '../../modal.react.js'; import UnsavedChangesModal from '../../unsaved-changes-modal.react.js'; type ChangeMemberRoleModalProps = { +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, }; function ChangeMemberRoleModal(props: ChangeMemberRoleModalProps): React.Node { const { memberInfo, threadInfo } = props; const { pushModal, popModal } = useModalContext(); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadMemberRoles = useServerCall(changeThreadMemberRoles); 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(threadInfo.id, [memberInfo.id], selectedRole); dispatchActionPromise( changeThreadMemberRolesActionTypes, createChangeThreadMemberRolesPromise(), ); popModal(); }, [ callChangeThreadMemberRoles, dispatchActionPromise, initialSelectedRole, memberInfo.id, popModal, selectedRole, threadInfo.id, ]); return (
    Members can only be assigned to one role at a time. Changing a member’s role will replace their previously assigned role.
    - +
    {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 e773bc7a9..beacbc6ad 100644 --- a/web/modals/threads/members/member.react.js +++ b/web/modals/threads/members/member.react.js @@ -1,144 +1,144 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { removeUsersFromThread } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { removeMemberFromThread, getAvailableThreadMemberActions, } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { SetState } from 'lib/types/hook-types.js'; import { type RelativeMemberInfo, type ThreadInfo, } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-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'; const commIconComponent = ; type Props = { +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +setOpenMenu: SetState, +isMenuOpen: boolean, }; function ThreadMember(props: Props): React.Node { const { memberInfo, threadInfo, setOpenMenu, isMenuOpen } = props; const { pushModal } = useModalContext(); const userName = stringForUser(memberInfo); const { roles } = threadInfo; const { role } = memberInfo; const onMenuChange = React.useCallback( menuOpen => { if (menuOpen) { setOpenMenu(() => memberInfo.id); } else { setOpenMenu(menu => (menu === memberInfo.id ? null : menu)); } }, [memberInfo.id, setOpenMenu], ); const dispatchActionPromise = useDispatchActionPromise(); const boundRemoveUsersFromThread = useServerCall(removeUsersFromThread); 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 roleName = role && roles[role].name; const label = React.useMemo( () => , [roleName], ); const memberContainerClasses = classNames(css.memberContainer, { [css.memberContainerWithMenuOpen]: isMenuOpen, }); return (
    - + {userName} {label}
    {menuItems}
    ); } export default ThreadMember; diff --git a/web/modals/threads/sidebars/sidebar.react.js b/web/modals/threads/sidebars/sidebar.react.js index 255777465..0c3373865 100644 --- a/web/modals/threads/sidebars/sidebar.react.js +++ b/web/modals/threads/sidebars/sidebar.react.js @@ -1,103 +1,103 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useMessagePreview } from 'lib/shared/message-utils.js'; import { useThreadChatMentionCandidates } from 'lib/shared/thread-utils.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './sidebars-modal.css'; import ThreadAvatar from '../../../avatars/thread-avatar.react.js'; import Button from '../../../components/button.react.js'; import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js'; import { useOnClickThread } from '../../../selectors/thread-selectors.js'; type Props = { +sidebar: ChatThreadItem, +isLastItem?: boolean, }; function Sidebar(props: Props): React.Node { const { sidebar, isLastItem } = props; const { threadInfo, lastUpdatedTime, mostRecentMessageInfo } = sidebar; const { unread } = threadInfo.currentUser; const { popModal } = useModalContext(); const navigateToThread = useOnClickThread(threadInfo); const onClickThread = React.useCallback( event => { popModal(); navigateToThread(event); }, [popModal, navigateToThread], ); const sidebarInfoClassName = classNames({ [css.sidebarInfo]: true, [css.unread]: unread, }); const previewTextClassName = classNames([ css.longTextEllipsis, css.avatarOffset, ]); const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTime), [lastUpdatedTime], ); const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const messagePreviewResult = useMessagePreview( mostRecentMessageInfo, threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const lastMessage = React.useMemo(() => { if (!messagePreviewResult) { return
    No messages
    ; } const { message, username } = messagePreviewResult; const previewText = username ? `${username.text}: ${message.text}` : message.text; return ( <>
    {previewText}
    {lastActivity}
    ); }, [lastActivity, messagePreviewResult, previewTextClassName]); const { uiName } = useResolvedThreadInfo(threadInfo); return ( ); } export default Sidebar; diff --git a/web/modals/threads/subchannels/subchannel.react.js b/web/modals/threads/subchannels/subchannel.react.js index d726cd54d..8893d9a64 100644 --- a/web/modals/threads/subchannels/subchannel.react.js +++ b/web/modals/threads/subchannels/subchannel.react.js @@ -1,90 +1,90 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { type ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useMessagePreview } from 'lib/shared/message-utils.js'; import { useThreadChatMentionCandidates } from 'lib/shared/thread-utils.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './subchannels-modal.css'; import ThreadAvatar from '../../../avatars/thread-avatar.react.js'; import Button from '../../../components/button.react.js'; import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js'; import { useOnClickThread } from '../../../selectors/thread-selectors.js'; type Props = { +chatThreadItem: ChatThreadItem, }; function Subchannel(props: Props): React.Node { const { chatThreadItem } = props; const { threadInfo, mostRecentMessageInfo, lastUpdatedTimeIncludingSidebars, } = chatThreadItem; const { unread } = threadInfo.currentUser; const subchannelTitleClassName = classNames({ [css.subchannelInfo]: true, [css.unread]: unread, }); const { popModal } = useModalContext(); const navigateToThread = useOnClickThread(threadInfo); const onClickThread = React.useCallback( event => { popModal(); navigateToThread(event); }, [popModal, navigateToThread], ); const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTimeIncludingSidebars), [lastUpdatedTimeIncludingSidebars], ); const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const messagePreviewResult = useMessagePreview( mostRecentMessageInfo, threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const lastMessage = React.useMemo(() => { if (!messagePreviewResult) { return
    No messages
    ; } const { message, username } = messagePreviewResult; const previewText = username ? `${username.text}: ${message.text}` : message.text; return ( <>
    {previewText}
    {lastActivity}
    ); }, [lastActivity, messagePreviewResult]); const { uiName } = useResolvedThreadInfo(threadInfo); return ( ); } export default Subchannel; diff --git a/web/modals/threads/thread-picker-modal.react.js b/web/modals/threads/thread-picker-modal.react.js index 86311a28d..5c304b691 100644 --- a/web/modals/threads/thread-picker-modal.react.js +++ b/web/modals/threads/thread-picker-modal.react.js @@ -1,140 +1,140 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { createSelector } from 'reselect'; import { useGlobalThreadSearchIndex } from 'lib/selectors/nav-selectors.js'; import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './thread-picker-modal.css'; import ThreadAvatar from '../../avatars/thread-avatar.react.js'; import Button from '../../components/button.react.js'; import Search from '../../components/search.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import Modal, { type ModalOverridableProps } from '../modal.react.js'; type OptionProps = { +threadInfo: ThreadInfo, +createNewEntry: (threadID: string) => void, +onCloseModal: () => void, }; function ThreadPickerOption(props: OptionProps) { const { threadInfo, createNewEntry, onCloseModal } = props; const onClickThreadOption = React.useCallback(() => { createNewEntry(threadInfo.id); onCloseModal(); }, [threadInfo.id, createNewEntry, onCloseModal]); const { uiName } = useResolvedThreadInfo(threadInfo); return (
    ); } type Props = { ...ModalOverridableProps, +createNewEntry: (threadID: string) => void, }; function ThreadPickerModal(props: Props): React.Node { const { createNewEntry, ...modalProps } = props; const onScreenThreadInfos = useSelector(onScreenEntryEditableThreadInfos); const searchIndex = useGlobalThreadSearchIndex(); invariant( onScreenThreadInfos.length > 0, "ThreadPicker can't be open when onScreenThreadInfos is empty", ); const [searchText, setSearchText] = React.useState(''); const [searchResults, setSearchResults] = React.useState>( new Set(), ); const searchRef = React.useRef(); React.useEffect(() => { searchRef.current?.focus(); }, []); const onChangeSearchText = React.useCallback( (text: string) => { const results = searchIndex.getSearchResults(text); setSearchText(text); setSearchResults(new Set(results)); }, [searchIndex], ); const listDataSelector = createSelector( state => state.onScreenThreadInfos, state => state.searchText, state => state.searchResults, ( threadInfos: $ReadOnlyArray, text: string, results: Set, ) => text ? threadInfos.filter(threadInfo => results.has(threadInfo.id)) : [...threadInfos], ); const threads = useSelector(() => listDataSelector({ onScreenThreadInfos, searchText, searchResults, }), ); const threadPickerContent = React.useMemo(() => { const options = threads.map(threadInfo => ( )); if (options.length === 0 && searchText.length > 0) { return (
    No results for {searchText}
    ); } else { return options; } }, [threads, createNewEntry, modalProps.onClose, searchText]); return (
    {threadPickerContent}
    ); } export default ThreadPickerModal; diff --git a/web/navigation-panels/nav-state-info-bar.react.js b/web/navigation-panels/nav-state-info-bar.react.js index 5727fb925..10709b3e7 100644 --- a/web/navigation-panels/nav-state-info-bar.react.js +++ b/web/navigation-panels/nav-state-info-bar.react.js @@ -1,67 +1,67 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import ThreadAncestors from './chat-thread-ancestors.react.js'; import css from './nav-state-info-bar.css'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; type NavStateInfoBarProps = { +threadInfo: ThreadInfo, }; function NavStateInfoBar(props: NavStateInfoBarProps): React.Node { const { threadInfo } = props; return ( <>
    - +
    ); } type PossiblyEmptyNavStateInfoBarProps = { +threadInfoInput: ?ThreadInfo, }; function PossiblyEmptyNavStateInfoBar( props: PossiblyEmptyNavStateInfoBarProps, ): React.Node { const { threadInfoInput } = props; const [threadInfo, setThreadInfo] = React.useState(threadInfoInput); React.useEffect(() => { if (threadInfoInput !== threadInfo) { if (threadInfoInput) { setThreadInfo(threadInfoInput); } else { const timeout = setTimeout(() => { setThreadInfo(null); }, 200); return () => clearTimeout(timeout); } } return undefined; }, [threadInfoInput, threadInfo]); const content = React.useMemo(() => { if (threadInfo) { return ; } else { return null; } }, [threadInfo]); const classes = classnames(css.topBarContainer, { [css.hide]: !threadInfoInput, [css.show]: threadInfoInput, }); return
    {content}
    ; } export default PossiblyEmptyNavStateInfoBar; diff --git a/web/settings/relationship/block-list-row.react.js b/web/settings/relationship/block-list-row.react.js index 5faf9e095..901c7d094 100644 --- a/web/settings/relationship/block-list-row.react.js +++ b/web/settings/relationship/block-list-row.react.js @@ -1,40 +1,40 @@ // @flow import * as React from 'react'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useRelationshipCallbacks } from 'lib/hooks/relationship-prompt.js'; import css from './user-list-row.css'; import type { UserRowProps } from './user-list.react.js'; import UserAvatar from '../../avatars/user-avatar.react.js'; import MenuItem from '../../components/menu-item.react.js'; import Menu from '../../components/menu.react.js'; function BlockListRow(props: UserRowProps): React.Node { const { userInfo, onMenuVisibilityChange } = props; const { unblockUser } = useRelationshipCallbacks(userInfo.id); const editIcon = ; return (
    - +
    {userInfo.username}
    ); } export default BlockListRow; diff --git a/web/settings/relationship/friend-list-row.react.js b/web/settings/relationship/friend-list-row.react.js index a24c92221..0095b6e12 100644 --- a/web/settings/relationship/friend-list-row.react.js +++ b/web/settings/relationship/friend-list-row.react.js @@ -1,93 +1,93 @@ // @flow import * as React from 'react'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useRelationshipCallbacks } from 'lib/hooks/relationship-prompt.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; import css from './user-list-row.css'; import type { UserRowProps } from './user-list.react.js'; import UserAvatar from '../../avatars/user-avatar.react.js'; import Button from '../../components/button.react.js'; import MenuItem from '../../components/menu-item.react.js'; import Menu from '../../components/menu.react.js'; const dangerButtonColor = { color: 'var(--btn-bg-danger)', }; function FriendListRow(props: UserRowProps): React.Node { const { userInfo, onMenuVisibilityChange } = props; const { friendUser, unfriendUser } = useRelationshipCallbacks(userInfo.id); const buttons = React.useMemo(() => { if (userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT) { return ( ); } if ( userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( <> ); } if (userInfo.relationshipStatus === userRelationshipStatus.FRIEND) { const editIcon = ; return (
    ); } return undefined; }, [ friendUser, unfriendUser, userInfo.relationshipStatus, onMenuVisibilityChange, ]); return (
    - +
    {userInfo.username}
    {buttons}
    ); } export default FriendListRow; diff --git a/web/sidebar/community-creation/community-creation-modal.react.js b/web/sidebar/community-creation/community-creation-modal.react.js index 072e0b32d..281a23255 100644 --- a/web/sidebar/community-creation/community-creation-modal.react.js +++ b/web/sidebar/community-creation/community-creation-modal.react.js @@ -1,209 +1,209 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { newThread, newThreadActionTypes } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { NewThreadResult } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import CommunityCreationKeyserverLabel from './community-creation-keyserver-label.react.js'; import CommunityCreationMembersModal from './community-creation-members-modal.react.js'; import css from './community-creation-modal.css'; import UserAvatar from '../../avatars/user-avatar.react.js'; import CommIcon from '../../CommIcon.react.js'; import Button, { buttonThemes } from '../../components/button.react.js'; import EnumSettingsOption from '../../components/enum-settings-option.react.js'; import LoadingIndicator from '../../loading-indicator.react.js'; import Input from '../../modals/input.react.js'; import Modal from '../../modals/modal.react.js'; import { updateNavInfoActionType } from '../../redux/action-types.js'; import { useSelector } from '../../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../../selectors/nav-selectors.js'; const announcementStatements = [ { statement: `This option sets the community’s root channel to an ` + `announcement channel. Only admins and other admin-appointed ` + `roles can send messages in an announcement channel.`, isStatementValid: true, styleStatementBasedOnValidity: false, }, ]; const createNewCommunityLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); function CommunityCreationModal(): React.Node { const modalContext = useModalContext(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); const calendarQueryFunc = useSelector(nonThreadCalendarQuery); const [errorMessage, setErrorMessage] = React.useState(); const [pendingCommunityName, setPendingCommunityName] = React.useState(''); const onChangePendingCommunityName = React.useCallback( (event: SyntheticEvent) => { setErrorMessage(); setPendingCommunityName(event.currentTarget.value); }, [], ); const [announcementSetting, setAnnouncementSetting] = React.useState(false); const onAnnouncementSelected = React.useCallback(() => { setErrorMessage(); setAnnouncementSetting(!announcementSetting); }, [announcementSetting]); const callCreateNewCommunity = React.useCallback(async () => { const calendarQuery = calendarQueryFunc(); try { const newThreadResult: NewThreadResult = await callNewThread({ name: pendingCommunityName, type: announcementSetting ? threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT : threadTypes.COMMUNITY_ROOT, calendarQuery, }); return newThreadResult; } catch (e) { setErrorMessage('Community creation failed. Please try again.'); throw e; } }, [ announcementSetting, calendarQueryFunc, callNewThread, pendingCommunityName, ]); const createNewCommunity = React.useCallback(async () => { setErrorMessage(); const newThreadResultPromise = callCreateNewCommunity(); dispatchActionPromise(newThreadActionTypes, newThreadResultPromise); const newThreadResult: NewThreadResult = await newThreadResultPromise; const { newThreadID } = newThreadResult; await dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: newThreadID, }, }); modalContext.popModal(); modalContext.pushModal( , ); }, [callCreateNewCommunity, dispatch, dispatchActionPromise, modalContext]); const megaphoneIcon = React.useMemo( () => , [], ); const avatarNodeEnabled = false; let avatarNode; if (avatarNodeEnabled) { avatarNode = (
    - +
    ); } const createNewCommunityLoadingStatus: LoadingStatus = useSelector( createNewCommunityLoadingStatusSelector, ); let buttonContent; if (createNewCommunityLoadingStatus === 'loading') { buttonContent = ( ); } else if (errorMessage) { buttonContent = errorMessage; } else { buttonContent = 'Create community'; } return (
    {avatarNode}
    Community Name
    You may edit your community’s image and name later.

    Optional settings
    ); } export default CommunityCreationModal; diff --git a/web/sidebar/community-drawer-item-community.react.js b/web/sidebar/community-drawer-item-community.react.js index 4ff8a7634..24e52fa6d 100644 --- a/web/sidebar/community-drawer-item-community.react.js +++ b/web/sidebar/community-drawer-item-community.react.js @@ -1,100 +1,100 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { getCommunityDrawerItemCommunityHandler } from './community-drawer-item-community-handlers.react.js'; import css from './community-drawer-item.css'; import type { DrawerItemProps } from './community-drawer-item.react.js'; import { getChildren, getExpandButton, } from './community-drawer-utils.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import CommunityActionsMenu from '../components/community-actions-menu.react.js'; function CommunityDrawerItemCommunity(props: DrawerItemProps): React.Node { const { itemData: { threadInfo, itemChildren, hasSubchannelsButton, labelStyle }, paddingLeft, expandable = true, handlerType, } = props; const Handler = getCommunityDrawerItemCommunityHandler(handlerType); const [handler, setHandler] = React.useState({ onClick: () => {}, isActive: false, expanded: false, toggleExpanded: () => {}, }); const children = React.useMemo( () => getChildren({ expanded: handler.expanded, hasSubchannelsButton, itemChildren, paddingLeft, threadInfo, expandable, handlerType, }), [ handler.expanded, hasSubchannelsButton, itemChildren, paddingLeft, threadInfo, expandable, handlerType, ], ); const itemExpandButton = React.useMemo( () => getExpandButton({ expandable, childrenLength: itemChildren?.length, hasSubchannelsButton, onExpandToggled: null, expanded: handler.expanded, }), [expandable, itemChildren?.length, hasSubchannelsButton, handler.expanded], ); const classes = classnames({ [css.communityBase]: true, [css.communityExpanded]: handler.expanded, }); const { uiName } = useResolvedThreadInfo(threadInfo); const titleLabel = classnames({ [css[labelStyle]]: true, [css.activeTitle]: handler.isActive, }); const style = React.useMemo(() => ({ paddingLeft }), [paddingLeft]); return ( ); } const MemoizedCommunityDrawerItemCommunity: React.ComponentType = React.memo(CommunityDrawerItemCommunity); export default MemoizedCommunityDrawerItemCommunity; diff --git a/web/sidebar/community-drawer-item.react.js b/web/sidebar/community-drawer-item.react.js index f7d616c8e..b9b8230bc 100644 --- a/web/sidebar/community-drawer-item.react.js +++ b/web/sidebar/community-drawer-item.react.js @@ -1,116 +1,116 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import type { CommunityDrawerItemData } from 'lib/utils/drawer-utils.react.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import type { HandlerProps } from './community-drawer-item-handlers.react.js'; import { getCommunityDrawerItemHandler } from './community-drawer-item-handlers.react.js'; import css from './community-drawer-item.css'; import { getChildren, getExpandButton, } from './community-drawer-utils.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import type { NavigationTab } from '../types/nav-types.js'; export type DrawerItemProps = { +itemData: CommunityDrawerItemData, +paddingLeft: number, +expandable?: boolean, +handlerType: NavigationTab, }; function CommunityDrawerItem(props: DrawerItemProps): React.Node { const { itemData: { threadInfo, itemChildren, hasSubchannelsButton, labelStyle }, paddingLeft, expandable = true, handlerType, } = props; const [handler, setHandler] = React.useState({ onClick: () => {}, expanded: false, toggleExpanded: () => {}, }); const Handler = getCommunityDrawerItemHandler(handlerType); const children = React.useMemo( () => getChildren({ expanded: handler.expanded, hasSubchannelsButton, itemChildren, paddingLeft, threadInfo, expandable, handlerType, }), [ handler.expanded, hasSubchannelsButton, itemChildren, paddingLeft, threadInfo, expandable, handlerType, ], ); const itemExpandButton = React.useMemo( () => getExpandButton({ expandable, childrenLength: itemChildren.length, hasSubchannelsButton, onExpandToggled: handler.toggleExpanded, expanded: handler.expanded, }), [ expandable, itemChildren.length, hasSubchannelsButton, handler.toggleExpanded, handler.expanded, ], ); const { uiName } = useResolvedThreadInfo(threadInfo); const titleLabel = classnames({ [css[labelStyle]]: true, [css.activeTitle]: handler.isActive, }); const style = React.useMemo(() => ({ paddingLeft }), [paddingLeft]); return ( <>
    {itemExpandButton} - +
    {uiName}
    {children}
    ); } export type CommunityDrawerItemChatProps = { +itemData: CommunityDrawerItemData, +paddingLeft: number, +expandable?: boolean, +handler: React.ComponentType, }; const MemoizedCommunityDrawerItem: React.ComponentType = React.memo(CommunityDrawerItem); export default MemoizedCommunityDrawerItem; diff --git a/web/utils/typeahead-utils.js b/web/utils/typeahead-utils.js index 4ac201c98..4e67d4b0c 100644 --- a/web/utils/typeahead-utils.js +++ b/web/utils/typeahead-utils.js @@ -1,278 +1,275 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { getNewTextAndSelection, type MentionTypeaheadSuggestionItem, type TypeaheadTooltipActionItem, getRawChatMention, } from 'lib/shared/mention-utils.js'; import { stringForUserExplicit } from 'lib/shared/user-utils.js'; import type { SetState } from 'lib/types/hook-types.js'; import { validChatNameRegexString } from 'lib/utils/validation-utils.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import { typeaheadStyle } from '../chat/chat-constants.js'; import css from '../chat/typeahead-tooltip.css'; import Button from '../components/button.react.js'; const webMentionTypeaheadRegex: RegExp = new RegExp( `(?(?:^(?:.|\n)*\\s+)|^)@(?${validChatNameRegexString})?$`, ); export type TooltipPosition = { +top: number, +left: number, }; function getCaretOffsets( textarea: HTMLTextAreaElement, text: string, ): { caretTopOffset: number, caretLeftOffset: number } { if (!textarea) { return { caretTopOffset: 0, caretLeftOffset: 0 }; } // terribly hacky but it works I guess :D // we had to use it, as it's hard to count lines in textarea // and track cursor position within it as // lines can be wrapped into new lines without \n character // as result of overflow const textareaStyle: CSSStyleDeclaration = window.getComputedStyle( textarea, null, ); const div = document.createElement('div'); for (const styleName of textareaStyle) { div.style.setProperty(styleName, textareaStyle.getPropertyValue(styleName)); } div.style.display = 'inline-block'; div.style.position = 'absolute'; div.textContent = text; const span = document.createElement('span'); span.textContent = textarea.value.slice(text.length); div.appendChild(span); document.body?.appendChild(div); const { offsetTop, offsetLeft } = span; document.body?.removeChild(div); const textareaWidth = parseInt(textareaStyle.getPropertyValue('width')); const caretLeftOffset = offsetLeft + typeaheadStyle.tooltipWidth > textareaWidth ? textareaWidth - typeaheadStyle.tooltipWidth : offsetLeft; return { caretTopOffset: offsetTop - textarea.scrollTop, caretLeftOffset, }; } export type GetTypeaheadTooltipActionsParams = { +inputStateDraft: string, +inputStateSetDraft: (draft: string) => mixed, +inputStateSetTextCursorPosition: (newPosition: number) => mixed, +suggestions: $ReadOnlyArray, +textBeforeAtSymbol: string, +query: string, }; function mentionTypeaheadTooltipActionExecuteHandler({ textBeforeAtSymbol, inputStateDraft, query, mentionText, inputStateSetDraft, inputStateSetTextCursorPosition, }) { const { newText, newSelectionStart } = getNewTextAndSelection( textBeforeAtSymbol, inputStateDraft, query, mentionText, ); inputStateSetDraft(newText); inputStateSetTextCursorPosition(newSelectionStart); } function getMentionTypeaheadTooltipActions( params: GetTypeaheadTooltipActionsParams, ): $ReadOnlyArray> { const { inputStateDraft, inputStateSetDraft, inputStateSetTextCursorPosition, suggestions, textBeforeAtSymbol, query, } = params; const actions = []; for (const suggestion of suggestions) { if (suggestion.type === 'user') { const suggestedUser = suggestion.userInfo; if (stringForUserExplicit(suggestedUser) === 'anonymous') { continue; } const mentionText = `@${stringForUserExplicit(suggestedUser)}`; actions.push({ key: suggestedUser.id, execute: () => mentionTypeaheadTooltipActionExecuteHandler({ textBeforeAtSymbol, inputStateDraft, query, mentionText, inputStateSetDraft, inputStateSetTextCursorPosition, }), actionButtonContent: { type: 'user', userInfo: suggestedUser, }, }); } else if (suggestion.type === 'chat') { const suggestedChat = suggestion.threadInfo; const mentionText = getRawChatMention(suggestedChat); actions.push({ key: suggestedChat.id, execute: () => mentionTypeaheadTooltipActionExecuteHandler({ textBeforeAtSymbol, inputStateDraft, query, mentionText, inputStateSetDraft, inputStateSetTextCursorPosition, }), actionButtonContent: { type: 'chat', threadInfo: suggestedChat, }, }); } } return actions; } export type GetMentionTypeaheadTooltipButtonsParams = { +setChosenPositionInOverlay: SetState, +chosenPositionInOverlay: number, +actions: $ReadOnlyArray>, }; function getMentionTypeaheadTooltipButtons( params: GetMentionTypeaheadTooltipButtonsParams, ): $ReadOnlyArray { const { setChosenPositionInOverlay, chosenPositionInOverlay, actions } = params; return actions.map((action, idx) => { const { key, execute, actionButtonContent } = action; const buttonClasses = classNames(css.suggestion, { [css.suggestionHover]: idx === chosenPositionInOverlay, }); const onMouseMove: ( event: SyntheticEvent, ) => mixed = () => { setChosenPositionInOverlay(idx); }; let avatarComponent = null; let typeaheadButtonText = null; if (actionButtonContent.type === 'user') { const suggestedUser = actionButtonContent.userInfo; avatarComponent = ( - + ); typeaheadButtonText = `@${stringForUserExplicit(suggestedUser)}`; } else if (actionButtonContent.type === 'chat') { const suggestedChat = actionButtonContent.threadInfo; avatarComponent = ( - + ); typeaheadButtonText = `@${suggestedChat.uiName}`; } return ( ); }); } function getTypeaheadOverlayScroll( currentScrollTop: number, chosenActionPosition: number, ): number { const upperButtonBoundary = chosenActionPosition * typeaheadStyle.rowHeight; const lowerButtonBoundary = (chosenActionPosition + 1) * typeaheadStyle.rowHeight; if (upperButtonBoundary < currentScrollTop) { return upperButtonBoundary; } else if ( lowerButtonBoundary - typeaheadStyle.tooltipMaxHeight > currentScrollTop ) { return ( lowerButtonBoundary + typeaheadStyle.tooltipVerticalPadding - typeaheadStyle.tooltipMaxHeight ); } return currentScrollTop; } function getTypeaheadTooltipPosition( textarea: HTMLTextAreaElement, actionsLength: number, textBeforeAtSymbol: string, ): TooltipPosition { const { caretTopOffset, caretLeftOffset } = getCaretOffsets( textarea, textBeforeAtSymbol, ); const textareaBoundingClientRect = textarea.getBoundingClientRect(); const top: number = textareaBoundingClientRect.top - Math.min( typeaheadStyle.tooltipVerticalPadding + actionsLength * typeaheadStyle.rowHeight, typeaheadStyle.tooltipMaxHeight, ) - typeaheadStyle.tooltipTopOffset + caretTopOffset; const left: number = textareaBoundingClientRect.left - typeaheadStyle.tooltipLeftOffset + caretLeftOffset; return { top, left }; } export { webMentionTypeaheadRegex, getCaretOffsets, getMentionTypeaheadTooltipActions, getMentionTypeaheadTooltipButtons, getTypeaheadOverlayScroll, getTypeaheadTooltipPosition, };