Merge branch 'feature/ses-145/leave-conversation-update' into userconfig_disappearingmessage

pull/3016/head
Audric Ackermann 1 year ago
commit 24887dd05b

@ -97,13 +97,24 @@
"continue": "Continue",
"error": "Error",
"delete": "Delete",
"hide": "Hide",
"messageDeletionForbidden": "You dont have permission to delete others messages",
"deleteJustForMe": "Delete just for me",
"deleteForEveryone": "Delete for everyone",
"deleteMessagesQuestion": "Delete $count$ messages?",
"deleteMessageQuestion": "Delete this message?",
"deleteMessages": "Delete Messages",
"deleteMessagesConfirmation": "Permanently delete the messages in this conversation?",
"hideConversation": "Hide Conversation",
"hideNoteToSelfConfirmation": "Are you sure you want to hide your <b>Note to Self</b> conversation?",
"hideConversationFailed": "Failed to hide the Conversation!",
"hideConversationFailedPleaseTryAgain": "Unable to hide the conversation, please try again",
"deleteConversation": "Delete Conversation",
"deleteConversationConfirmation": "Are you sure you want to delete your conversation with <b>$name$</b>?",
"deleteConversationFailed": "Failed to delete the Conversation!",
"deleteConversationFailedPleaseTryAgain": "Unable to delete the conversation, please try again",
"hiding": "Hiding...",
"leaving": "Leaving...",
"deleted": "$count$ deleted",
"messageDeletedPlaceholder": "This message has been deleted",
"from": "From:",
@ -117,7 +128,6 @@
"failed": "Failed",
"read": "Read",
"resend": "Resend",
"deleteConversationConfirmation": "Permanently delete the messages in this conversation?",
"clear": "Clear",
"clearAllData": "Clear All Data",
"deleteAccountWarning": "This will permanently delete your messages and contacts.",
@ -285,10 +295,19 @@
"banUserAndDeleteAll": "Ban and Delete All",
"userBanned": "Banned successfully",
"userBanFailed": "Ban failed!",
"leave": "Leave",
"leaveGroup": "Leave Group",
"leaveAndRemoveForEveryone": "Leave Group and Remove for Everyone",
"leaveGroupConfirmation": "Are you sure you want to leave this group?",
"leaveGroupConfirmation": "Are you sure you want to leave <b>$name$</b>?",
"leaveGroupConfirmationAdmin": "As you are the admin of this group, if you leave it it will be removed for every current members. Are you sure you want to leave this group?",
"leaveGroupConrirmationOnlyAdminLegacy": "Are you sure you want to leave <b>$name$</b>? This will deactivate the group for all members.",
"leaveGroupConfirmationOnlyAdmin": "You are the only admin in <b>$name$</b>",
"leaveGroupConfirmationOnlyAdminWarning": "Group settings and members cannot be changed without an admin",
"leaveGroupFailed": "Failed to leave Group!",
"leaveGroupFailedPleaseTryAgain": "Unable to leave the Group, please try again",
"leaveCommunity": "Leave Community",
"leaveCommunityFailed": "Failed to leave Community!",
"leaveCommunityFailedPleaseTryAgain": "Unable to leave the Community, please try again",
"cannotRemoveCreatorFromGroup": "Cannot remove this user",
"cannotRemoveCreatorFromGroupDesc": "You cannot remove this user as they are the creator of the group.",
"noContactsForGroup": "You don't have any contacts yet",
@ -351,6 +370,7 @@
"editProfileModalTitle": "Profile",
"groupNamePlaceholder": "Group Name",
"inviteContacts": "Invite Contacts",
"addModerator": "Add Admin",
"addModerators": "Add Admins",
"removeModerators": "Remove Admins",
"addAsModerator": "Add as Admin",

@ -91,7 +91,7 @@ type Props = {
onEmojiClicked: (emoji: FixedBaseEmoji) => void;
show: boolean;
isModal?: boolean;
// NOTE Currently this doesn't work but we have a PR waiting to be merged to resolve this. William Grant 30/09/2022
// NOTE Currently this doesn't work but we have a PR waiting to be merged to resolve this
onKeyDown?: (event: any) => void;
};

@ -11,6 +11,7 @@ import {
PropsForExpirationTimer,
PropsForGroupInvitation,
PropsForGroupUpdate,
PropsForInteractionNotification,
} from '../../state/ducks/conversations';
import {
getOldBottomMessageId,
@ -28,6 +29,7 @@ import { CallNotification } from './message/message-item/notification-bubble/Cal
import { DataExtractionNotification } from './message/message-item/DataExtractionNotification';
import { SessionLastSeenIndicator } from './SessionLastSeenIndicator';
import { TimerNotification } from './TimerNotification';
import { InteractionNotification } from './message/message-item/InteractionNotification';
function isNotTextboxEvent(e: KeyboardEvent) {
return (e?.target as any)?.type === undefined;
@ -118,6 +120,7 @@ export const SessionMessagesList = (props: {
) : null;
const componentToMerge = [dateBreak, unreadIndicator];
if (messageProps.message?.messageType === 'group-notification') {
const msgProps = messageProps.message.props as PropsForGroupUpdate;
return [<GroupUpdateMessage key={messageId} {...msgProps} />, ...componentToMerge];
@ -155,6 +158,12 @@ export const SessionMessagesList = (props: {
return [<CallNotification key={messageId} {...msgProps} />, ...componentToMerge];
}
if (messageProps.message?.messageType === 'interaction-notification') {
const msgProps = messageProps.message.props as PropsForInteractionNotification;
return [<InteractionNotification key={messageId} {...msgProps} />, ...componentToMerge];
}
if (!messageProps) {
return null;
}

@ -0,0 +1,82 @@
import React from 'react';
import { isEmpty } from 'lodash';
import styled from 'styled-components';
import { useIsPrivate, useIsPublic } from '../../../../hooks/useParamSelector';
import {
ConversationInteractionStatus,
ConversationInteractionType,
} from '../../../../interactions/conversationInteractions';
import { PropsForInteractionNotification } from '../../../../state/ducks/conversations';
import { assertUnreachable } from '../../../../types/sqlSharedTypes';
import { Flex } from '../../../basic/Flex';
import { ReadableMessage } from './ReadableMessage';
const StyledFailText = styled.div`
color: var(--danger-color);
`;
export const InteractionNotification = (props: PropsForInteractionNotification) => {
const { notificationType, convoId, messageId, receivedAt, isUnread } = props;
const { interactionStatus, interactionType } = notificationType;
const isGroup = !useIsPrivate(convoId);
const isCommunity = useIsPublic(convoId);
// NOTE at this time we don't show visible control messages in communities, that might change in future...
if (isCommunity) {
return null;
}
if (interactionStatus !== ConversationInteractionStatus.Error) {
// NOTE For now we only show interaction errors in the message history
return null;
}
let text = '';
switch (interactionType) {
case ConversationInteractionType.Hide:
text = window.i18n('hideConversationFailedPleaseTryAgain');
break;
case ConversationInteractionType.Leave:
text = isCommunity
? window.i18n('leaveCommunityFailedPleaseTryAgain')
: isGroup
? window.i18n('leaveGroupFailedPleaseTryAgain')
: window.i18n('deleteConversationFailedPleaseTryAgain');
break;
default:
assertUnreachable(
interactionType,
`InteractionErrorMessage: Missing case error "${interactionType}"`
);
}
if (isEmpty(text)) {
return null;
}
return (
<ReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
key={`readable-message-${messageId}`}
dataTestId="interaction-notification"
>
<Flex
id={`convo-interaction-${convoId}`}
container={true}
flexDirection="row"
alignItems="center"
justifyContent="center"
margin={'var(--margins-md) var(--margins-sm)'}
data-testid="control-message"
>
<StyledFailText>{text}</StyledFailText>
</Flex>
</ReadableMessage>
);
};

@ -7,10 +7,14 @@ import styled from 'styled-components';
import { Data } from '../../../../data/data';
import { SessionIconButton } from '../../../icon';
import { useDisappearingMessageSettingText } from '../../../../hooks/useParamSelector';
import {
useConversationUsername,
useDisappearingMessageSettingText,
} from '../../../../hooks/useParamSelector';
import { useIsRightPanelShowing } from '../../../../hooks/useUI';
import {
deleteAllMessagesByConvoIdWithConfirmation,
ConversationInteractionStatus,
ConversationInteractionType,
showAddModeratorsByConvoId,
showInviteContactByConvoId,
showLeaveGroupByConvoId,
@ -30,6 +34,7 @@ import {
useSelectedIsKickedFromGroup,
useSelectedIsLeft,
useSelectedIsPublic,
useSelectedLastMessage,
useSelectedSubscriberCount,
useSelectedWeAreAdmin,
} from '../../../../state/selectors/selectedConversation';
@ -207,9 +212,11 @@ export const OverlayRightPanelSettings = () => {
const [media, setMedia] = useState<Array<MediaItemType>>([]);
const selectedConvoKey = useSelectedConversationKey();
const dispatch = useDispatch();
const selectedUsername = useConversationUsername(selectedConvoKey) || selectedConvoKey;
const isShowing = useIsRightPanelShowing();
const dispatch = useDispatch();
const isActive = useSelectedIsActive();
const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
@ -221,6 +228,7 @@ export const OverlayRightPanelSettings = () => {
convoId: selectedConvoKey,
separator: ': ',
});
const lastMessage = useSelectedLastMessage();
useEffect(() => {
let isCancelled = false;
@ -271,7 +279,10 @@ export const OverlayRightPanelSettings = () => {
const commonNoShow = isKickedFromGroup || left || isBlocked || !isActive;
const hasDisappearingMessages = !isPublic && !commonNoShow;
const leaveGroupString = isPublic
? window.i18n('leaveGroup')
? window.i18n('leaveCommunity')
: lastMessage?.interactionType === ConversationInteractionType.Leave &&
lastMessage?.interactionStatus === ConversationInteractionStatus.Error
? window.i18n('deleteConversation')
: isKickedFromGroup
? window.i18n('youGotKickedFromGroup')
: left
@ -282,13 +293,9 @@ export const OverlayRightPanelSettings = () => {
const showAddRemoveModeratorsButton = weAreAdmin && !commonNoShow && isPublic;
const showUpdateGroupMembersButton = !isPublic && isGroup && !commonNoShow;
const deleteConvoAction = isPublic
? () => {
deleteAllMessagesByConvoIdWithConfirmation(selectedConvoKey); // TODOLATER this does not delete the public group and showLeaveGroupByConvoId is not only working for closed groups
}
: () => {
showLeaveGroupByConvoId(selectedConvoKey);
};
const deleteConvoAction = async () => {
await showLeaveGroupByConvoId(selectedConvoKey, selectedUsername);
};
return (
<>

@ -1,61 +0,0 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { getConversationController } from '../../session/conversations';
import { adminLeaveClosedGroup } from '../../state/ducks/modalDialog';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SpacerLG } from '../basic/Text';
import { SessionSpinner } from '../basic/SessionSpinner';
const StyledWarning = styled.p`
max-width: 500px;
line-height: 1.3333;
`;
export const AdminLeaveClosedGroupDialog = (props: { conversationId: string }) => {
const dispatch = useDispatch();
const convo = getConversationController().get(props.conversationId);
const [loading, setLoading] = useState(false);
const titleText = `${window.i18n('leaveGroup')} ${convo?.getRealSessionUsername() || ''}`;
const closeDialog = () => {
dispatch(adminLeaveClosedGroup(null));
};
const onClickOK = async () => {
if (loading) {
return;
}
setLoading(true);
// we know want to delete a closed group right after we've left it, so we can call the deleteContact which takes care of it all
await getConversationController().deleteClosedGroup(props.conversationId, {
fromSyncMessage: false,
sendLeaveMessage: true,
});
setLoading(false);
closeDialog();
};
return (
<SessionWrapperModal title={titleText} onClose={closeDialog}>
<SpacerLG />
<StyledWarning>{window.i18n('leaveGroupConfirmationAdmin')}</StyledWarning>
<SessionSpinner loading={loading} />
<div className="session-modal__button-group">
<SessionButton
text={window.i18n('leaveAndRemoveForEveryone')}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.Simple}
onClick={onClickOK}
/>
<SessionButton
text={window.i18n('cancel')}
buttonType={SessionButtonType.Simple}
onClick={closeDialog}
/>
</div>
</SessionWrapperModal>
);
};

@ -2,7 +2,6 @@ import React from 'react';
import { useSelector } from 'react-redux';
import {
getAddModeratorsModal,
getAdminLeaveClosedGroupDialog,
getBanOrUnbanUserModalState,
getChangeNickNameDialog,
getConfirmModal,
@ -20,7 +19,6 @@ import {
getUpdateGroupNameModal,
getUserDetailsModal,
} from '../../state/selectors/modal';
import { AdminLeaveClosedGroupDialog } from './AdminLeaveClosedGroupDialog';
import { InviteContactsDialog } from './InviteContactsDialog';
import { DeleteAccountModal } from './DeleteAccountModal';
import { EditProfileDialog } from './EditProfileDialog';
@ -51,7 +49,6 @@ export const ModalContainer = () => {
const editProfileModalState = useSelector(getEditProfileDialog);
const onionPathModalState = useSelector(getOnionPathDialog);
const recoveryPhraseModalState = useSelector(getRecoveryPhraseDialog);
const adminLeaveClosedGroupModalState = useSelector(getAdminLeaveClosedGroupDialog);
const sessionPasswordModalState = useSelector(getSessionPasswordDialog);
const deleteAccountModalState = useSelector(getDeleteAccountModalState);
const banOrUnbanUserModalState = useSelector(getBanOrUnbanUserModalState);
@ -74,9 +71,6 @@ export const ModalContainer = () => {
{editProfileModalState && <EditProfileDialog {...editProfileModalState} />}
{onionPathModalState && <OnionPathModal {...onionPathModalState} />}
{recoveryPhraseModalState && <SessionSeedModal {...recoveryPhraseModalState} />}
{adminLeaveClosedGroupModalState && (
<AdminLeaveClosedGroupDialog {...adminLeaveClosedGroupModalState} />
)}
{sessionPasswordModalState && <SessionPasswordDialog {...sessionPasswordModalState} />}
{deleteAccountModalState && <DeleteAccountModal {...deleteAccountModalState} />}
{confirmModalState && <SessionConfirm {...confirmModalState} />}

@ -1,8 +1,12 @@
import { Dispatch } from '@reduxjs/toolkit';
import { shell } from 'electron';
import React, { useState } from 'react';
import React, { Dispatch, useEffect, useState } from 'react';
import styled from 'styled-components';
import { useLastMessage } from '../../hooks/useParamSelector';
import { MessageInteraction } from '../../interactions';
import {
ConversationInteractionStatus,
updateConversationInteractionState,
} from '../../interactions/conversationInteractions';
import { updateConfirmModal } from '../../state/ducks/modalDialog';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
@ -15,12 +19,20 @@ import { SessionIcon, SessionIconSize, SessionIconType } from '../icon';
const StyledSubText = styled(SessionHtmlRenderer)<{ textLength: number }>`
font-size: var(--font-size-md);
line-height: 1.5;
margin-bottom: var(--margins-lg);
max-width: ${props =>
props.textLength > 90
? '60ch'
: '33ch'}; // this is ugly, but we want the dialog description to have multiple lines when a short text is displayed
`;
const StyledSubMessageText = styled(SessionHtmlRenderer)`
// Overrides SASS in this one case
margin-top: 0;
margin-bottom: var(--margins - md);
`;
export interface SessionConfirmDialogProps {
message?: string;
messageSub?: string;
@ -42,6 +54,7 @@ export interface SessionConfirmDialogProps {
* function to run on close click. Closes modal after execution by default
*/
onClickCancel?: () => any;
okText?: string;
cancelText?: string;
hideCancel?: boolean;
@ -51,6 +64,8 @@ export interface SessionConfirmDialogProps {
iconSize?: SessionIconSize;
shouldShowConfirm?: boolean | undefined;
showExitIcon?: boolean | undefined;
headerReverse?: boolean;
conversationId?: string;
}
export const SessionConfirm = (props: SessionConfirmDialogProps) => {
@ -69,9 +84,13 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
shouldShowConfirm,
onClickCancel,
showExitIcon,
headerReverse,
closeAfterInput = true,
conversationId,
} = props;
const lastMessage = useLastMessage(conversationId);
const [isLoading, setIsLoading] = useState(false);
const [chosenOption, setChosenOption] = useState(
radioOptions?.length ? radioOptions[0].value : ''
@ -98,6 +117,18 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
}
};
useEffect(() => {
if (isLoading) {
if (conversationId && lastMessage?.interactionType) {
void updateConversationInteractionState({
conversationId,
type: lastMessage?.interactionType,
status: ConversationInteractionStatus.Loading,
});
}
}
}, [isLoading, conversationId, lastMessage?.interactionType]);
if (shouldShowConfirm && !shouldShowConfirm) {
return null;
}
@ -113,15 +144,15 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
if (onClickClose) {
onClickClose();
}
window.inboxStore?.dispatch(updateConfirmModal(null));
};
return (
<SessionWrapperModal
title={title}
onClose={onClickClose}
showExitIcon={showExitIcon}
showHeader={showHeader}
headerReverse={headerReverse}
>
{!showHeader && <SpacerLG />}
@ -134,8 +165,15 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
)}
<StyledSubText tag="span" textLength={message.length} html={message} />
<SessionHtmlRenderer tag="span" className="session-confirm-sub-message" html={messageSub} />
{radioOptions && chosenOption !== '' && (
{messageSub && (
<StyledSubMessageText
tag="span"
className="session-confirm-sub-message"
html={messageSub}
/>
)}
{radioOptions && chosenOption !== '' ? (
<SessionRadioGroup
group="session-confirm-radio-group"
initialItem={chosenOption}
@ -147,7 +185,7 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
}
}}
/>
)}
) : null}
<SessionSpinner loading={isLoading} />
</div>

@ -0,0 +1,105 @@
import React, { useEffect, useState } from 'react';
import { isEmpty } from 'lodash';
import { useIsPrivate, useIsPublic } from '../../../hooks/useParamSelector';
import { MessageBody } from '../../conversation/message/message-content/MessageBody';
import { assertUnreachable } from '../../../types/sqlSharedTypes';
import {
ConversationInteractionStatus,
ConversationInteractionType,
} from '../../../interactions/conversationInteractions';
import styled from 'styled-components';
import { getConversationController } from '../../../session/conversations';
import { LastMessageType } from '../../../state/ducks/conversations';
const StyledInteractionItemText = styled.div<{ isError: boolean }>`
${props => props.isError && 'color: var(--danger-color) !important;'}
`;
type InteractionItemProps = {
conversationId: string;
lastMessage: LastMessageType | null;
};
export const InteractionItem = (props: InteractionItemProps) => {
const { conversationId, lastMessage } = props;
const isGroup = !useIsPrivate(conversationId);
const isCommunity = useIsPublic(conversationId);
if (!lastMessage) {
return null;
}
const { interactionType, interactionStatus } = lastMessage;
if (!interactionType || !interactionStatus) {
return null;
}
const [storedLastMessageText, setStoredLastMessageText] = useState(lastMessage?.text);
const [storedLastMessageInteractionStatus, setStoredLastMessageInteractionStatus] = useState(
lastMessage?.interactionStatus
);
// NOTE we want to reset the interaction state when the last message changes
useEffect(() => {
if (conversationId) {
const convo = getConversationController().get(conversationId);
if (storedLastMessageInteractionStatus !== convo.get('lastMessageInteractionStatus')) {
setStoredLastMessageInteractionStatus(convo.get('lastMessageInteractionStatus'));
setStoredLastMessageText(convo.get('lastMessage'));
}
}
}, [conversationId]);
let text = storedLastMessageText || '';
let errorText = '';
switch (interactionType) {
case ConversationInteractionType.Hide:
errorText = window.i18n('hideConversationFailed');
text =
interactionStatus === ConversationInteractionStatus.Error
? errorText
: interactionStatus === ConversationInteractionStatus.Start ||
interactionStatus === ConversationInteractionStatus.Loading
? window.i18n('hiding')
: text;
break;
case ConversationInteractionType.Leave:
errorText = isCommunity
? window.i18n('leaveCommunityFailed')
: isGroup
? window.i18n('leaveGroupFailed')
: window.i18n('deleteConversationFailed');
text =
interactionStatus === ConversationInteractionStatus.Error
? errorText
: interactionStatus === ConversationInteractionStatus.Start ||
interactionStatus === ConversationInteractionStatus.Loading
? window.i18n('leaving')
: text;
break;
default:
assertUnreachable(
interactionType,
`InteractionItem: Missing case error "${interactionType}"`
);
}
if (isEmpty(text)) {
return null;
}
return (
<div className="module-conversation-list-item__message">
<StyledInteractionItemText
className="module-conversation-list-item__message__text"
isError={Boolean(interactionStatus === ConversationInteractionStatus.Error)}
>
<MessageBody text={text} disableJumbomoji={true} disableLinks={true} isGroup={isGroup} />
</StyledInteractionItemText>
</div>
);
};

@ -3,10 +3,10 @@ import { isEmpty } from 'lodash';
import React from 'react';
import { useSelector } from 'react-redux';
import {
useConversationPropsById,
useHasUnread,
useIsPrivate,
useIsTyping,
useLastMessage,
} from '../../../hooks/useParamSelector';
import { LastMessageStatusType } from '../../../state/ducks/conversations';
import { isSearching } from '../../../state/selectors/search';
@ -16,18 +16,11 @@ import { TypingAnimation } from '../../conversation/TypingAnimation';
import { MessageBody } from '../../conversation/message/message-content/MessageBody';
import { SessionIcon } from '../../icon';
import { useConvoIdFromContext } from './ConvoIdContext';
function useLastMessageFromConvo(convoId: string) {
const convoProps = useConversationPropsById(convoId);
if (!convoProps) {
return null;
}
return convoProps.lastMessage;
}
import { InteractionItem } from './InteractionItem';
export const MessageItem = () => {
const conversationId = useConvoIdFromContext();
const lastMessage = useLastMessageFromConvo(conversationId);
const lastMessage = useLastMessage(conversationId);
const isGroup = !useIsPrivate(conversationId);
const hasUnread = useHasUnread(conversationId);
@ -36,9 +29,14 @@ export const MessageItem = () => {
const isSearchingMode = useSelector(isSearching);
if (lastMessage?.interactionType && lastMessage?.interactionStatus) {
return <InteractionItem conversationId={conversationId} lastMessage={lastMessage} />;
}
if (!lastMessage && !isConvoTyping) {
return null;
}
const text = lastMessage?.text || '';
if (isEmpty(text)) {

@ -16,11 +16,9 @@ import {
CopyMenuItem,
DeclineAndBlockMsgRequestMenuItem,
DeclineMsgRequestMenuItem,
DeletePrivateContactMenuItem,
DeleteGroupOrCommunityMenuItem,
DeleteMessagesMenuItem,
InviteContactMenuItem,
LeaveGroupMenuItem,
LeaveGroupOrCommunityMenuItem,
MarkAllReadMenuItem,
MarkConversationUnreadMenuItem,
ShowUserDetailsMenuItem,
@ -67,9 +65,7 @@ const ConversationListItemContextMenu = (props: PropsContextConversationItem) =>
<InviteContactMenuItem />
<DeleteMessagesMenuItem />
<DeletePrivateConversationMenuItem />
<DeletePrivateContactMenuItem />
<DeleteGroupOrCommunityMenuItem />
<LeaveGroupMenuItem />
<LeaveGroupOrCommunityMenuItem />
<ShowUserDetailsMenuItem />
</Menu>
</SessionContextMenuContainer>

@ -16,10 +16,13 @@ import {
useIsPrivate,
useIsPrivateAndFriend,
useIsPublic,
useLastMessage,
useNotificationSetting,
useWeAreAdmin,
} from '../../hooks/useParamSelector';
import {
ConversationInteractionStatus,
ConversationInteractionType,
approveConvoAndSendResponse,
blockConvoById,
clearNickNameByConvoId,
@ -32,6 +35,7 @@ import {
showBanUserByConvoId,
showInviteContactByConvoId,
showLeaveGroupByConvoId,
showLeavePrivateConversationbyConvoId,
showRemoveModeratorsByConvoId,
showUnbanUserByConvoId,
showUpdateGroupNameByConvoId,
@ -132,67 +136,28 @@ export const DeletePrivateContactMenuItem = () => {
return null;
};
export const DeleteGroupOrCommunityMenuItem = () => {
const dispatch = useDispatch();
export const LeaveGroupOrCommunityMenuItem = () => {
const convoId = useConvoIdFromContext();
const isPublic = useIsPublic(convoId);
const username = useConversationUsername(convoId) || convoId;
const isLeft = useIsLeft(convoId);
const isKickedFromGroup = useIsKickedFromGroup(convoId);
const isPrivate = useIsPrivate(convoId);
const isGroup = !isPrivate && !isPublic;
// You need to have left a closed group first to be able to delete it completely as there is a leaving message to send first.
// A community can just be removed right away.
if (isPublic || (isGroup && (isLeft || isKickedFromGroup))) {
const menuItemText = isPublic ? window.i18n('leaveGroup') : window.i18n('editMenuDeleteGroup');
const onClickClose = () => {
dispatch(updateConfirmModal(null));
};
const showConfirmationModal = () => {
dispatch(
updateConfirmModal({
title: menuItemText,
message: window.i18n('leaveGroupConfirmation'),
onClickClose,
okTheme: SessionButtonColor.Danger,
onClickOk: async () => {
if (isPublic) {
await getConversationController().deleteCommunity(convoId, {
fromSyncMessage: false,
});
} else {
await getConversationController().deleteClosedGroup(convoId, {
fromSyncMessage: false,
sendLeaveMessage: true,
});
}
},
})
);
};
return <Item onClick={showConfirmationModal}>{menuItemText}</Item>;
}
return null;
};
export const LeaveGroupMenuItem = () => {
const convoId = useConvoIdFromContext();
const isPublic = useIsPublic(convoId);
const isLeft = useIsLeft(convoId);
const isKickedFromGroup = useIsKickedFromGroup(convoId);
const isPrivate = useIsPrivate(convoId);
const lastMessage = useLastMessage(convoId);
if (!isKickedFromGroup && !isLeft && !isPrivate && !isPublic) {
if (!isKickedFromGroup && !isLeft && !isPrivate) {
return (
<Item
onClick={() => {
showLeaveGroupByConvoId(convoId);
void showLeaveGroupByConvoId(convoId, username);
}}
>
{window.i18n('leaveGroup')}
{isPublic
? window.i18n('leaveCommunity')
: lastMessage?.interactionType === ConversationInteractionType.Leave &&
lastMessage?.interactionStatus === ConversationInteractionStatus.Error
? window.i18n('deleteConversation')
: window.i18n('leaveGroup')}
</Item>
);
}
@ -453,8 +418,10 @@ export const DeleteMessagesMenuItem = () => {
*/
export const DeletePrivateConversationMenuItem = () => {
const convoId = useConvoIdFromContext();
const username = useConversationUsername(convoId) || convoId;
const isRequest = useIsIncomingRequest(convoId);
const isPrivate = useIsPrivate(convoId);
const isMe = useIsMe(convoId);
if (!convoId || !isPrivate || isRequest) {
return null;
@ -463,13 +430,10 @@ export const DeletePrivateConversationMenuItem = () => {
return (
<Item
onClick={() => {
void getConversationController().delete1o1(convoId, {
fromSyncMessage: false,
justHidePrivate: true,
});
showLeavePrivateConversationbyConvoId(convoId, username);
}}
>
{window.i18n('deleteConversation')}
{isMe ? window.i18n('hideConversation') : window.i18n('deleteConversation')}
</Item>
);
};

@ -420,3 +420,13 @@ export function useDisappearingMessageSettingText({
? `${expirationModeText}${expireTimerText ? `${separator}${expireTimerText}` : ''}`
: '';
}
export function useLastMessage(convoId?: string) {
const convoProps = useConversationPropsById(convoId);
if (!convoId || !convoProps) {
return null;
}
return convoProps.lastMessage;
}

@ -2,6 +2,7 @@ import { isNil } from 'lodash';
import {
ConversationNotificationSettingType,
ConversationTypeEnum,
READ_MESSAGE_STATE,
} from '../models/conversationAttributes';
import { CallManager, SyncUtils, ToastUtils, UserUtils } from '../session/utils';
@ -26,7 +27,6 @@ import {
resetConversationExternal,
} from '../state/ducks/conversations';
import {
adminLeaveClosedGroup,
changeNickNameModal,
updateAddModeratorsModal,
updateBanOrUnbanUserModal,
@ -45,6 +45,19 @@ import { encryptProfile } from '../util/crypto/profileEncrypter';
import { ReleasedFeatures } from '../util/releaseFeature';
import { Storage, setLastProfileUpdateTimestamp } from '../util/storage';
import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface';
import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime';
export enum ConversationInteractionStatus {
Start = 'start',
Loading = 'loading',
Error = 'error',
Complete = 'complete',
}
export enum ConversationInteractionType {
Hide = 'hide',
Leave = 'leave',
}
export async function copyPublicKeyByConvoId(convoId: string) {
if (OpenGroupUtils.isOpenGroupV2(convoId)) {
@ -219,54 +232,186 @@ export async function showUpdateGroupMembersByConvoId(conversationId: string) {
window.inboxStore?.dispatch(updateGroupMembersModal({ conversationId }));
}
export function showLeaveGroupByConvoId(conversationId: string) {
export function showLeavePrivateConversationbyConvoId(
conversationId: string,
name: string | undefined
) {
const conversation = getConversationController().get(conversationId);
const isMe = conversation.isMe();
if (!conversation.isPrivate()) {
throw new Error('showLeavePrivateConversationDialog() called with a non private convo.');
}
const onClickClose = () => {
window?.inboxStore?.dispatch(updateConfirmModal(null));
};
const onClickOk = async () => {
try {
await updateConversationInteractionState({
conversationId,
type: isMe ? ConversationInteractionType.Hide : ConversationInteractionType.Leave,
status: ConversationInteractionStatus.Start,
});
onClickClose();
await getConversationController().delete1o1(conversationId, {
fromSyncMessage: false,
justHidePrivate: true,
keepMessages: isMe,
});
await clearConversationInteractionState({ conversationId });
} catch (err) {
window.log.warn(`showLeavePrivateConversationbyConvoId error: ${err}`);
await saveConversationInteractionErrorAsMessage({
conversationId,
interactionType: isMe
? ConversationInteractionType.Hide
: ConversationInteractionType.Leave,
});
}
};
window?.inboxStore?.dispatch(
updateConfirmModal({
title: isMe ? window.i18n('hideConversation') : window.i18n('deleteConversation'),
message: isMe
? window.i18n('hideNoteToSelfConfirmation')
: window.i18n('deleteConversationConfirmation', name ? [name] : ['']),
onClickOk,
okText: isMe ? window.i18n('hide') : window.i18n('delete'),
okTheme: SessionButtonColor.Danger,
onClickClose,
conversationId,
})
);
}
async function leaveGroupOrCommunityByConvoId(
conversationId: string,
isPublic: boolean,
forceDeleteLocal: boolean,
onClickClose?: () => void
) {
try {
await updateConversationInteractionState({
conversationId,
type: ConversationInteractionType.Leave,
status: ConversationInteractionStatus.Start,
});
if (onClickClose) {
onClickClose();
}
if (isPublic) {
await getConversationController().deleteCommunity(conversationId, {
fromSyncMessage: false,
});
} else {
await getConversationController().deleteClosedGroup(conversationId, {
fromSyncMessage: false,
sendLeaveMessage: true,
forceDeleteLocal,
});
}
await clearConversationInteractionState({ conversationId });
} catch (err) {
window.log.warn(`showLeaveGroupByConvoId error: ${err}`);
await saveConversationInteractionErrorAsMessage({
conversationId,
interactionType: ConversationInteractionType.Leave,
});
}
}
export async function showLeaveGroupByConvoId(conversationId: string, name: string | undefined) {
const conversation = getConversationController().get(conversationId);
if (!conversation.isGroup()) {
throw new Error('showLeaveGroupDialog() called with a non group convo.');
}
const title = window.i18n('leaveGroup');
const message = window.i18n('leaveGroupConfirmation');
const isAdmin = (conversation.get('groupAdmins') || []).includes(
UserUtils.getOurPubKeyStrFromCache()
);
const isClosedGroup = conversation.isClosedGroup() || false;
const isPublic = conversation.isPublic() || false;
const admins = conversation.get('groupAdmins') || [];
const isAdmin = admins.includes(UserUtils.getOurPubKeyStrFromCache());
const showOnlyGroupAdminWarning = isClosedGroup && isAdmin && admins.length === 1;
const lastMessageInteractionType = conversation.get('lastMessageInteractionType');
const lastMessageInteractionStatus = conversation.get('lastMessageInteractionStatus');
if (
!isPublic &&
lastMessageInteractionType === ConversationInteractionType.Leave &&
lastMessageInteractionStatus === ConversationInteractionStatus.Error
) {
await leaveGroupOrCommunityByConvoId(conversationId, isPublic, true);
return;
}
// if this is a community, or we legacy group are not admin, we can just show a confirmation dialog
if (isPublic || (isClosedGroup && !isAdmin)) {
const onClickClose = () => {
window.inboxStore?.dispatch(updateConfirmModal(null));
};
window.inboxStore?.dispatch(
const onClickClose = () => {
window?.inboxStore?.dispatch(updateConfirmModal(null));
};
const onClickOk = async () => {
await leaveGroupOrCommunityByConvoId(conversationId, isPublic, false, onClickClose);
};
if (showOnlyGroupAdminWarning) {
// NOTE For legacy closed groups
window?.inboxStore?.dispatch(
updateConfirmModal({
title,
message,
onClickOk: async () => {
if (isPublic) {
await getConversationController().deleteCommunity(conversation.id, {
fromSyncMessage: false,
});
} else {
await getConversationController().deleteClosedGroup(conversation.id, {
fromSyncMessage: false,
sendLeaveMessage: true,
});
}
onClickClose();
},
title: window.i18n('leaveGroup'),
message: window.i18n('leaveGroupConrirmationOnlyAdminLegacy', name ? [name] : ['']),
onClickOk,
okText: window.i18n('leave'),
okTheme: SessionButtonColor.Danger,
onClickClose,
conversationId,
})
);
return;
// TODO Only to be used after the closed group rebuild
// const onClickOkLastAdmin = () => {
// /* TODO */
// };
// const onClickCloseLastAdmin = () => {
// /* TODO */
// };
// window?.inboxStore?.dispatch(
// updateConfirmModal({
// title: window.i18n('leaveGroup'),
// message: window.i18n('leaveGroupConfirmationOnlyAdmin', name ? [name] : ['']),
// messageSub: window.i18n('leaveGroupConfirmationOnlyAdminWarning'),
// onClickOk: onClickOkLastAdmin,
// okText: window.i18n('addModerator'),
// cancelText: window.i18n('leave'),
// onClickCancel: onClickCloseLastAdmin,
// closeTheme: SessionButtonColor.Danger,
// onClickClose,
// showExitIcon: true,
// headerReverse: true,
// conversationId,
// })
// );
} else {
if (isPublic || (isClosedGroup && !isAdmin)) {
window?.inboxStore?.dispatch(
updateConfirmModal({
title: isPublic ? window.i18n('leaveCommunity') : window.i18n('leaveGroup'),
message: window.i18n('leaveGroupConfirmation', name ? [name] : ['']),
onClickOk,
okText: window.i18n('leave'),
okTheme: SessionButtonColor.Danger,
onClickClose,
conversationId,
})
);
}
}
window.inboxStore?.dispatch(
adminLeaveClosedGroup({
conversationId,
})
);
}
export function showInviteContactByConvoId(conversationId: string) {
window.inboxStore?.dispatch(updateInviteContactModal({ conversationId }));
}
@ -329,6 +474,8 @@ export async function deleteAllMessagesByConvoIdNoConfirmation(conversationId: s
// conversation still appears on the conversation list but is empty
conversation.set({
lastMessage: null,
lastMessageInteractionType: null,
lastMessageInteractionStatus: null,
});
await conversation.commit();
@ -348,7 +495,7 @@ export function deleteAllMessagesByConvoIdWithConfirmation(conversationId: strin
window?.inboxStore?.dispatch(
updateConfirmModal({
title: window.i18n('deleteMessages'),
message: window.i18n('deleteConversationConfirmation'),
message: window.i18n('deleteMessagesConfirmation'),
onClickOk,
okTheme: SessionButtonColor.Danger,
onClickClose,
@ -616,3 +763,96 @@ export async function callRecipient(pubkey: string, canCall: boolean) {
await CallManager.USER_callRecipient(convo.id);
}
}
/**
* Updates the interaction state for a conversation. Remember to run clearConversationInteractionState() when the interaction is complete and we don't want to show it in the UI anymore.
* @param conversationId id of the converation we want to interact with
* @param type the type of conversation interaciton we are doing
* @param status the status of that interaction
*/
export async function updateConversationInteractionState({
conversationId,
type,
status,
}: {
conversationId: string;
type: ConversationInteractionType;
status: ConversationInteractionStatus;
}) {
const convo = getConversationController().get(conversationId);
if (
convo &&
(type !== convo.get('lastMessageInteractionType') ||
status !== convo.get('lastMessageInteractionStatus'))
) {
convo.set('lastMessageInteractionType', type);
convo.set('lastMessageInteractionStatus', status);
await convo.commit();
window.log.debug(
`updateConversationInteractionState for ${conversationId} to ${type} ${status}`
);
}
}
/**
* Clears the interaction state for a conversation. We would use this when we don't need to show anything in the UI once an action is complete.
* @param conversationId id of the conversation whose interaction we want to clear
*/
export async function clearConversationInteractionState({
conversationId,
}: {
conversationId: string;
}) {
const convo = getConversationController().get(conversationId);
if (
convo &&
(convo.get('lastMessageInteractionType') || convo.get('lastMessageInteractionStatus'))
) {
convo.set('lastMessageInteractionType', undefined);
convo.set('lastMessageInteractionStatus', undefined);
await convo.commit();
window.log.debug(`clearConversationInteractionState for ${conversationId}`);
}
}
async function saveConversationInteractionErrorAsMessage({
conversationId,
interactionType,
}: {
conversationId: string;
interactionType: ConversationInteractionType;
}) {
const conversation = getConversationController().get(conversationId);
if (!conversation) {
return;
}
const interactionStatus = ConversationInteractionStatus.Error;
await updateConversationInteractionState({
conversationId,
type: interactionType,
status: interactionStatus,
});
// NOTE at this time we don't have visible control messages in communities
if (conversation.isPublic()) {
return;
}
// Add an error message to the database so we can view it in the message history
await conversation?.addSingleIncomingMessage({
source: GetNetworkTime.getNowWithNetworkOffset().toString(),
sent_at: Date.now(),
interactionNotification: {
interactionType,
interactionStatus,
},
unread: READ_MESSAGE_STATE.read,
expireTimer: 0,
});
conversation.updateLastMessage();
}

@ -403,6 +403,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}
// -- Handle the last message status, if present --
const lastMessageInteractionType = this.get('lastMessageInteractionType');
const lastMessageInteractionStatus = this.get('lastMessageInteractionStatus');
const lastMessageText = this.get('lastMessage');
if (lastMessageText && lastMessageText.length) {
const lastMessageStatus = this.get('lastMessageStatus');
@ -410,7 +412,19 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
toRet.lastMessage = {
status: lastMessageStatus,
text: lastMessageText,
interactionType: lastMessageInteractionType,
interactionStatus: lastMessageInteractionStatus,
};
} else {
// if there is no last message, we still want to display the interaction status
if (lastMessageInteractionType && lastMessageInteractionStatus) {
toRet.lastMessage = {
text: '',
status: 'sent',
interactionType: lastMessageInteractionType,
interactionStatus: lastMessageInteractionStatus,
};
}
}
return toRet;
}
@ -790,6 +804,15 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
active_at: networkTimestamp,
});
const interactionNotification = messageModel.getInteractionNotification();
if (interactionNotification) {
this.set({
lastMessageInteractionType: interactionNotification?.interactionType,
lastMessageInteractionStatus: interactionNotification?.interactionStatus,
});
}
await this.commit();
void this.queueJob(async () => {
@ -2045,6 +2068,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
});
}
// tslint:disable-next-line cyclomatic-complexity
private async bouncyUpdateLastMessage() {
if (!this.id || !this.get('active_at') || this.isHidden()) {
return;
@ -2063,22 +2087,41 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return;
}
const lastMessageModel = messages.at(0);
const interactionNotification = lastMessageModel.getInteractionNotification();
const lastMessageInteractionType = interactionNotification?.interactionType;
const lastMessageInteractionStatus = lastMessageModel.getInteractionNotification()
?.interactionStatus;
const lastMessageStatus = lastMessageModel.getMessagePropStatus() || undefined;
const lastMessageNotificationText = lastMessageModel.getNotificationText() || undefined;
// we just want to set the `status` to `undefined` if there are no `lastMessageNotificationText`
const lastMessageUpdate = !isEmpty(lastMessageNotificationText)
? {
lastMessage: lastMessageNotificationText || '',
lastMessageStatus,
}
: { lastMessage: '', lastMessageStatus: undefined };
const lastMessageUpdate =
!!lastMessageNotificationText && !isEmpty(lastMessageNotificationText)
? {
lastMessage: lastMessageNotificationText || '',
lastMessageStatus,
lastMessageInteractionType,
lastMessageInteractionStatus,
}
: {
lastMessage: '',
lastMessageStatus: undefined,
lastMessageInteractionType: undefined,
lastMessageInteractionStatus: undefined,
};
const existingLastMessageInteractionType = this.get('lastMessageInteractionType');
const existingLastMessageInteractionStatus = this.get('lastMessageInteractionStatus');
if (
lastMessageUpdate.lastMessage !== existingLastMessageAttribute ||
lastMessageUpdate.lastMessageStatus !== existingLastMessageStatus
lastMessageUpdate.lastMessageStatus !== existingLastMessageStatus ||
lastMessageUpdate.lastMessageInteractionType !== existingLastMessageInteractionType ||
lastMessageUpdate.lastMessageInteractionStatus !== existingLastMessageInteractionStatus
) {
if (
lastMessageUpdate.lastMessageStatus === existingLastMessageStatus &&
lastMessageUpdate.lastMessageInteractionType === existingLastMessageInteractionType &&
lastMessageUpdate.lastMessageInteractionStatus === existingLastMessageInteractionStatus &&
lastMessageUpdate.lastMessage &&
lastMessageUpdate.lastMessage.length > 40 &&
existingLastMessageAttribute &&

@ -1,6 +1,10 @@
import { defaults } from 'lodash';
import { DisappearingMessageConversationModeType } from '../session/disappearing_messages/types';
import { LastMessageStatusType } from '../state/ducks/conversations';
import {
ConversationInteractionStatus,
ConversationInteractionType,
} from '../interactions/conversationInteractions';
/**
* Private chats have always the type `Private`
@ -56,7 +60,6 @@ export interface ConversationAttributes {
// 0 means inactive (undefined and null too but we try to get rid of them and only have 0 = inactive)
active_at: number; // this field is the one used to sort conversations in the left pane from most recent
lastMessageStatus: LastMessageStatusType;
/**
* lastMessage is actually just a preview of the last message text, shortened to 60 chars.
* This is to avoid filling the redux store with a huge last message when it's only used in the
@ -64,6 +67,9 @@ export interface ConversationAttributes {
* The shortening is made in sql.ts directly.
*/
lastMessage: string | null;
lastMessageStatus: LastMessageStatusType;
lastMessageInteractionType: ConversationInteractionType | null;
lastMessageInteractionStatus: ConversationInteractionStatus | null;
avatarImageId?: number; // avatar imageID is currently used only for sogs. It's the fileID of the image uploaded and set as the sogs avatar (not only sogs I think, but our profile too?)
@ -134,8 +140,10 @@ export const fillConvoAttributesWithDefaults = (
active_at: 0,
lastMessageStatus: undefined,
lastMessage: null,
lastMessageStatus: undefined,
lastMessageInteractionType: null,
lastMessageInteractionStatus: null,
triggerNotificationsFor: 'all', // if the settings is not set in the db, this is the default

@ -38,6 +38,10 @@ import {
import { Data } from '../data/data';
import { OpenGroupData } from '../data/opengroups';
import { SettingsKey } from '../data/settings-key';
import {
ConversationInteractionStatus,
ConversationInteractionType,
} from '../interactions/conversationInteractions';
import { isUsAnySogsFromCache } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime';
import { SnodeNamespaces } from '../session/apis/snode_api/namespaces';
@ -91,13 +95,14 @@ import {
import { ReactionList } from '../types/Reaction';
import { getAudioDuration, getVideoDuration } from '../types/attachments/VisualAttachment';
import { getAttachmentMetadata } from '../types/message/initializeAttachmentMetadata';
import { roomHasBlindEnabled } from '../types/sqlSharedTypes';
import { assertUnreachable, roomHasBlindEnabled } from '../types/sqlSharedTypes';
import { GoogleChrome } from '../util';
import { LinkPreviews } from '../util/linkPreviews';
import { Notifications } from '../util/notifications';
import { Storage } from '../util/storage';
import { ConversationModel } from './conversation';
import { READ_MESSAGE_STATE } from './conversationAttributes';
// tslint:disable: cyclomatic-complexity
/**
* @returns true if the array contains only a single item being 'You', 'you' or our device pubkey
@ -148,6 +153,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
const propsForMessageRequestResponse = this.getPropsForMessageRequestResponse();
const propsForQuote = this.getPropsForQuote();
const callNotificationType = this.get('callNotificationType');
const interactionNotification = this.getInteractionNotification();
const messageProps: MessageModelPropsWithoutConvoProps = {
propsForMessage: this.getPropsForMessage(),
};
@ -183,6 +190,17 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
};
}
if (interactionNotification) {
messageProps.propsForInteractionNotification = {
notificationType: interactionNotification,
convoId: this.get('conversationId'),
messageId: this.id,
receivedAt: this.get('received_at') || Date.now(),
isUnread: this.isUnread(),
};
}
perfEnd(`getPropsMessage-${this.id}`, 'getPropsMessage');
return messageProps;
}
@ -243,6 +261,13 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
public isCallNotification() {
return !!this.get('callNotificationType');
}
public isInteractionNotification() {
return !!this.getInteractionNotification();
}
public getInteractionNotification() {
return this.get('interactionNotification');
}
public getNotificationText() {
let description = this.getDescription();
@ -507,7 +532,11 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return undefined;
}
if (this.isDataExtractionNotification() || this.isCallNotification()) {
if (
this.isDataExtractionNotification() ||
this.isCallNotification() ||
this.isInteractionNotification()
) {
return undefined;
}
@ -1306,6 +1335,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
if (arrayContainsUsOnly(groupUpdate.kicked)) {
return window.i18n('youGotKickedFromGroup');
}
if (arrayContainsUsOnly(groupUpdate.left)) {
return window.i18n('youLeftTheGroup');
}
@ -1317,12 +1347,15 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
const messages = [];
if (!groupUpdate.name && !groupUpdate.joined && !groupUpdate.kicked && !groupUpdate.kicked) {
return window.i18n('updatedTheGroup'); // Group Updated
}
if (groupUpdate.name) {
return window.i18n('titleIsNow', [groupUpdate.name]);
}
if (groupUpdate.joined && groupUpdate.joined.length) {
const names = groupUpdate.joined.map(
getConversationController().getContactProfileNameOrShortenedPubKey
@ -1350,9 +1383,11 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
return messages.join(' ');
}
if (this.isIncoming() && this.hasErrors()) {
return window.i18n('incomingError');
}
if (this.isGroupInvitation()) {
return `😎 ${window.i18n('openGroupInvitation')}`;
}
@ -1386,6 +1421,38 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return window.i18n('answeredACall', [displayName]);
}
}
const interactionNotification = this.getInteractionNotification();
if (interactionNotification) {
const { interactionType, interactionStatus } = interactionNotification;
// NOTE For now we only show interaction errors in the message history
if (interactionStatus === ConversationInteractionStatus.Error) {
const convo = getConversationController().get(this.get('conversationId'));
if (convo) {
const isGroup = !convo.isPrivate();
const isCommunity = convo.isPublic();
switch (interactionType) {
case ConversationInteractionType.Hide:
return window.i18n('hideConversationFailed');
case ConversationInteractionType.Leave:
return isCommunity
? window.i18n('leaveCommunityFailed')
: isGroup
? window.i18n('leaveGroupFailed')
: window.i18n('deleteConversationFailed');
default:
assertUnreachable(
interactionType,
`Message.getDescription: Missing case error "${interactionType}"`
);
}
}
}
}
if (this.get('reaction')) {
const reaction = this.get('reaction');
if (reaction && reaction.emoji && reaction.emoji !== '') {

@ -6,6 +6,7 @@ import {
} from '../session/disappearing_messages/types';
import {
CallNotificationType,
InteractionNotificationType,
LastMessageStatusType,
PropsForMessageWithConvoProps,
} from '../state/ducks/conversations';
@ -118,6 +119,11 @@ export interface MessageAttributes {
isDeleted?: boolean;
callNotificationType?: CallNotificationType;
/**
* This is used when a user has performed an interaction (hiding, leaving, etc.) on a conversation. At the moment, this is only used for showing interaction errors.
*/
interactionNotification?: InteractionNotificationType;
}
export interface DataExtractionNotificationMsg {
@ -212,6 +218,7 @@ export interface MessageAttributesOptionals {
messageHash?: string;
isDeleted?: boolean;
callNotificationType?: CallNotificationType;
interactionNotification?: InteractionNotificationType;
}
/**

@ -57,6 +57,8 @@ const allowedKeysFormatRowOfConversation = [
'left',
'lastMessage',
'lastMessageStatus',
'lastMessageInteractionType',
'lastMessageInteractionStatus',
'triggerNotificationsFor',
'unreadCount',
'lastJoinedTimestamp',
@ -145,6 +147,14 @@ export function formatRowOfConversation(
convo.blocksSogsMsgReqsTimestamp = 0;
}
if (!convo.lastMessageInteractionType) {
convo.lastMessageInteractionType = null;
}
if (!convo.lastMessageInteractionStatus) {
convo.lastMessageInteractionStatus = null;
}
if (!convo.triggerNotificationsFor) {
convo.triggerNotificationsFor = 'all';
}
@ -182,6 +192,8 @@ const allowedKeysOfConversationAttributes = [
'left',
'lastMessage',
'lastMessageStatus',
'lastMessageInteractionType',
'lastMessageInteractionStatus',
'triggerNotificationsFor',
'lastJoinedTimestamp',
'expireTimer',

@ -103,6 +103,7 @@ const LOKI_SCHEMA_VERSIONS = [
updateToSessionSchemaVersion32,
updateToSessionSchemaVersion33,
updateToSessionSchemaVersion34,
updateToSessionSchemaVersion35,
];
function updateToSessionSchemaVersion1(currentVersion: number, db: BetterSqlite3.Database) {
@ -1906,6 +1907,27 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: BetterSqlite
console.log(`updateToSessionSchemaVersion${targetVersion}: success!`);
}
function updateToSessionSchemaVersion35(currentVersion: number, db: BetterSqlite3.Database) {
const targetVersion = 35;
if (currentVersion >= targetVersion) {
return;
}
console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`);
db.transaction(() => {
db.prepare(
`ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN lastMessageInteractionType TEXT;`
).run();
db.prepare(
`ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN lastMessageInteractionStatus TEXT;`
).run();
writeSessionSchemaVersion(targetVersion, db);
})();
console.log(`updateToSessionSchemaVersion${targetVersion}: success!`);
}
export function printTableColumns(table: string, db: BetterSqlite3.Database) {
console.info(db.pragma(`table_info('${table}');`));
}

@ -431,8 +431,10 @@ function saveConversation(data: ConversationAttributes): SaveConversationReturn
expirationMode,
expireTimer,
hasOutdatedClient,
lastMessageStatus,
lastMessage,
lastMessageStatus,
lastMessageInteractionType,
lastMessageInteractionStatus,
lastJoinedTimestamp,
groupAdmins,
isKickedFromGroup,
@ -484,6 +486,8 @@ function saveConversation(data: ConversationAttributes): SaveConversationReturn
hasOutdatedClient,
lastMessageStatus,
lastMessage: shortenedLastMessage,
lastMessageInteractionType,
lastMessageInteractionStatus,
lastJoinedTimestamp,
groupAdmins: groupAdmins && groupAdmins.length ? arrayStrToJson(groupAdmins) : '[]',

@ -283,9 +283,12 @@ async function handleRegularMessage(
conversation.isHidden() ||
(message.get('sent_at') || 0) > conversationActiveAt
) {
const interactionNotification = message.getInteractionNotification();
conversation.set({
active_at: message.get('sent_at'),
lastMessage: message.getNotificationText(),
lastMessageInteractionType: interactionNotification?.interactionType,
lastMessageInteractionStatus: interactionNotification?.interactionStatus,
});
// a new message was received for that conversation. If it was not it should not be hidden anymore
await conversation.unhideIfNeeded(false);

@ -45,6 +45,8 @@ export const getConversationController = () => {
return instance;
};
type DeleteOptions = { fromSyncMessage: boolean };
export class ConversationController {
private readonly conversations: ConversationCollection;
private _initialFetchComplete: boolean = false;
@ -210,7 +212,7 @@ export class ConversationController {
public async deleteClosedGroup(
groupId: string,
options: { fromSyncMessage: boolean; sendLeaveMessage: boolean }
options: DeleteOptions & { sendLeaveMessage: boolean; forceDeleteLocal?: boolean }
) {
const conversation = await this.deleteConvoInitialChecks(groupId, 'LegacyGroup');
if (!conversation || !conversation.isClosedGroup()) {
@ -219,8 +221,15 @@ export class ConversationController {
window.log.info(`deleteClosedGroup: ${groupId}, sendLeaveMessage?:${options.sendLeaveMessage}`);
getSwarmPollingInstance().removePubkey(groupId); // we don't need to keep polling anymore.
if (options.sendLeaveMessage) {
if (!options.forceDeleteLocal) {
await leaveClosedGroup(groupId, options.fromSyncMessage);
window.log.info(
`deleteClosedGroup: ${groupId}, sendLeaveMessage?:${options.sendLeaveMessage}`
);
if (options.sendLeaveMessage) {
await leaveClosedGroup(groupId, options.fromSyncMessage);
}
}
// if we were kicked or sent our left message, we have nothing to do more with that group.
@ -233,7 +242,7 @@ export class ConversationController {
}
}
public async deleteCommunity(convoId: string, options: { fromSyncMessage: boolean }) {
public async deleteCommunity(convoId: string, options: DeleteOptions) {
const conversation = await this.deleteConvoInitialChecks(convoId, 'Community');
if (!conversation || !conversation.isPublic()) {
return;
@ -254,9 +263,10 @@ export class ConversationController {
public async delete1o1(
id: string,
options: { fromSyncMessage: boolean; justHidePrivate?: boolean }
options: DeleteOptions & { justHidePrivate?: boolean; keepMessages?: boolean }
) {
const conversation = await this.deleteConvoInitialChecks(id, '1o1');
const conversation = await this.deleteConvoInitialChecks(id, '1o1', options?.keepMessages);
if (!conversation || !conversation.isPrivate()) {
return;
}
@ -389,9 +399,13 @@ export class ConversationController {
this.conversations.reset([]);
}
private async deleteConvoInitialChecks(convoId: string, deleteType: ConvoVolatileType) {
private async deleteConvoInitialChecks(
convoId: string,
deleteType: ConvoVolatileType,
keepMessages?: boolean
) {
if (!this._initialFetchComplete) {
throw new Error(`getConversationController.${deleteType} needs complete initial fetch`);
throw new Error(`getConversationController.${deleteType} needs to complete initial fetch`);
}
window.log.info(`${deleteType} with ${convoId}`);
@ -402,10 +416,14 @@ export class ConversationController {
return null;
}
// those are the stuff to do for all conversation types
window.log.info(`${deleteType} destroyingMessages: ${convoId}`);
await deleteAllMessagesByConvoIdNoConfirmation(convoId);
window.log.info(`${deleteType} messages destroyed: ${convoId}`);
// Note in some cases (hiding a conversation) we don't want to delete the messages
if (!keepMessages) {
// those are the stuff to do for all conversation types
window.log.info(`${deleteType} destroyingMessages: ${convoId}`);
await deleteAllMessagesByConvoIdNoConfirmation(convoId);
window.log.info(`${deleteType} messages destroyed: ${convoId}`);
}
return conversation;
}

@ -22,6 +22,10 @@ import {
DisappearingMessageType,
} from '../../session/disappearing_messages/types';
import { ReactionList } from '../../types/Reaction';
import {
ConversationInteractionStatus,
ConversationInteractionType,
} from '../../interactions/conversationInteractions';
export type CallNotificationType = 'missed-call' | 'started-call' | 'answered-a-call';
@ -40,6 +44,7 @@ export type MessageModelPropsWithoutConvoProps = {
propsForCallNotification?: PropsForCallNotification;
propsForMessageRequestResponse?: PropsForMessageRequestResponse;
propsForQuote?: PropsForQuote;
propsForInteractionNotification?: PropsForInteractionNotification;
};
export type MessageModelPropsWithConvoProps = SortedMessageModelProps & {
@ -178,13 +183,21 @@ export type PropsForAttachment = {
};
export type PropsForQuote = {
text?: string;
attachment?: QuotedAttachmentType;
author: string;
convoId?: string;
id?: string; // this is the quoted message timestamp
isFromMe?: boolean;
referencedMessageNotFound?: boolean;
text?: string;
};
export type PropsForInteractionNotification = {
notificationType: InteractionNotificationType;
convoId: string;
messageId: string;
receivedAt: number;
isUnread: boolean;
};
export type PropsForMessageWithoutConvoProps = {
@ -229,6 +242,13 @@ export type PropsForMessageWithConvoProps = PropsForMessageWithoutConvoProps & {
export type LastMessageType = {
status: LastMessageStatusType;
text: string | null;
interactionType: ConversationInteractionType | null;
interactionStatus: ConversationInteractionStatus | null;
};
export type InteractionNotificationType = {
interactionType: ConversationInteractionType;
interactionStatus: ConversationInteractionStatus;
};
/**

@ -1,7 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { EditProfilePictureModalProps } from '../../components/dialog/EditProfilePictureModal';
import { SessionConfirmDialogProps } from '../../components/dialog/SessionConfirm';
import { PasswordAction } from '../../components/dialog/SessionPasswordDialog';
import { EditProfilePictureModalProps } from '../../components/dialog/EditProfilePictureModal';
import { Noop } from '../../types/Util';
export type BanType = 'ban' | 'unban';
@ -18,7 +18,6 @@ export type RemoveModeratorsModalState = InviteContactModalState;
export type UpdateGroupMembersModalState = InviteContactModalState;
export type UpdateGroupNameModalState = InviteContactModalState;
export type ChangeNickNameModalState = InviteContactModalState;
export type AdminLeaveClosedGroupModalState = InviteContactModalState;
export type EditProfileModalState = object | null;
export type OnionPathModalState = EditProfileModalState;
export type RecoveryPhraseModalState = EditProfileModalState;
@ -52,7 +51,6 @@ export type ModalState = {
editProfileModal: EditProfileModalState;
onionPathModal: OnionPathModalState;
recoveryPhraseModal: RecoveryPhraseModalState;
adminLeaveClosedGroup: AdminLeaveClosedGroupModalState;
sessionPasswordModal: SessionPasswordModalState;
deleteAccountModal: DeleteAccountModalState;
reactListModalState: ReactModalsState;
@ -73,7 +71,6 @@ export const initialModalState: ModalState = {
editProfileModal: null,
onionPathModal: null,
recoveryPhraseModal: null,
adminLeaveClosedGroup: null,
sessionPasswordModal: null,
deleteAccountModal: null,
reactListModalState: null,
@ -121,9 +118,6 @@ const ModalSlice = createSlice({
recoveryPhraseModal(state, action: PayloadAction<RecoveryPhraseModalState | null>) {
return { ...state, recoveryPhraseModal: action.payload };
},
adminLeaveClosedGroup(state, action: PayloadAction<AdminLeaveClosedGroupModalState | null>) {
return { ...state, adminLeaveClosedGroup: action.payload };
},
sessionPassword(state, action: PayloadAction<SessionPasswordModalState>) {
return { ...state, sessionPasswordModal: action.payload };
},
@ -155,7 +149,6 @@ export const {
editProfileModal,
onionPathModal,
recoveryPhraseModal,
adminLeaveClosedGroup,
sessionPassword,
updateDeleteAccountModal,
updateBanOrUnbanUserModal,

@ -105,7 +105,8 @@ export type MessagePropsType =
| 'timer-notification'
| 'regular-message'
| 'unread-indicator'
| 'call-notification';
| 'call-notification'
| 'interaction-notification';
export const getSortedMessagesTypesOfSelectedConversation = createSelector(
getSortedMessagesOfSelectedConversation,
@ -195,6 +196,19 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
};
}
if (msg.propsForInteractionNotification) {
return {
...common,
message: {
messageType: 'interaction-notification',
props: {
...msg.propsForInteractionNotification,
messageId: msg.propsForMessage.id,
},
},
};
}
return {
showUnreadIndicator: isFirstUnread,
showDateBreak,

@ -3,7 +3,6 @@ import { createSelector } from '@reduxjs/toolkit';
import { StateType } from '../reducer';
import {
AddModeratorsModalState,
AdminLeaveClosedGroupModalState,
BanOrUnbanUserModalState,
ChangeNickNameModalState,
ConfirmModalState,
@ -86,11 +85,6 @@ export const getRecoveryPhraseDialog = createSelector(
(state: ModalState): RecoveryPhraseModalState => state.recoveryPhraseModal
);
export const getAdminLeaveClosedGroupDialog = createSelector(
getModal,
(state: ModalState): AdminLeaveClosedGroupModalState => state.adminLeaveClosedGroup
);
export const getSessionPasswordDialog = createSelector(
getModal,
(state: ModalState): SessionPasswordModalState => state.sessionPasswordModal

@ -383,3 +383,7 @@ export function useSelectedWeAreModerator() {
export function useIsMessageSelectionMode() {
return useSelector(getIsMessageSelectionMode);
}
export function useSelectedLastMessage() {
return useSelector((state: StateType) => getSelectedConversation(state)?.lastMessage);
}

@ -6,6 +6,7 @@ export type LocalizerKeys =
| 'add'
| 'addACaption'
| 'addAsModerator'
| 'addModerator'
| 'addModerators'
| 'addingContacts'
| 'allUsersAreRandomly...'
@ -129,10 +130,13 @@ export type LocalizerKeys =
| 'deleteContactConfirmation'
| 'deleteConversation'
| 'deleteConversationConfirmation'
| 'deleteConversationFailed'
| 'deleteConversationFailedPleaseTryAgain'
| 'deleteForEveryone'
| 'deleteJustForMe'
| 'deleteMessageQuestion'
| 'deleteMessages'
| 'deleteMessagesConfirmation'
| 'deleteMessagesQuestion'
| 'deleted'
| 'destination'
@ -214,11 +218,17 @@ export type LocalizerKeys =
| 'groupNamePlaceholder'
| 'helpSettingsTitle'
| 'helpUsTranslateSession'
| 'hide'
| 'hideBanner'
| 'hideConversation'
| 'hideConversationFailed'
| 'hideConversationFailedPleaseTryAgain'
| 'hideMenuBarDescription'
| 'hideMenuBarTitle'
| 'hideNoteToSelfConfirmation'
| 'hideRequestBanner'
| 'hideRequestBannerDescription'
| 'hiding'
| 'iAmSure'
| 'image'
| 'imageAttachmentAlt'
@ -243,10 +253,20 @@ export type LocalizerKeys =
| 'keepDisabled'
| 'kickedFromTheGroup'
| 'learnMore'
| 'leave'
| 'leaveAndRemoveForEveryone'
| 'leaveCommunity'
| 'leaveCommunityFailed'
| 'leaveCommunityFailedPleaseTryAgain'
| 'leaveGroup'
| 'leaveGroupConfirmation'
| 'leaveGroupConfirmationAdmin'
| 'leaveGroupConfirmationOnlyAdmin'
| 'leaveGroupConfirmationOnlyAdminWarning'
| 'leaveGroupConrirmationOnlyAdminLegacy'
| 'leaveGroupFailed'
| 'leaveGroupFailedPleaseTryAgain'
| 'leaving'
| 'leftTheGroup'
| 'lightboxImageAlt'
| 'linkDevice'

Loading…
Cancel
Save