diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7cc647207..e8b055b94 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -504,7 +504,11 @@ "messageRequestAcceptedOurs": "You have accepted $name$'s message request", "messageRequestAcceptedOursNoName": "You have accepted the message request", "declineRequestMessage": "Are you sure you want to decline this message request?", + "deleteGroupRequest": "Are you sure you want to delete this group invite?", + "deleteGroupRequestAndBlock": "Are you sure you want to block $name$? Blocked users cannot send you message requests, group invites or call you.", "respondingToRequestWarning": "Sending a message to this user will automatically accept their message request and reveal your Session ID.", + "respondingToGroupRequestWarning": "Sending a message to this group will automatically accept the group invite.", + "userInvitedYouToGroup": "$name$ invited you to join $groupName$.", "hideRequestBanner": "Hide Message Request Banner", "openMessageRequestInbox": "Message Requests", "noMessageRequestsPending": "No pending message requests", diff --git a/ts/components/SessionInboxView.tsx b/ts/components/SessionInboxView.tsx index 8e5207e5f..2bd74fbec 100644 --- a/ts/components/SessionInboxView.tsx +++ b/ts/components/SessionInboxView.tsx @@ -1,12 +1,12 @@ +import { fromPairs, map } from 'lodash'; import moment from 'moment'; import React from 'react'; import { Provider } from 'react-redux'; -import styled from 'styled-components'; -import { fromPairs, map } from 'lodash'; +import useMount from 'react-use/lib/useMount'; +import useUpdate from 'react-use/lib/useUpdate'; import { persistStore } from 'redux-persist'; import { PersistGate } from 'redux-persist/integration/react'; -import useUpdate from 'react-use/lib/useUpdate'; -import useMount from 'react-use/lib/useMount'; +import styled from 'styled-components'; import { LeftPane } from './leftpane/LeftPane'; // moment does not support es-419 correctly (and cause white screen on app start) @@ -33,13 +33,14 @@ import { ExpirationTimerOptions } from '../util/expiringMessages'; import { SessionMainPanel } from './SessionMainPanel'; import { SettingsKey } from '../data/settings-key'; +import { groupInfoActions, initialGroupState } from '../state/ducks/metaGroups'; import { getSettingsInitialState, updateAllOnStorageReady } from '../state/ducks/settings'; import { initialSogsRoomInfoState } from '../state/ducks/sogsRoomInfo'; import { useHasDeviceOutdatedSyncing } from '../state/selectors/settings'; import { Storage } from '../util/storage'; +import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface'; import { NoticeBanner } from './NoticeBanner'; import { Flex } from './basic/Flex'; -import { groupInfoActions, initialGroupState } from '../state/ducks/groups'; function makeLookup(items: Array, key: string): { [key: string]: T } { // Yep, we can't index into item without knowing what it is. True. But we want to. @@ -59,12 +60,18 @@ const StyledGutter = styled.div` transition: none; `; -function createSessionInboxStore() { +async function createSessionInboxStore() { // Here we set up a full redux store with initial state for our LeftPane Root const conversations = ConvoHub.use() .getConversations() .map(conversation => conversation.getConversationModelProps()); + const userGroups: Record = {}; + + (await UserGroupsWrapperActions.getAllGroups()).forEach(m => { + userGroups[m.pubkeyHex] = m; + }); + const timerOptions: TimerOptionsArray = ExpirationTimerOptions.getTimerSecondsWithName(); const initialState: StateType = { conversations: { @@ -90,14 +97,15 @@ function createSessionInboxStore() { sogsRoomInfo: initialSogsRoomInfoState, settings: getSettingsInitialState(), groups: initialGroupState, + userGroups: { userGroups }, }; return createStore(initialState); } -function setupLeftPane(forceUpdateInboxComponent: () => void) { +async function setupLeftPane(forceUpdateInboxComponent: () => void) { window.openConversationWithMessages = openConversationWithMessages; - window.inboxStore = createSessionInboxStore(); + window.inboxStore = await createSessionInboxStore(); window.inboxStore.dispatch(updateAllOnStorageReady()); window.inboxStore.dispatch(groupInfoActions.loadMetaDumpsFromDB()); // this loads the dumps from DB and fills the 03-groups slice with the corresponding details forceUpdateInboxComponent(); @@ -125,7 +133,7 @@ export const SessionInboxView = () => { const update = useUpdate(); // run only on mount useMount(() => { - setupLeftPane(update); + void setupLeftPane(update); }); if (!window.inboxStore) { diff --git a/ts/components/conversation/MessageRequestButtons.tsx b/ts/components/conversation/MessageRequestButtons.tsx index 59bdc0d13..81ae85af3 100644 --- a/ts/components/conversation/MessageRequestButtons.tsx +++ b/ts/components/conversation/MessageRequestButtons.tsx @@ -1,18 +1,29 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import styled from 'styled-components'; import { useIsIncomingRequest } from '../../hooks/useParamSelector'; import { approveConvoAndSendResponse, declineConversationWithConfirm, } from '../../interactions/conversationInteractions'; +import { getSwarmPollingInstance } from '../../session/apis/snode_api/swarmPolling'; import { ConvoHub } from '../../session/conversations'; -import { hasSelectedConversationIncomingMessages } from '../../state/selectors/conversations'; -import { useSelectedConversationKey } from '../../state/selectors/selectedConversation'; +import { PubKey } from '../../session/types'; +import { + useSelectedConversationIdOrigin, + useSelectedConversationKey, + useSelectedIsGroupV2, + useSelectedIsPrivateFriend, +} from '../../state/selectors/selectedConversation'; +import { useLibGroupInvitePending } from '../../state/selectors/userGroups'; +import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; import { SessionButton, SessionButtonColor } from '../basic/SessionButton'; -import { ConversationRequestExplanation } from './SubtleNotification'; +import { + ConversationRequestExplanation, + GroupRequestExplanation, + InvitedToGroupControlMessage, +} from './SubtleNotification'; -const ConversationRequestBanner = styled.div` +const MessageRequestContainer = styled.div` display: flex; flex-direction: column; justify-content: center; @@ -40,24 +51,31 @@ const StyledBlockUserText = styled.span` font-weight: 700; `; -const handleDeclineConversationRequest = (convoId: string, currentSelected: string | undefined) => { +const handleDeclineConversationRequest = ( + convoId: string, + currentSelected: string | undefined, + conversationIdOrigin: string | null +) => { declineConversationWithConfirm({ conversationId: convoId, syncToDevices: true, - blockContact: false, + alsoBlock: false, currentlySelectedConvo: currentSelected, + conversationIdOrigin, }); }; const handleDeclineAndBlockConversationRequest = ( convoId: string, - currentSelected: string | undefined + currentSelected: string | undefined, + conversationIdOrigin: string | null ) => { declineConversationWithConfirm({ conversationId: convoId, syncToDevices: true, - blockContact: true, + alsoBlock: true, currentlySelectedConvo: currentSelected, + conversationIdOrigin, }); }; @@ -69,17 +87,34 @@ const handleAcceptConversationRequest = async (convoId: string) => { await convo.setDidApproveMe(true, false); await convo.setIsApproved(true, false); await convo.commit(); - await convo.addOutgoingApprovalMessage(Date.now()); - await approveConvoAndSendResponse(convoId, true); + if (convo.isPrivate()) { + await convo.addOutgoingApprovalMessage(Date.now()); + await approveConvoAndSendResponse(convoId, true); + } else if (PubKey.is03Pubkey(convoId)) { + const found = await UserGroupsWrapperActions.getGroup(convoId); + if (!found) { + window.log.warn('cannot approve a non existing group in usergroup'); + return; + } + // this updates the wrapper and refresh the redux slice + await UserGroupsWrapperActions.setGroup({ ...found, invitePending: false }); + getSwarmPollingInstance().addGroupId(convoId); + } }; export const ConversationMessageRequestButtons = () => { const selectedConvoId = useSelectedConversationKey(); - - const hasIncomingMessages = useSelector(hasSelectedConversationIncomingMessages); const isIncomingRequest = useIsIncomingRequest(selectedConvoId); + const isGroupV2 = useSelectedIsGroupV2(); + const isPrivateAndFriend = useSelectedIsPrivateFriend(); + const isGroupPendingInvite = useLibGroupInvitePending(selectedConvoId); + const convoOrigin = useSelectedConversationIdOrigin() ?? null; - if (!selectedConvoId || !hasIncomingMessages) { + if ( + !selectedConvoId || + isPrivateAndFriend || // if we are already friends, there is no need for the msg request buttons + (isGroupV2 && !isGroupPendingInvite) + ) { return null; } @@ -88,18 +123,8 @@ export const ConversationMessageRequestButtons = () => { } return ( - - { - handleDeclineAndBlockConversationRequest(selectedConvoId, selectedConvoId); - }} - data-testid="decline-and-block-message-request" - > - {window.i18n('block')} - - - - + + { @@ -110,13 +135,25 @@ export const ConversationMessageRequestButtons = () => { /> { - handleDeclineConversationRequest(selectedConvoId, selectedConvoId); + handleDeclineConversationRequest(selectedConvoId, selectedConvoId, convoOrigin); }} dataTestId="decline-message-request" /> - + {isGroupV2 ? : } + + {(isGroupV2 && !!convoOrigin) || !isGroupV2 ? ( + { + handleDeclineAndBlockConversationRequest(selectedConvoId, selectedConvoId, convoOrigin); + }} + data-testid="decline-and-block-message-request" + > + {window.i18n('block')} + + ) : null} + ); }; diff --git a/ts/components/conversation/SessionConversation.tsx b/ts/components/conversation/SessionConversation.tsx index 5efadffea..83efd3870 100644 --- a/ts/components/conversation/SessionConversation.tsx +++ b/ts/components/conversation/SessionConversation.tsx @@ -55,6 +55,7 @@ import { MessageDetail } from './message/message-item/MessageDetail'; import { HTMLDirection } from '../../util/i18n'; import { SessionSpinner } from '../basic/SessionSpinner'; +import { ConversationMessageRequestButtons } from './MessageRequestButtons'; const DEFAULT_JPEG_QUALITY = 0.85; @@ -270,7 +271,7 @@ export class SessionConversation extends React.Component {
- + } bottom={ diff --git a/ts/components/conversation/SessionMessagesListContainer.tsx b/ts/components/conversation/SessionMessagesListContainer.tsx index 92941a56d..1c693051e 100644 --- a/ts/components/conversation/SessionMessagesListContainer.tsx +++ b/ts/components/conversation/SessionMessagesListContainer.tsx @@ -24,7 +24,6 @@ import { import { getSelectedConversationKey } from '../../state/selectors/selectedConversation'; import { SessionMessagesList } from './SessionMessagesList'; import { TypingBubble } from './TypingBubble'; -import { ConversationMessageRequestButtons } from './MessageRequestButtons'; export type SessionMessageListProps = { messageContainerRef: React.RefObject; @@ -126,7 +125,6 @@ class SessionMessagesListContainerInner extends React.Component { isTyping={!!conversation.isTyping} key="typing-bubble" /> - + + + + + ); +} + /** * This component is used to display a warning when the user is responding to a message request. - * */ export const ConversationRequestExplanation = () => { const selectedConversation = useSelectedConversationKey(); @@ -46,9 +66,64 @@ export const ConversationRequestExplanation = () => { } return ( - - {window.i18n('respondingToRequestWarning')} - + + ); +}; + +/** + * This component is used to display a warning when the user is responding to a group message request. + */ +export const GroupRequestExplanation = () => { + const selectedConversation = useSelectedConversationKey(); + const isIncomingMessageRequest = useIsIncomingRequest(selectedConversation); + const isGroupV2 = useSelectedIsGroupV2(); + const showMsgRequestUI = selectedConversation && isIncomingMessageRequest; + // isApproved in DB is tracking the pending state for a group + const isApproved = useSelectedIsApproved(); + const isGroupPendingInvite = useLibGroupInvitePending(selectedConversation); + + if (!showMsgRequestUI || isApproved || !isGroupV2 || !isGroupPendingInvite) { + return null; + } + return ( + + ); +}; + +export const InvitedToGroupControlMessage = () => { + const selectedConversation = useSelectedConversationKey(); + const isGroupV2 = useSelectedIsGroupV2(); + const hasMessages = useSelectedHasMessages(); + const isApproved = useSelectedIsApproved(); + + const groupName = useLibGroupInviteGroupName(selectedConversation) || window.i18n('unknown'); + const conversationOrigin = useSelectedConversationIdOrigin(); + const adminNameInvitedUs = + useConversationUsernameOrShorten(conversationOrigin) || window.i18n('unknown'); + const isGroupPendingInvite = useLibGroupInvitePending(selectedConversation); + if ( + !selectedConversation || + isApproved || + hasMessages || // we don't want to display that "xx invited you" message if there are already other messages (incoming or outgoing) + !isGroupV2 || + !conversationOrigin || + !PubKey.is05Pubkey(conversationOrigin) || + !isGroupPendingInvite + ) { + return null; + } + + return ( + ); }; @@ -58,7 +133,9 @@ export const ConversationRequestExplanation = () => { export const NoMessageInConversation = () => { const selectedConversation = useSelectedConversationKey(); - const hasMessage = useSelector(getSelectedHasMessages); + const hasMessage = useSelectedHasMessages(); + const isGroupV2 = useSelectedIsGroupV2(); + const isInvitePending = useLibGroupInvitePending(selectedConversation); const isMe = useSelectedIsNoteToSelf(); const canWrite = useSelector(getSelectedCanWrite); @@ -66,7 +143,8 @@ export const NoMessageInConversation = () => { // TODOLATER use this selector accross the whole application (left pane excluded) const nameToRender = useSelectedNicknameOrProfileNameOrShortenedPubkey() || ''; - if (!selectedConversation || hasMessage) { + // groupV2 use its own invite logic as part of + if (!selectedConversation || hasMessage || (isGroupV2 && isInvitePending)) { return null; } let localizedKey: LocalizerKeys = 'noMessagesInEverythingElse'; @@ -81,10 +159,9 @@ export const NoMessageInConversation = () => { } return ( - - - - - + ); }; diff --git a/ts/components/conversation/message/message-content/MessageAvatar.tsx b/ts/components/conversation/message/message-content/MessageAvatar.tsx index bec16a9cb..8454e186d 100644 --- a/ts/components/conversation/message/message-content/MessageAvatar.tsx +++ b/ts/components/conversation/message/message-content/MessageAvatar.tsx @@ -100,7 +100,7 @@ export const MessageAvatar = (props: Props) => { privateConvoToOpen = foundRealSessionId || privateConvoToOpen; } - await ConvoHub.use().get(privateConvoToOpen).setOriginConversationID(selectedConvoKey); + await ConvoHub.use().get(privateConvoToOpen).setOriginConversationID(selectedConvoKey, true); // public and blinded key for that message, we should open the convo as is and see if the user wants // to send a sogs blinded message request. diff --git a/ts/components/dialog/InviteContactsDialog.tsx b/ts/components/dialog/InviteContactsDialog.tsx index f90e1bb57..61f3c352c 100644 --- a/ts/components/dialog/InviteContactsDialog.tsx +++ b/ts/components/dialog/InviteContactsDialog.tsx @@ -22,7 +22,7 @@ import { useSet } from '../../hooks/useSet'; import { ClosedGroup } from '../../session/group/closed-group'; import { PubKey } from '../../session/types'; import { SessionUtilUserGroups } from '../../session/utils/libsession/libsession_utils_user_groups'; -import { groupInfoActions } from '../../state/ducks/groups'; +import { groupInfoActions } from '../../state/ducks/metaGroups'; import { getPrivateContactsPubkeys } from '../../state/selectors/conversations'; import { useMemberGroupChangePending } from '../../state/selectors/groups'; import { MemberListItem } from '../MemberListItem'; diff --git a/ts/components/dialog/UpdateGroupMembersDialog.tsx b/ts/components/dialog/UpdateGroupMembersDialog.tsx index d8d9997d8..1fdad5c58 100644 --- a/ts/components/dialog/UpdateGroupMembersDialog.tsx +++ b/ts/components/dialog/UpdateGroupMembersDialog.tsx @@ -27,7 +27,7 @@ import { useSet } from '../../hooks/useSet'; import { ConvoHub } from '../../session/conversations'; import { ClosedGroup } from '../../session/group/closed-group'; import { PubKey } from '../../session/types'; -import { groupInfoActions } from '../../state/ducks/groups'; +import { groupInfoActions } from '../../state/ducks/metaGroups'; import { useMemberGroupChangePending } from '../../state/selectors/groups'; import { useSelectedIsGroupV2 } from '../../state/selectors/selectedConversation'; import { SessionSpinner } from '../basic/SessionSpinner'; diff --git a/ts/components/dialog/UpdateGroupNameDialog.tsx b/ts/components/dialog/UpdateGroupNameDialog.tsx index 59c18883f..2a047899d 100644 --- a/ts/components/dialog/UpdateGroupNameDialog.tsx +++ b/ts/components/dialog/UpdateGroupNameDialog.tsx @@ -9,7 +9,7 @@ import { ConvoHub } from '../../session/conversations'; import { ClosedGroup } from '../../session/group/closed-group'; import { initiateOpenGroupUpdate } from '../../session/group/open-group'; import { PubKey } from '../../session/types'; -import { groupInfoActions } from '../../state/ducks/groups'; +import { groupInfoActions } from '../../state/ducks/metaGroups'; import { updateGroupNameModal } from '../../state/ducks/modalDialog'; import { getLibGroupNameOutsideRedux } from '../../state/selectors/groups'; import { pickFileForAvatar } from '../../types/attachments/VisualAttachment'; diff --git a/ts/components/leftpane/overlay/OverlayClosedGroup.tsx b/ts/components/leftpane/overlay/OverlayClosedGroup.tsx index f9c54fdbd..386932230 100644 --- a/ts/components/leftpane/overlay/OverlayClosedGroup.tsx +++ b/ts/components/leftpane/overlay/OverlayClosedGroup.tsx @@ -15,7 +15,7 @@ import { useSet } from '../../../hooks/useSet'; import { VALIDATION } from '../../../session/constants'; import { createClosedGroup } from '../../../session/conversations/createClosedGroup'; import { ToastUtils } from '../../../session/utils'; -import { groupInfoActions } from '../../../state/ducks/groups'; +import { groupInfoActions } from '../../../state/ducks/metaGroups'; import { resetOverlayMode } from '../../../state/ducks/section'; import { getPrivateContactsPubkeys } from '../../../state/selectors/conversations'; import { useIsCreatingGroupFromUIPending } from '../../../state/selectors/groups'; diff --git a/ts/components/leftpane/overlay/OverlayMessageRequest.tsx b/ts/components/leftpane/overlay/OverlayMessageRequest.tsx index 529ffd619..99a164698 100644 --- a/ts/components/leftpane/overlay/OverlayMessageRequest.tsx +++ b/ts/components/leftpane/overlay/OverlayMessageRequest.tsx @@ -78,10 +78,11 @@ export const OverlayMessageRequest = () => { const convoId = messageRequests[index]; // eslint-disable-next-line no-await-in-loop await declineConversationWithoutConfirm({ - blockContact: false, + alsoBlock: false, conversationId: convoId, currentlySelectedConvo, syncToDevices: false, + conversationIdOrigin: null, // block is false, no need for conversationIdOrigin }); } diff --git a/ts/components/menu/Menu.tsx b/ts/components/menu/Menu.tsx index 235f43251..aa0cd739c 100644 --- a/ts/components/menu/Menu.tsx +++ b/ts/components/menu/Menu.tsx @@ -9,6 +9,7 @@ import { useIsActive, useIsBlinded, useIsBlocked, + useIsGroupV2, useIsIncomingRequest, useIsKickedFromGroup, useIsLeft, @@ -48,6 +49,7 @@ import { updateConfirmModal, updateUserDetailsModal, } from '../../state/ducks/modalDialog'; +import { useConversationIdOrigin } from '../../state/selectors/conversations'; import { getIsMessageSection } from '../../state/selectors/section'; import { useSelectedConversationKey } from '../../state/selectors/selectedConversation'; import { LocalizerKeys } from '../../types/LocalizerKeys'; @@ -502,20 +504,21 @@ export const DeclineMsgRequestMenuItem = () => { const isRequest = useIsIncomingRequest(convoId); const isPrivate = useIsPrivate(convoId); const selected = useSelectedConversationKey(); - - if (isPrivate && isRequest) { + const isGroupV2 = useIsGroupV2(convoId); + if ((isPrivate || isGroupV2) && isRequest) { return ( { declineConversationWithConfirm({ conversationId: convoId, syncToDevices: true, - blockContact: false, + alsoBlock: false, currentlySelectedConvo: selected || undefined, + conversationIdOrigin: null, }); }} > - {window.i18n('decline')} + {isGroupV2 ? window.i18n('delete') : window.i18n('decline')} ); } @@ -527,16 +530,20 @@ export const DeclineAndBlockMsgRequestMenuItem = () => { const isRequest = useIsIncomingRequest(convoId); const selected = useSelectedConversationKey(); const isPrivate = useIsPrivate(convoId); + const isGroupV2 = useIsGroupV2(convoId); + const convoOrigin = useConversationIdOrigin(convoId); - if (isRequest && isPrivate) { + if (isRequest && (isPrivate || (isGroupV2 && convoOrigin))) { + // to block the author of a groupv2 invitge we need the convoOrigin set return ( { declineConversationWithConfirm({ conversationId: convoId, syncToDevices: true, - blockContact: true, + alsoBlock: true, currentlySelectedConvo: selected || undefined, + conversationIdOrigin: convoOrigin ?? null, }); }} > diff --git a/ts/hooks/useParamSelector.ts b/ts/hooks/useParamSelector.ts index c68358eec..2b5d11818 100644 --- a/ts/hooks/useParamSelector.ts +++ b/ts/hooks/useParamSelector.ts @@ -231,6 +231,7 @@ export function useIsIncomingRequest(convoId?: string) { return Boolean( convoProps && hasValidIncomingRequestValues({ + id: convoProps.id, isMe: convoProps.isMe || false, isApproved: convoProps.isApproved || false, isPrivate: convoProps.isPrivate || false, diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 7c1f47287..c3e3011b0 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -14,6 +14,7 @@ import { OpenGroupUtils } from '../session/apis/open_group_api/utils'; import { ConvoHub } from '../session/conversations'; import { getSodiumRenderer } from '../session/crypto'; import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager'; +import { PubKey } from '../session/types'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { fromHexToArray, toHex } from '../session/utils/String'; import { UserSync } from '../session/utils/job_runners/jobs/UserSyncJob'; @@ -36,6 +37,7 @@ import { updateRemoveModeratorsModal, } from '../state/ducks/modalDialog'; import { MIME } from '../types'; +import { LocalizerKeys } from '../types/LocalizerKeys'; import { IMAGE_JPEG } from '../types/MIME'; import { processNewAttachment } from '../types/MessageAttachment'; import { urlToBlob } from '../types/attachments/VisualAttachment'; @@ -70,11 +72,6 @@ export async function blockConvoById(conversationId: string) { return; } - // I don't think we want to reset the approved fields when blocking a contact - // if (conversation.isPrivate()) { - // await conversation.setIsApproved(false); - // } - await BlockedNumberController.block(conversation.id); await conversation.commit(); ToastUtils.pushToastSuccess('blocked', window.i18n('blocked')); @@ -124,19 +121,24 @@ export const approveConvoAndSendResponse = async ( }; export async function declineConversationWithoutConfirm({ - blockContact, + alsoBlock, conversationId, currentlySelectedConvo, syncToDevices, + conversationIdOrigin, }: { conversationId: string; currentlySelectedConvo: string | undefined; syncToDevices: boolean; - blockContact: boolean; // if set to false, the contact will just be set to not approved + alsoBlock: boolean; + conversationIdOrigin: string | null; }) { const conversationToDecline = ConvoHub.use().get(conversationId); - if (!conversationToDecline || !conversationToDecline.isPrivate()) { + if ( + !conversationToDecline || + (!conversationToDecline.isPrivate() && !conversationToDecline.isClosedGroupV2()) + ) { window?.log?.info('No conversation to decline.'); return; } @@ -144,10 +146,20 @@ export async function declineConversationWithoutConfirm({ // Note: do not set the active_at undefined as this would make that conversation not synced with the libsession wrapper await conversationToDecline.setIsApproved(false, false); await conversationToDecline.setDidApproveMe(false, false); + await conversationToDecline.setOriginConversationID('', false); // this will update the value in the wrapper if needed but not remove the entry if we want it gone. The remove is done below with removeContactFromWrapper await conversationToDecline.commit(); - if (blockContact) { - await blockConvoById(conversationId); + if (alsoBlock) { + if (PubKey.is03Pubkey(conversationId)) { + // Note: if we do want to block this convo, we actually want to block the person who invited us, not the 03 pubkey itself + if (conversationIdOrigin && !PubKey.is03Pubkey(conversationIdOrigin)) { + // restoring from seed we can be missing the conversationIdOrigin, so we wouldn't be able to block the person who invited us + + await blockConvoById(conversationIdOrigin); + } + } else { + await blockConvoById(conversationId); + } } // when removing a message request, without blocking it, we actually have no need to store the conversation in the wrapper. So just remove the entry @@ -158,6 +170,10 @@ export async function declineConversationWithoutConfirm({ await SessionUtilContact.removeContactFromWrapper(conversationToDecline.id); } + if (PubKey.is03Pubkey(conversationId)) { + await UserGroupsWrapperActions.eraseGroup(conversationId); + } + if (syncToDevices) { await forceSyncConfigurationNowIfNeeded(); } @@ -169,25 +185,58 @@ export async function declineConversationWithoutConfirm({ export const declineConversationWithConfirm = ({ conversationId, syncToDevices, - blockContact, + alsoBlock, currentlySelectedConvo, + conversationIdOrigin, }: { conversationId: string; currentlySelectedConvo: string | undefined; syncToDevices: boolean; - blockContact: boolean; // if set to false, the contact will just be set to not approved + alsoBlock: boolean; + conversationIdOrigin: string | null; }) => { + const isGroupV2 = PubKey.is03Pubkey(conversationId); + + const okKey: LocalizerKeys = alsoBlock ? 'block' : isGroupV2 ? 'delete' : 'decline'; + const nameToBlock = + alsoBlock && !!conversationIdOrigin + ? ConvoHub.use().get(conversationIdOrigin)?.getContactProfileNameOrShortenedPubKey() + : null; + const messageKey: LocalizerKeys = isGroupV2 + ? alsoBlock && nameToBlock + ? 'deleteGroupRequestAndBlock' + : 'deleteGroupRequest' + : 'declineRequestMessage'; + + let message = ''; + // restoring from seeed we might not have the sender of that invite, so we need to take care of not having one (and not block) + if (isGroupV2 && messageKey === 'deleteGroupRequestAndBlock') { + if (!nameToBlock) { + throw new Error( + 'deleteGroupRequestAndBlock needs a nameToBlock (or block should not be visible)' + ); + } + + message = window.i18n( + messageKey, + messageKey === 'deleteGroupRequestAndBlock' ? [nameToBlock] : [] + ); + } else { + message = window.i18n(messageKey); + } + window?.inboxStore?.dispatch( updateConfirmModal({ - okText: blockContact ? window.i18n('block') : window.i18n('decline'), + okText: window.i18n(okKey), cancelText: window.i18n('cancel'), - message: window.i18n('declineRequestMessage'), + message, onClickOk: async () => { await declineConversationWithoutConfirm({ conversationId, currentlySelectedConvo, - blockContact, + alsoBlock, syncToDevices, + conversationIdOrigin, }); }, onClickCancel: () => { diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 5de2f69e7..292a98987 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -237,6 +237,10 @@ export class ConversationModel extends Backbone.Model { return isDirectConversation(this.get('type')); } + public isPrivateAndBlinded() { + return this.isPrivate() && PubKey.isBlinded(this.id); + } + // returns true if this is a closed/medium or open group public isGroup() { return isOpenOrClosedGroup(this.get('type')); @@ -383,8 +387,11 @@ export class ConversationModel extends Backbone.Model { toRet.groupAdmins = this.getGroupAdmins(); } - // those are values coming only from the DB when this is a closed group + if (this.isClosedGroupV2() || this.isPrivateAndBlinded()) { + toRet.conversationIdOrigin = this.getConversationIdOrigin(); + } if (this.isClosedGroup()) { + // those are values coming only from the DB when this is a closed group if (this.isKickedFromGroup()) { toRet.isKickedFromGroup = this.isKickedFromGroup(); } @@ -660,6 +667,7 @@ export class ConversationModel extends Backbone.Model { */ public isIncomingRequest(): boolean { return hasValidIncomingRequestValues({ + id: this.id, isMe: this.isMe(), isApproved: this.isApproved(), isBlocked: this.isBlocked(), @@ -1359,14 +1367,29 @@ export class ConversationModel extends Backbone.Model { } } - public async setOriginConversationID(conversationIdOrigin: string) { - if (conversationIdOrigin === this.get('conversationIdOrigin')) { + public async setOriginConversationID(conversationIdOrigin: string, shouldCommit: boolean) { + if (conversationIdOrigin === this.getConversationIdOrigin()) { return; } + // conversationIdOrigin can only be a 05 pubkey (invite to a 03 group from a 05 person, or a sogs url), or undefined + if ( + conversationIdOrigin && + !PubKey.is05Pubkey(conversationIdOrigin) && + !OpenGroupUtils.isOpenGroupV2(conversationIdOrigin) + ) { + window.log.warn( + 'tried to setOriginConversationID with invalid parameter:', + conversationIdOrigin + ); + throw new Error('tried to setOriginConversationID with invalid parameter '); + } this.set({ conversationIdOrigin, }); - await this.commit(); + + if (shouldCommit) { + await this.commit(); + } } /** @@ -1735,6 +1758,7 @@ export class ConversationModel extends Backbone.Model { public isLeft(): boolean { if (this.isClosedGroup()) { if (this.isClosedGroupV2()) { + // getLibGroupNameOutsideRedux(this.id) || // console.info('isLeft using lib todo'); // debugger } return !!this.get('left'); @@ -1931,7 +1955,7 @@ export class ConversationModel extends Backbone.Model { private async sendBlindedMessageRequest(messageParams: VisibleMessageParams) { const ourSignKeyBytes = await UserUtils.getUserED25519KeyPairBytes(); - const groupUrl = this.getSogsOriginMessage(); + const groupUrl = this.getConversationIdOrigin(); if (!PubKey.isBlinded(this.id)) { window?.log?.warn('sendBlindedMessageRequest - convo is not a blinded one'); @@ -2091,10 +2115,18 @@ export class ConversationModel extends Backbone.Model { } /** - * - * @returns The open group conversationId this conversation originated from + * @link ConversationAttributes#conversationIdOrigin */ - private getSogsOriginMessage() { + private getConversationIdOrigin() { + if (!this.isClosedGroupV2() && !this.isPrivateAndBlinded()) { + window.log.warn( + 'getConversationIdOrigin can only be set with 03-group or blinded conversation (15 prefix), got:', + this.id + ); + throw new Error( + 'getConversationIdOrigin can only be set with 03-group or blinded conversation (15 prefix)' + ); + } return this.get('conversationIdOrigin'); } @@ -2443,6 +2475,7 @@ export function hasValidOutgoingRequestValues({ * @param values Required properties to evaluate if this is a message request */ export function hasValidIncomingRequestValues({ + id, isMe, isApproved, isBlocked, @@ -2450,6 +2483,7 @@ export function hasValidIncomingRequestValues({ activeAt, didApproveMe, }: { + id: string; isMe: boolean; isApproved: boolean; isBlocked: boolean; @@ -2459,5 +2493,12 @@ export function hasValidIncomingRequestValues({ }): boolean { // if a convo is not active, it means we didn't get any messages nor sent any. const isActive = activeAt && isFinite(activeAt) && activeAt > 0; - return Boolean(isPrivate && !isMe && !isApproved && !isBlocked && isActive && didApproveMe); + return Boolean( + (isPrivate || PubKey.is03Pubkey(id)) && + !isMe && + !isApproved && + !isBlocked && + isActive && + didApproveMe + ); } diff --git a/ts/models/conversationAttributes.ts b/ts/models/conversationAttributes.ts index 80f0cd4e7..0450bbf57 100644 --- a/ts/models/conversationAttributes.ts +++ b/ts/models/conversationAttributes.ts @@ -73,7 +73,7 @@ export interface ConversationAttributes { isTrustedForAttachmentDownload: boolean; // not synced accross devices, this field is used if we should auto download attachments from this conversation or not - conversationIdOrigin?: string; // Blinded message requests ONLY: The community from which this conversation originated from + conversationIdOrigin?: string; // The conversation from which this conversation originated from: blinded message request or 03-group admin who invited us // TODOLATER those two items are only used for legacy closed groups and will be removed when we get rid of the legacy closed groups support lastJoinedTimestamp: number; // ClosedGroup: last time we were added to this group // TODOLATER to remove after legacy closed group are dropped diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts index 08aace3f7..3019f1992 100644 --- a/ts/receiver/configMessage.ts +++ b/ts/receiver/configMessage.ts @@ -34,7 +34,7 @@ import { } from '../session/apis/snode_api/namespaces'; import { RetrieveMessageItemWithNamespace } from '../session/apis/snode_api/types'; import { ClosedGroup, GroupInfo } from '../session/group/closed-group'; -import { groupInfoActions } from '../state/ducks/groups'; +import { groupInfoActions } from '../state/ducks/metaGroups'; import { ConfigWrapperObjectTypesMeta, ConfigWrapperUser, diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index 72efa8b8d..e97c3648c 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -3,6 +3,7 @@ import { isEmpty, isFinite, isNumber } from 'lodash'; import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { HexString } from '../../node/hexStrings'; import { SignalService } from '../../protobuf'; +import { getMessageQueue } from '../../session'; import { getSwarmPollingInstance } from '../../session/apis/snode_api'; import { GetNetworkTime } from '../../session/apis/snode_api/getNetworkTime'; import { ConvoHub } from '../../session/conversations'; @@ -10,14 +11,13 @@ import { getSodiumRenderer } from '../../session/crypto'; import { ClosedGroup } from '../../session/group/closed-group'; import { GroupUpdateInviteResponseMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInviteResponseMessage'; import { ed25519Str } from '../../session/onions/onionPath'; -import { getMessageQueue } from '../../session/sending'; import { PubKey } from '../../session/types'; import { UserUtils } from '../../session/utils'; import { stringToUint8Array } from '../../session/utils/String'; import { PreConditionFailed } from '../../session/utils/errors'; import { UserSync } from '../../session/utils/job_runners/jobs/UserSyncJob'; import { LibSessionUtil } from '../../session/utils/libsession/libsession_utils'; -import { groupInfoActions } from '../../state/ducks/groups'; +import { groupInfoActions } from '../../state/ducks/metaGroups'; import { toFixedUint8ArrayOfLength } from '../../types/sqlSharedTypes'; import { BlockedNumberController } from '../../util'; import { @@ -42,6 +42,20 @@ type GroupUpdateDetails = { updateMessage: SignalService.GroupUpdateMessage; } & WithEnvelopeTimestamp; +async function sendInviteResponseToGroup({ groupPk }: { groupPk: GroupPubkeyType }) { + await getMessageQueue().sendToGroupV2({ + message: new GroupUpdateInviteResponseMessage({ + groupPk, + isApproved: true, + createAtNetworkTimestamp: GetNetworkTime.now(), + }), + }); + + // TODO use the pending so we actually don't start polling here unless it is not in the pending state. + // once everything is ready, start polling using that authData to get the keys, members, details of that group, and its messages. + getSwarmPollingInstance().addGroupId(groupPk); +} + async function handleGroupInviteMessage({ inviteMessage, author, @@ -82,6 +96,8 @@ async function handleGroupInviteMessage({ ); convo.set({ active_at: envelopeTimestamp, + didApproveMe: true, + conversationIdOrigin: author, }); if (inviteMessage.name && isEmpty(convo.getRealSessionUsername())) { @@ -90,6 +106,7 @@ async function handleGroupInviteMessage({ }); } await convo.commit(); + const userEd25519Secretkey = (await UserUtils.getUserED25519KeyPairBytes()).privKeyBytes; let found = await UserGroupsWrapperActions.getGroup(inviteMessage.groupSessionId); if (!found) { @@ -100,12 +117,16 @@ async function handleGroupInviteMessage({ priority: 0, pubkeyHex: inviteMessage.groupSessionId, secretKey: null, + kicked: false, + invitePending: true, }; + } else { + found.kicked = false; + found.name = inviteMessage.name; } // not sure if we should drop it, or set it again? They should be the same anyway found.authData = inviteMessage.memberAuthData; - const userEd25519Secretkey = (await UserUtils.getUserED25519KeyPairBytes()).privKeyBytes; await UserGroupsWrapperActions.setGroup(found); await MetaGroupWrapperActions.init(inviteMessage.groupSessionId, { metaDumped: null, @@ -118,20 +139,10 @@ async function handleGroupInviteMessage({ }); await LibSessionUtil.saveDumpsToDb(UserUtils.getOurPubKeyStrFromCache()); await UserSync.queueNewJobIfNeeded(); - - // TODO currently sending auto-accept of invite. needs to be removed once we get the Group message request logic - console.warn('currently sending auto accept invite response'); - await getMessageQueue().sendToGroupV2({ - message: new GroupUpdateInviteResponseMessage({ - groupPk: inviteMessage.groupSessionId, - isApproved: true, - createAtNetworkTimestamp: GetNetworkTime.now(), - }), - }); - - // TODO use the pending so we actually don't start polling here unless it is not in the pending state. - // once everything is ready, start polling using that authData to get the keys, members, details of that group, and its messages. - getSwarmPollingInstance().addGroupId(inviteMessage.groupSessionId); + if (!found.invitePending) { + // if this group should already be polling + getSwarmPollingInstance().addGroupId(inviteMessage.groupSessionId); + } } async function verifySig({ diff --git a/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts b/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts index 38c0f36d5..6f8b71a5f 100644 --- a/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts +++ b/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts @@ -436,7 +436,7 @@ async function handleInboxOutboxMessages( messageHash: '', sentAt: postedAtInMs, }); - await outboxConversationModel.setOriginConversationID(serverConversationId); + await outboxConversationModel.setOriginConversationID(serverConversationId, true); await handleOutboxMessageModel( msgModel, diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index eccd290bd..001f2ba7c 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -189,6 +189,9 @@ export class SwarmPolling { } public async getPollingDetails(pollingEntries: Array) { + // Note: all of those checks are explicitely made only based on the libsession wrappers data, and NOT the DB. + // Eventually, we want to get rid of the duplication between the DB and libsession wrappers. + // If you need to add a check based on the DB, this is code smell. let toPollDetails: Array = []; const ourPubkey = UserUtils.getOurPubKeyStrFromCache(); @@ -230,7 +233,12 @@ export class SwarmPolling { const allGroupsTracked = groups .filter(m => this.shouldPollByTimeout(m)) // should we poll from it depending on this group activity? - .filter(m => allGroupsInWrapper.some(w => w.pubkeyHex === m.pubkey.key)) // we don't poll from groups which are not in the usergroup wrapper + .filter(m => { + // We don't poll from groups which are not in the usergroup wrapper, and for those which are not marked as accepted + // We don't want to leave them, we just don't want to poll from them. + const found = allGroupsInWrapper.find(w => w.pubkeyHex === m.pubkey.key); + return found && !found.invitePending; + }) .map(m => m.pubkey.key as GroupPubkeyType) // extract the pubkey .map(m => [m, ConversationTypeEnum.GROUPV2] as PollForGroup); @@ -560,7 +568,12 @@ export class SwarmPolling { const closedGroupsOnly = convos.filter( (c: ConversationModel) => - c.isClosedGroup() && !c.isBlocked() && !c.isKickedFromGroup() && !c.isLeft() + (c.isClosedGroupV2() && + !c.isBlocked() && + !c.isKickedFromGroup() && + !c.isLeft() && + c.isApproved()) || + (c.isClosedGroup() && !c.isBlocked() && !c.isKickedFromGroup() && !c.isLeft()) ); closedGroupsOnly.forEach(c => { diff --git a/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts b/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts index 5f460acbd..f3c9d618d 100644 --- a/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts +++ b/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts @@ -1,5 +1,5 @@ import { GroupPubkeyType } from 'libsession_util_nodejs'; -import { groupInfoActions } from '../../../../state/ducks/groups'; +import { groupInfoActions } from '../../../../state/ducks/metaGroups'; import { MetaGroupWrapperActions } from '../../../../webworker/workers/browser/libsession_worker_interface'; import { ed25519Str } from '../../../onions/onionPath'; import { fromBase64ToArray } from '../../../utils/String'; @@ -19,7 +19,6 @@ async function handleGroupSharedConfigMessages( ); if (groupConfigMessages.find(m => !m.storedAt)) { - debugger; throw new Error('all incoming group config message should have a timestamp'); } const infos = groupConfigMessages diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index 8efcb22fa..b9b8ff5b9 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -18,7 +18,7 @@ import { getMessageQueue } from '..'; import { deleteAllMessagesByConvoIdNoConfirmation } from '../../interactions/conversationInteractions'; import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../../models/conversationAttributes'; import { removeAllClosedGroupEncryptionKeyPairs } from '../../receiver/closedGroups'; -import { groupInfoActions } from '../../state/ducks/groups'; +import { groupInfoActions } from '../../state/ducks/metaGroups'; import { getCurrentlySelectedConversationOutsideRedux } from '../../state/selectors/conversations'; import { assertUnreachable } from '../../types/sqlSharedTypes'; import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; @@ -212,7 +212,6 @@ class ConvoController { } window.log.info(`deleteClosedGroup: ${groupId}, sendLeaveMessage?:${options.sendLeaveMessage}`); getSwarmPollingInstance().removePubkey(groupId, 'deleteClosedGroup'); // we don't need to keep polling anymore. - if (options.sendLeaveMessage) { await leaveClosedGroup(groupId, options.fromSyncMessage); } diff --git a/ts/session/utils/job_runners/jobs/GroupInviteJob.ts b/ts/session/utils/job_runners/jobs/GroupInviteJob.ts index 4fb1636a5..bfa75c18b 100644 --- a/ts/session/utils/job_runners/jobs/GroupInviteJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupInviteJob.ts @@ -153,6 +153,7 @@ class GroupInviteJob extends PersistedJob { } finally { updateFailedStateForMember(groupPk, member, failed); try { + debugger; await MetaGroupWrapperActions.memberSetInvited(groupPk, member, failed); } catch (e) { window.log.warn('GroupInviteJob memberSetInvited failed with', e.message); diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 921a3d714..a5609fbc8 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -1,13 +1,13 @@ import { bindActionCreators, Dispatch } from '@reduxjs/toolkit'; -import { actions as search } from './ducks/search'; import { actions as conversations } from './ducks/conversations'; -import { actions as user } from './ducks/user'; -import { actions as sections } from './ducks/section'; -import { actions as theme } from './ducks/theme'; +import { groupInfoActions } from './ducks/metaGroups'; import { actions as modalDialog } from './ducks/modalDialog'; import { actions as primaryColor } from './ducks/primaryColor'; -import { groupInfoActions } from './ducks/groups'; +import { actions as search } from './ducks/search'; +import { actions as sections } from './ducks/section'; +import { actions as theme } from './ducks/theme'; +import { actions as user } from './ducks/user'; export function mapDispatchToProps(dispatch: Dispatch): object { return { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 93ce75ae2..f879c5bcd 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -8,6 +8,8 @@ import { LightBoxOptions } from '../../components/conversation/SessionConversati import { Data } from '../../data/data'; import { CONVERSATION_PRIORITIES, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ConversationAttributes, ConversationNotificationSettingType, ConversationTypeEnum, } from '../../models/conversationAttributes'; @@ -253,6 +255,10 @@ export interface ReduxConversationType { * If this is undefined, it means all notification are enabled */ currentNotificationSetting?: ConversationNotificationSettingType; + /** + * @see {@link ConversationAttributes#conversationIdOrigin}. + */ + conversationIdOrigin?: string; priority?: number; // undefined means 0 isInitialFetchingInProgress?: boolean; diff --git a/ts/state/ducks/groups.ts b/ts/state/ducks/metaGroups.ts similarity index 99% rename from ts/state/ducks/groups.ts rename to ts/state/ducks/metaGroups.ts index ac7c1628e..b3b60b3a4 100644 --- a/ts/state/ducks/groups.ts +++ b/ts/state/ducks/metaGroups.ts @@ -34,6 +34,7 @@ import { RunJobResult } from '../../session/utils/job_runners/PersistedJob'; import { GroupInvite } from '../../session/utils/job_runners/jobs/GroupInviteJob'; import { GroupSync } from '../../session/utils/job_runners/jobs/GroupSyncJob'; import { UserSync } from '../../session/utils/job_runners/jobs/UserSyncJob'; +import { LibSessionUtil } from '../../session/utils/libsession/libsession_utils'; import { stringify, toFixedUint8ArrayOfLength } from '../../types/sqlSharedTypes'; import { getGroupPubkeyFromWrapperType, @@ -585,6 +586,8 @@ async function handleMemberChangeFromUIOrNot({ // this removes them from the wrapper await handleRemoveMembers({ groupPk, removed, secretKey: group.secretKey, fromCurrentDevice }); + await LibSessionUtil.saveDumpsToDb(groupPk); + // push new members & key supplement in a single batch call const batchResult = await GroupSync.pushChangesToGroupSwarmIfNeeded(groupPk, supplementKeys); if (batchResult !== RunJobResult.Success) { @@ -644,6 +647,7 @@ async function handleMemberChangeFromUIOrNot({ }), }); } + await LibSessionUtil.saveDumpsToDb(groupPk); convo.set({ active_at: createAtNetworkTimestamp, @@ -862,8 +866,8 @@ const currentDeviceGroupNameChange = createAsyncThunk( /** * This slice is the one holding the default joinable rooms fetched once in a while from the default opengroup v2 server. */ -const groupSlice = createSlice({ - name: 'group', +const metaGroupSlice = createSlice({ + name: 'metaGroup', initialState: initialGroupState, reducers: {}, extraReducers: builder => { @@ -1019,6 +1023,6 @@ export const groupInfoActions = { markUsAsAdmin, inviteResponseReceived, currentDeviceGroupNameChange, - ...groupSlice.actions, + ...metaGroupSlice.actions, }; -export const groupReducer = groupSlice.reducer; +export const groupReducer = metaGroupSlice.reducer; diff --git a/ts/state/ducks/userGroups.ts b/ts/state/ducks/userGroups.ts new file mode 100644 index 000000000..4a24beee0 --- /dev/null +++ b/ts/state/ducks/userGroups.ts @@ -0,0 +1,35 @@ +/* eslint-disable no-await-in-loop */ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { GroupPubkeyType, UserGroupsGet } from 'libsession_util_nodejs'; + +export type UserGroupState = { + userGroups: Record; +}; + +export const initialUserGroupState: UserGroupState = { + userGroups: {}, +}; + +const userGroupSlice = createSlice({ + name: 'userGroup', + initialState: initialUserGroupState, + + reducers: { + refreshUserGroupsSlice( + state: UserGroupState, + action: PayloadAction<{ groups: Array }> + ) { + state.userGroups = {}; + action.payload.groups.forEach(m => { + state.userGroups[m.pubkeyHex] = m; + }); + + return state; + }, + }, +}); + +export const userGroupsActions = { + ...userGroupSlice.actions, +}; +export const userGroupReducer = userGroupSlice.reducer; diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 3ab29c048..9c9eb18de 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -1,26 +1,27 @@ import { combineReducers } from '@reduxjs/toolkit'; -import { reducer as search, SearchStateType } from './ducks/search'; -import { ConversationsStateType, reducer as conversations } from './ducks/conversations'; -import { reducer as user, UserStateType } from './ducks/user'; -import { reducer as theme } from './ducks/theme'; +import { callReducer as call, CallStateType } from './ducks/call'; +import { reducer as conversations, ConversationsStateType } from './ducks/conversations'; +import { defaultRoomReducer as defaultRooms, DefaultRoomsState } from './ducks/defaultRooms'; import { reducer as primaryColor } from './ducks/primaryColor'; +import { reducer as search, SearchStateType } from './ducks/search'; import { reducer as section, SectionStateType } from './ducks/section'; -import { defaultRoomReducer as defaultRooms, DefaultRoomsState } from './ducks/defaultRooms'; import { ReduxSogsRoomInfos, SogsRoomInfoState } from './ducks/sogsRoomInfo'; -import { callReducer as call, CallStateType } from './ducks/call'; +import { reducer as theme } from './ducks/theme'; +import { reducer as user, UserStateType } from './ducks/user'; -import { defaultOnionReducer as onionPaths, OnionState } from './ducks/onion'; +import { PrimaryColorStateType, ThemeStateType } from '../themes/constants/colors'; +import { groupReducer, GroupState } from './ducks/metaGroups'; import { modalReducer as modals, ModalState } from './ducks/modalDialog'; -import { userConfigReducer as userConfig, UserConfigState } from './ducks/userConfig'; -import { timerOptionReducer as timerOptions, TimerOptionsState } from './ducks/timerOptions'; +import { defaultOnionReducer as onionPaths, OnionState } from './ducks/onion'; +import { settingsReducer, SettingsState } from './ducks/settings'; import { reducer as stagedAttachments, StagedAttachmentsStateType, } from './ducks/stagedAttachments'; -import { PrimaryColorStateType, ThemeStateType } from '../themes/constants/colors'; -import { settingsReducer, SettingsState } from './ducks/settings'; -import { groupReducer, GroupState } from './ducks/groups'; +import { timerOptionReducer as timerOptions, TimerOptionsState } from './ducks/timerOptions'; +import { userConfigReducer as userConfig, UserConfigState } from './ducks/userConfig'; +import { userGroupReducer, UserGroupState } from './ducks/userGroups'; export type StateType = { search: SearchStateType; @@ -39,6 +40,7 @@ export type StateType = { sogsRoomInfo: SogsRoomInfoState; settings: SettingsState; groups: GroupState; + userGroups: UserGroupState; }; export const reducers = { @@ -58,6 +60,7 @@ export const reducers = { sogsRoomInfo: ReduxSogsRoomInfos.sogsRoomInfoReducer, settings: settingsReducer, groups: groupReducer, + userGroups: userGroupReducer, }; // Making this work would require that our reducer signature supported AnyAction, not diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index d60c5a6a2..8c9dab9d7 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -2,6 +2,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { filter, isEmpty, isFinite, isNumber, pick, sortBy, toNumber } from 'lodash'; +import { useSelector } from 'react-redux'; import { ConversationLookupType, ConversationsStateType, @@ -93,6 +94,13 @@ export const hasSelectedConversationIncomingMessages = createSelector( } ); +export const hasSelectedConversationOutgoingMessages = createSelector( + getSortedMessagesOfSelectedConversation, + (messages: Array): boolean => { + return messages.some(m => m.propsForMessage.direction === 'outgoing'); + } +); + export const getFirstUnreadMessageId = (state: StateType): string | undefined => { return state.conversations.firstUnreadMessageId; }; @@ -379,8 +387,9 @@ const _getConversationRequests = ( sortedConversations: Array ): Array => { return filter(sortedConversations, conversation => { - const { isApproved, isBlocked, isPrivate, isMe, activeAt, didApproveMe } = conversation; + const { isApproved, isBlocked, isPrivate, isMe, activeAt, didApproveMe, id } = conversation; const isIncomingRequest = hasValidIncomingRequestValues({ + id, isApproved: isApproved || false, isBlocked: isBlocked || false, isPrivate: isPrivate || false, @@ -613,8 +622,8 @@ export function getLoadedMessagesLength(state: StateType) { return getMessagesFromState(state).length; } -export function getSelectedHasMessages(state: StateType): boolean { - return !isEmpty(getMessagesFromState(state)); +export function useSelectedHasMessages(): boolean { + return useSelector((state: StateType) => !isEmpty(getMessagesFromState(state))); } export const isFirstUnreadMessageIdAbove = createSelector( @@ -962,3 +971,9 @@ export const getIsSelectedConvoInitialLoadingInProgress = (state: StateType): bo export function getCurrentlySelectedConversationOutsideRedux() { return window?.inboxStore?.getState().conversations.selectedConversation as string | undefined; } + +export function useConversationIdOrigin(convoId: string | undefined) { + return useSelector((state: StateType) => + convoId ? state.conversations.conversationLookup?.[convoId]?.conversationIdOrigin : undefined + ); +} diff --git a/ts/state/selectors/groups.ts b/ts/state/selectors/groups.ts index 333dd5003..1903aef7c 100644 --- a/ts/state/selectors/groups.ts +++ b/ts/state/selectors/groups.ts @@ -1,7 +1,7 @@ import { GroupMemberGet, GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs'; import { useSelector } from 'react-redux'; import { PubKey } from '../../session/types'; -import { GroupState } from '../ducks/groups'; +import { GroupState } from '../ducks/metaGroups'; import { StateType } from '../reducer'; const getLibGroupsState = (state: StateType): GroupState => state.groups; diff --git a/ts/state/selectors/selectedConversation.ts b/ts/state/selectors/selectedConversation.ts index d4e51397d..a8dd33e75 100644 --- a/ts/state/selectors/selectedConversation.ts +++ b/ts/state/selectors/selectedConversation.ts @@ -36,10 +36,6 @@ const getIsSelectedBlocked = (state: StateType): boolean => { return Boolean(getSelectedConversation(state)?.isBlocked) || false; }; -const getSelectedIsApproved = (state: StateType): boolean => { - return Boolean(getSelectedConversation(state)?.isApproved) || false; -}; - const getSelectedApprovedMe = (state: StateType): boolean => { return Boolean(getSelectedConversation(state)?.didApproveMe) || false; }; @@ -210,7 +206,9 @@ export function useSelectedIsBlocked() { } export function useSelectedIsApproved() { - return useSelector(getSelectedIsApproved); + return useSelector((state: StateType): boolean => { + return !!(getSelectedConversation(state)?.isApproved || false); + }); } export function useSelectedApprovedMe() { @@ -280,6 +278,10 @@ export function useSelectedIsLeft() { return useSelector((state: StateType) => Boolean(getSelectedConversation(state)?.left) || false); } +export function useSelectedConversationIdOrigin() { + return useSelector((state: StateType) => getSelectedConversation(state)?.conversationIdOrigin); +} + export function useSelectedNickname() { return useSelector((state: StateType) => getSelectedConversation(state)?.nickname); } diff --git a/ts/state/selectors/userGroups.ts b/ts/state/selectors/userGroups.ts new file mode 100644 index 000000000..801217b3a --- /dev/null +++ b/ts/state/selectors/userGroups.ts @@ -0,0 +1,20 @@ +import { useSelector } from 'react-redux'; +import { PubKey } from '../../session/types'; +import { UserGroupState } from '../ducks/userGroups'; +import { StateType } from '../reducer'; + +const getUserGroupState = (state: StateType): UserGroupState => state.userGroups; + +const getGroupById = (state: StateType, convoId?: string) => { + return convoId && PubKey.is03Pubkey(convoId) + ? getUserGroupState(state).userGroups[convoId] + : undefined; +}; + +export function useLibGroupInvitePending(convoId?: string) { + return useSelector((state: StateType) => getGroupById(state, convoId)?.invitePending); +} + +export function useLibGroupInviteGroupName(convoId?: string) { + return useSelector((state: StateType) => getGroupById(state, convoId)?.name); +} diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts index 7e20bb973..b094f1391 100644 --- a/ts/types/LocalizerKeys.ts +++ b/ts/types/LocalizerKeys.ts @@ -126,6 +126,8 @@ export type LocalizerKeys = | 'deleteConversationConfirmation' | 'deleteForEveryone' | 'deleteFromAllMyDevices' + | 'deleteGroupRequest' + | 'deleteGroupRequestAndBlock' | 'deleteJustForMe' | 'deleteMessageQuestion' | 'deleteMessages' @@ -397,6 +399,7 @@ export type LocalizerKeys = | 'requestsPlaceholder' | 'requestsSubtitle' | 'resend' + | 'respondingToGroupRequestWarning' | 'respondingToRequestWarning' | 'restoreUsingRecoveryPhrase' | 'ringing' @@ -502,6 +505,7 @@ export type LocalizerKeys = | 'userAddedToModerators' | 'userBanFailed' | 'userBanned' + | 'userInvitedYouToGroup' | 'userRemovedFromModerators' | 'userUnbanFailed' | 'userUnbanned' diff --git a/ts/webworker/workers/browser/libsession_worker_interface.ts b/ts/webworker/workers/browser/libsession_worker_interface.ts index 77a5d9186..b2e9c8baf 100644 --- a/ts/webworker/workers/browser/libsession_worker_interface.ts +++ b/ts/webworker/workers/browser/libsession_worker_interface.ts @@ -16,13 +16,16 @@ import { Uint8ArrayLen100, Uint8ArrayLen64, UserConfigWrapperActionsCalls, + UserGroupsGet, UserGroupsSet, UserGroupsWrapperActionsCalls, } from 'libsession_util_nodejs'; // eslint-disable-next-line import/order import { join } from 'path'; +import { cloneDeep } from 'lodash'; import { getAppRootPath } from '../../../node/getRootPath'; +import { userGroupsActions } from '../../../state/ducks/userGroups'; import { WorkerInterface } from '../../worker_interface'; import { ConfigWrapperUser, LibSessionWorkerFunctions } from './libsession_worker_functions'; @@ -115,11 +118,11 @@ function createBaseActionsFor(wrapperType: ConfigWrapperUser) { GenericWrapperActions.confirmPushed(wrapperType, seqno, hash), dump: async () => GenericWrapperActions.dump(wrapperType), makeDump: async () => GenericWrapperActions.makeDump(wrapperType), - merge: async (toMerge: Array) => GenericWrapperActions.merge(wrapperType, toMerge), needsDump: async () => GenericWrapperActions.needsDump(wrapperType), needsPush: async () => GenericWrapperActions.needsPush(wrapperType), push: async () => GenericWrapperActions.push(wrapperType), currentHashes: async () => GenericWrapperActions.currentHashes(wrapperType), + merge: async (toMerge: Array) => GenericWrapperActions.merge(wrapperType, toMerge), }; } @@ -184,9 +187,24 @@ export const ContactsWrapperActions: ContactsWrapperActionsCalls = { >, }; +// this is a cache of the new groups only. Anytime we create, update, delete, or merge a group, we update this +const groups: Map = new Map(); + +function dispatchCachedGroupsToRedux() { + window?.inboxStore?.dispatch?.( + userGroupsActions.refreshUserGroupsSlice({ groups: [...groups.values()] }) + ); +} + export const UserGroupsWrapperActions: UserGroupsWrapperActionsCalls = { /* Reuse the GenericWrapperActions with the UserGroupsConfig argument */ ...createBaseActionsFor('UserGroupsConfig'), + // override the merge() as we need to refresh the cached groups + merge: async (toMerge: Array) => { + const mergeRet = await GenericWrapperActions.merge('UserGroupsConfig', toMerge); + await UserGroupsWrapperActions.getAllGroups(); // this refreshes the cached data after merge + return mergeRet; + }, /** UserGroups wrapper specific actions */ @@ -245,30 +263,61 @@ export const UserGroupsWrapperActions: UserGroupsWrapperActionsCalls = { ReturnType >, - createGroup: async () => - callLibSessionWorker(['UserGroupsConfig', 'createGroup']) as Promise< + createGroup: async () => { + const group = (await callLibSessionWorker(['UserGroupsConfig', 'createGroup'])) as Awaited< ReturnType - >, + >; + groups.set(group.pubkeyHex, group); + dispatchCachedGroupsToRedux(); + return cloneDeep(group); + }, - getGroup: async (pubkeyHex: GroupPubkeyType) => - callLibSessionWorker(['UserGroupsConfig', 'getGroup', pubkeyHex]) as Promise< - ReturnType - >, + getGroup: async (pubkeyHex: GroupPubkeyType) => { + const group = (await callLibSessionWorker([ + 'UserGroupsConfig', + 'getGroup', + pubkeyHex, + ])) as Awaited>; + if (group) { + groups.set(group.pubkeyHex, group); + } else { + groups.delete(pubkeyHex); + } + dispatchCachedGroupsToRedux(); + return cloneDeep(group); + }, - getAllGroups: async () => - callLibSessionWorker(['UserGroupsConfig', 'getAllGroups']) as Promise< - ReturnType - >, + getAllGroups: async () => { + const groupsFetched = (await callLibSessionWorker([ + 'UserGroupsConfig', + 'getAllGroups', + ])) as Awaited>; + groups.clear(); + groupsFetched.forEach(f => groups.set(f.pubkeyHex, f)); + dispatchCachedGroupsToRedux(); + return cloneDeep(groupsFetched); + }, - setGroup: async (info: UserGroupsSet) => - callLibSessionWorker(['UserGroupsConfig', 'setGroup', info]) as Promise< + setGroup: async (info: UserGroupsSet) => { + const group = (await callLibSessionWorker(['UserGroupsConfig', 'setGroup', info])) as Awaited< ReturnType - >, + >; + groups.set(group.pubkeyHex, group); + dispatchCachedGroupsToRedux(); + return cloneDeep(group); + }, - eraseGroup: async (pubkeyHex: GroupPubkeyType) => - callLibSessionWorker(['UserGroupsConfig', 'eraseGroup', pubkeyHex]) as Promise< - ReturnType - >, + eraseGroup: async (pubkeyHex: GroupPubkeyType) => { + const ret = (await callLibSessionWorker([ + 'UserGroupsConfig', + 'eraseGroup', + pubkeyHex, + ])) as Awaited>; + + groups.delete(pubkeyHex); + dispatchCachedGroupsToRedux(); + return ret; + }, }; export const ConvoInfoVolatileWrapperActions: ConvoInfoVolatileWrapperActionsCalls = { @@ -541,7 +590,7 @@ export const MetaGroupWrapperActions: MetaGroupWrapperActionsCalls = { 'swarmVerifySubAccount', signingValue, ]) as Promise>, - loadAdminKeys: async (groupPk: GroupPubkeyType, secret: Uint8ArrayLen64) => { + loadAdminKeys: async (groupPk: GroupPubkeyType, secret: Uint8ArrayLen64) => { return callLibSessionWorker([`MetaGroupConfig-${groupPk}`, 'loadAdminKeys', secret]) as Promise< ReturnType >;