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 (
);
}
return (
);
}, [
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('success' | 'failure')>();
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 ;
}, [
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 (
<>
{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 (
<>
{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}
);
}
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 (
);
}
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 (
);
}
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 (
);
}
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 (
);
}
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 (
<>
{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,
};