diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 55e74ab10..c4df15896 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -486,6 +486,9 @@ "youHaveANewFriendRequest": "You have a new friend request", "clearAllConfirmationTitle": "Clear All Message Requests", "clearAllConfirmationBody": "Are you sure you want to clear all message requests?", + "noMessagesInReadOnly": "There are no messages in $name$.", + "noMessagesInNoteToSelf": "You have no messages in $name$.", + "noMessagesInEverythingElse": "You have no messages from $name$. Send a message to start the conversation!", "hideBanner": "Hide", "openMessageRequestInboxDescription": "View your Message Request inbox", "clearAllReactions": "Are you sure you want to clear all $emoji$ ?", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 0bbfcf659..99b690f35 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -331,3 +331,24 @@ message GroupContext { } + + +message WebSocketRequestMessage { + optional string verb = 1; + optional string path = 2; + optional bytes body = 3; + repeated string headers = 5; + optional uint64 id = 4; +} + + +message WebSocketMessage { + enum Type { + UNKNOWN = 0; + REQUEST = 1; + RESPONSE = 2; + } + + optional Type type = 1; + optional WebSocketRequestMessage request = 2; +} \ No newline at end of file diff --git a/protos/SubProtocol.proto b/protos/SubProtocol.proto deleted file mode 100644 index a788021e5..000000000 --- a/protos/SubProtocol.proto +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (C) 2014 Open WhisperSystems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package signalservice; - -option java_package = "org.whispersystems.websocket.messages.protobuf"; - -message WebSocketRequestMessage { - optional string verb = 1; - optional string path = 2; - optional bytes body = 3; - repeated string headers = 5; - optional uint64 id = 4; -} - - -message WebSocketMessage { - enum Type { - UNKNOWN = 0; - REQUEST = 1; - RESPONSE = 2; - } - - optional Type type = 1; - optional WebSocketRequestMessage request = 2; -} diff --git a/ts/components/SessionInboxView.tsx b/ts/components/SessionInboxView.tsx index f2b29b113..fef3c0cc0 100644 --- a/ts/components/SessionInboxView.tsx +++ b/ts/components/SessionInboxView.tsx @@ -1,12 +1,13 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Provider } from 'react-redux'; import { LeftPane } from './leftpane/LeftPane'; // tslint:disable-next-line: no-submodule-imports -import { PersistGate } from 'redux-persist/integration/react'; import { persistStore } from 'redux-persist'; +import { PersistGate } from 'redux-persist/integration/react'; import { getConversationController } from '../session/conversations'; import { UserUtils } from '../session/utils'; +import { createStore } from '../state/createStore'; import { initialCallState } from '../state/ducks/call'; import { getEmptyConversationState, @@ -15,18 +16,17 @@ import { import { initialDefaultRoomState } from '../state/ducks/defaultRooms'; import { initialModalState } from '../state/ducks/modalDialog'; import { initialOnionPathState } from '../state/ducks/onion'; +import { initialPrimaryColorState } from '../state/ducks/primaryColor'; import { initialSearchState } from '../state/ducks/search'; import { initialSectionState } from '../state/ducks/section'; import { getEmptyStagedAttachmentsState } from '../state/ducks/stagedAttachments'; import { initialThemeState } from '../state/ducks/theme'; -import { initialPrimaryColorState } from '../state/ducks/primaryColor'; import { TimerOptionsArray } from '../state/ducks/timerOptions'; import { initialUserConfigState } from '../state/ducks/userConfig'; import { StateType } from '../state/reducer'; import { makeLookup } from '../util'; -import { SessionMainPanel } from './SessionMainPanel'; -import { createStore } from '../state/createStore'; import { ExpirationTimerOptions } from '../util/expiringMessages'; +import { SessionMainPanel } from './SessionMainPanel'; // moment does not support es-419 correctly (and cause white screen on app start) import moment from 'moment'; @@ -41,91 +41,76 @@ moment.locale((window.i18n as any).getLocale()); // Workaround: A react component's required properties are filtering up through connect() // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 - -type State = { - isInitialLoadComplete: boolean; -}; +import useUpdate from 'react-use/lib/useUpdate'; const StyledGutter = styled.div` width: 380px !important; transition: none; `; -export class SessionInboxView extends React.Component { - private store: any; - - constructor(props: any) { - super(props); - this.state = { - isInitialLoadComplete: false, - }; - } - - public componentDidMount() { - this.setupLeftPane(); - } +function createSessionInboxStore() { + // Here we set up a full redux store with initial state for our LeftPane Root + const conversations = getConversationController() + .getConversations() + .map(conversation => conversation.getConversationModelProps()); + + const timerOptions: TimerOptionsArray = ExpirationTimerOptions.getTimerSecondsWithName(); + + const initialState: StateType = { + conversations: { + ...getEmptyConversationState(), + conversationLookup: makeLookup(conversations, 'id'), + }, + user: { + ourNumber: UserUtils.getOurPubKeyStrFromCache(), + }, + section: initialSectionState, + defaultRooms: initialDefaultRoomState, + search: initialSearchState, + theme: initialThemeState, + primaryColor: initialPrimaryColorState, + onionPaths: initialOnionPathState, + modals: initialModalState, + userConfig: initialUserConfigState, + timerOptions: { + timerOptions, + }, + stagedAttachments: getEmptyStagedAttachmentsState(), + call: initialCallState, + sogsRoomInfo: initialSogsRoomInfoState, + }; + + return createStore(initialState); +} - public render() { - if (!this.state.isInitialLoadComplete) { - return null; - } +function setupLeftPane(forceUpdateInboxComponent: () => void) { + window.openConversationWithMessages = openConversationWithMessages; + window.inboxStore = createSessionInboxStore(); + forceUpdateInboxComponent(); +} - const persistor = persistStore(this.store); - window.persistStore = persistor; +export const SessionInboxView = () => { + const update = useUpdate(); + // run only on mount + useEffect(() => setupLeftPane(update), []); - return ( - - - - {this.renderLeftPane()} - - - - - ); + if (!window.inboxStore) { + return null; } - private renderLeftPane() { - return ; - } - - private setupLeftPane() { - // Here we set up a full redux store with initial state for our LeftPane Root - const conversations = getConversationController() - .getConversations() - .map(conversation => conversation.getConversationModelProps()); - - const timerOptions: TimerOptionsArray = ExpirationTimerOptions.getTimerSecondsWithName(); - - const initialState: StateType = { - conversations: { - ...getEmptyConversationState(), - conversationLookup: makeLookup(conversations, 'id'), - }, - user: { - ourNumber: UserUtils.getOurPubKeyStrFromCache(), - }, - section: initialSectionState, - defaultRooms: initialDefaultRoomState, - search: initialSearchState, - theme: initialThemeState, - primaryColor: initialPrimaryColorState, - onionPaths: initialOnionPathState, - modals: initialModalState, - userConfig: initialUserConfigState, - timerOptions: { - timerOptions, - }, - stagedAttachments: getEmptyStagedAttachmentsState(), - call: initialCallState, - sogsRoomInfo: initialSogsRoomInfoState, - }; - - this.store = createStore(initialState); - window.inboxStore = this.store; - - window.openConversationWithMessages = openConversationWithMessages; - - this.setState({ isInitialLoadComplete: true }); - } -} + const persistor = persistStore(window.inboxStore); + window.persistStore = persistor; + + return ( + + + + + + + + + + + ); +}; diff --git a/ts/components/conversation/ConversationRequestButtons.tsx b/ts/components/conversation/ConversationRequestButtons.tsx index 911eb598b..3d6d05e5a 100644 --- a/ts/components/conversation/ConversationRequestButtons.tsx +++ b/ts/components/conversation/ConversationRequestButtons.tsx @@ -11,10 +11,6 @@ import { hasSelectedConversationIncomingMessages } from '../../state/selectors/c import { useSelectedConversationKey } from '../../state/selectors/selectedConversation'; import { SessionButton, SessionButtonColor } from '../basic/SessionButton'; -const handleDeclineConversationRequest = (convoId: string) => { - declineConversationWithConfirm(convoId, true); -}; - const handleAcceptConversationRequest = async (convoId: string) => { const convo = getConversationController().get(convoId); await convo.setDidApproveMe(true); @@ -69,7 +65,7 @@ export const ConversationMessageRequestButtons = () => { buttonColor={SessionButtonColor.Danger} text={window.i18n('decline')} onClick={() => { - handleDeclineConversationRequest(selectedConvoId); + declineConversationWithConfirm(selectedConvoId, true); }} dataTestId="decline-message-request" /> diff --git a/ts/components/conversation/ConversationRequestInfo.tsx b/ts/components/conversation/ConversationRequestInfo.tsx deleted file mode 100644 index 15972ac81..000000000 --- a/ts/components/conversation/ConversationRequestInfo.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import styled from 'styled-components'; -import { useIsRequest } from '../../hooks/useParamSelector'; -import { hasSelectedConversationIncomingMessages } from '../../state/selectors/conversations'; -import { useSelectedConversationKey } from '../../state/selectors/selectedConversation'; - -const ConversationRequestTextBottom = styled.div` - display: flex; - flex-direction: row; - justify-content: center; - padding: var(--margins-lg); - background-color: var(--background-secondary-color); -`; - -const ConversationRequestTextInner = styled.div` - color: var(--text-secondary-color); - text-align: center; - max-width: 390px; -`; - -export const ConversationRequestinfo = () => { - const selectedConversation = useSelectedConversationKey(); - const isIncomingMessageRequest = useIsRequest(selectedConversation); - - const showMsgRequestUI = selectedConversation && isIncomingMessageRequest; - const hasIncomingMessages = useSelector(hasSelectedConversationIncomingMessages); - - if (!showMsgRequestUI || !hasIncomingMessages) { - return null; - } - - return ( - - - {window.i18n('respondingToRequestWarning')} - - - ); -}; diff --git a/ts/components/conversation/SessionConversation.tsx b/ts/components/conversation/SessionConversation.tsx index c202e0c79..1e77c835e 100644 --- a/ts/components/conversation/SessionConversation.tsx +++ b/ts/components/conversation/SessionConversation.tsx @@ -51,7 +51,10 @@ import { import { blobToArrayBuffer } from 'blob-util'; import { MAX_ATTACHMENT_FILESIZE_BYTES } from '../../session/constants'; import { ConversationMessageRequestButtons } from './ConversationRequestButtons'; -import { ConversationRequestinfo } from './ConversationRequestInfo'; +import { + NoMessageNoMessageInConversation, + RespondToMessageRequestWarning, +} from './SubtleNotification'; import { getCurrentRecoveryPhrase } from '../../util/storage'; import loadImage from 'blueimp-load-image'; import { markAllReadByConvoId } from '../../interactions/conversationInteractions'; @@ -247,7 +250,7 @@ export class SessionConversation extends React.Component { // return an empty message view return ; } - + // TODOLATER break showMessageDetails & selectionMode into it's own container component so we can use hooks to fetch relevant state from the store const selectionMode = selectedMessages.length > 0; return ( @@ -272,6 +275,7 @@ export class SessionConversation extends React.Component { {lightBoxOptions?.media && this.renderLightBox(lightBoxOptions)} + } @@ -283,11 +287,10 @@ export class SessionConversation extends React.Component { } disableTop={!this.props.hasOngoingCallWithFocusedConvo} /> - {isDraggingFile && } + - { + const selectedConversation = useSelectedConversationKey(); + const isIncomingMessageRequest = useIsRequest(selectedConversation); + + const showMsgRequestUI = selectedConversation && isIncomingMessageRequest; + const hasIncomingMessages = useSelector(hasSelectedConversationIncomingMessages); + + if (!showMsgRequestUI || !hasIncomingMessages) { + return null; + } + + return ( + + {window.i18n('respondingToRequestWarning')} + + ); +}; + +/** + * This component is used to display a warning when the user is looking at an empty conversation. + */ +export const NoMessageNoMessageInConversation = () => { + const selectedConversation = useSelectedConversationKey(); + + const hasMessage = useSelector(getSelectedHasMessages); + + const isMe = useSelectedisNoteToSelf(); + const canWrite = useSelector(getSelectedCanWrite); + // TODOLATER use this selector accross the whole application (left pane excluded) + const nameToRender = useSelectedNicknameOrProfileNameOrShortenedPubkey(); + + if (!selectedConversation || hasMessage) { + return null; + } + let localizedKey: LocalizerKeys = 'noMessagesInEverythingElse'; + if (!canWrite) { + localizedKey = 'noMessagesInReadOnly'; + } else if (isMe) { + localizedKey = 'noMessagesInNoteToSelf'; + } + + return ( + + + + + + ); +}; diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index d2f2129dd..733776440 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -15,7 +15,11 @@ import { ConfigurationSync } from '../session/utils/job_runners/jobs/Configurati import { perfEnd, perfStart } from '../session/utils/Performance'; import { fromHexToArray, toHex } from '../session/utils/String'; import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils'; -import { quoteMessage, resetConversationExternal } from '../state/ducks/conversations'; +import { + conversationReset, + quoteMessage, + resetConversationExternal, +} from '../state/ducks/conversations'; import { adminLeaveClosedGroup, changeNickNameModal, @@ -134,9 +138,9 @@ export const declineConversationWithConfirm = (convoId: string, syncToDevices: b }; /** - * Sets the approval fields to false for conversation. Sends decline message. + * Sets the approval fields to false for conversation. Does not send anything back. */ -export const declineConversationWithoutConfirm = async ( +const declineConversationWithoutConfirm = async ( conversationId: string, syncToDevices: boolean = true ) => { @@ -284,6 +288,7 @@ export async function deleteAllMessagesByConvoIdNoConfirmation(conversationId: s }); await conversation.commit(); + window.inboxStore?.dispatch(conversationReset(conversationId)); } export function deleteAllMessagesByConvoIdWithConfirmation(conversationId: string) { diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 7fd073f49..152c5849f 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -220,9 +220,21 @@ export class ConversationModel extends Backbone.Model { public isMe() { return UserUtils.isUsFromCache(this.id); } + + /** + * Same as this.isOpenGroupV2(). + * + * // TODOLATER merge them together + */ public isPublic(): boolean { return this.isOpenGroupV2(); } + + /** + * Same as this.isPublic(). + * + * // TODOLATER merge them together + */ public isOpenGroupV2(): boolean { return OpenGroupUtils.isOpenGroupV2(this.id); } @@ -232,6 +244,7 @@ export class ConversationModel extends Backbone.Model { (this.get('type') === ConversationTypeEnum.GROUPV3 && this.id.startsWith('03')) ); } + public isPrivate() { return isDirectConversation(this.get('type')); } @@ -593,143 +606,6 @@ export class ConversationModel extends Backbone.Model { return getOpenGroupV2FromConversationId(this.id); } - public async sendMessageJob(message: MessageModel, expireTimer: number | undefined) { - try { - const { body, attachments, preview, quote, fileIdsToLink } = await message.uploadData(); - const { id } = message; - const destination = this.id; - - const sentAt = message.get('sent_at'); - if (!sentAt) { - throw new Error('sendMessageJob() sent_at must be set.'); - } - - if (this.isPublic() && !this.isOpenGroupV2()) { - throw new Error('Only opengroupv2 are supported now'); - } - - // we are trying to send a message to someone. If that convo is hidden in the list, make sure it is not - this.unhideIfNeeded(true); - - // an OpenGroupV2 message is just a visible message - const chatMessageParams: VisibleMessageParams = { - body, - identifier: id, - timestamp: sentAt, - attachments, - expireTimer, - preview: preview ? [preview] : [], - quote, - lokiProfile: UserUtils.getOurProfile(), - }; - - const shouldApprove = !this.isApproved() && this.isPrivate(); - const incomingMessageCount = await Data.getMessageCountByType( - this.id, - MessageDirection.incoming - ); - const hasIncomingMessages = incomingMessageCount > 0; - - if (this.id.startsWith('15')) { - window.log.info('Sending a blinded message to this user: ', this.id); - await this.sendBlindedMessageRequest(chatMessageParams); - return; - } - - if (shouldApprove) { - await this.setIsApproved(true); - if (hasIncomingMessages) { - // have to manually add approval for local client here as DB conditional approval check in config msg handling will prevent this from running - await this.addOutgoingApprovalMessage(Date.now()); - if (!this.didApproveMe()) { - await this.setDidApproveMe(true); - } - // should only send once - await this.sendMessageRequestResponse(); - void forceSyncConfigurationNowIfNeeded(); - } - } - - if (this.isOpenGroupV2()) { - const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams); - const roomInfos = this.toOpenGroupV2(); - if (!roomInfos) { - throw new Error('Could not find this room in db'); - } - const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id); - // send with blinding if we need to - await getMessageQueue().sendToOpenGroupV2({ - message: chatMessageOpenGroupV2, - roomInfos, - blinded: Boolean(roomHasBlindEnabled(openGroup)), - filesToLink: fileIdsToLink, - }); - return; - } - - const destinationPubkey = new PubKey(destination); - - if (this.isPrivate()) { - if (this.isMe()) { - chatMessageParams.syncTarget = this.id; - const chatMessageMe = new VisibleMessage(chatMessageParams); - - await getMessageQueue().sendSyncMessage({ - namespace: SnodeNamespaces.UserMessages, - message: chatMessageMe, - }); - return; - } - - if (message.get('groupInvitation')) { - const groupInvitation = message.get('groupInvitation'); - const groupInvitMessage = new GroupInvitationMessage({ - identifier: id, - timestamp: sentAt, - name: groupInvitation.name, - url: groupInvitation.url, - expireTimer: this.get('expireTimer'), - }); - // we need the return await so that errors are caught in the catch {} - await getMessageQueue().sendToPubKey( - destinationPubkey, - groupInvitMessage, - SnodeNamespaces.UserMessages - ); - return; - } - const chatMessagePrivate = new VisibleMessage(chatMessageParams); - - await getMessageQueue().sendToPubKey( - destinationPubkey, - chatMessagePrivate, - SnodeNamespaces.UserMessages - ); - return; - } - - if (this.isClosedGroup()) { - const chatMessageMediumGroup = new VisibleMessage(chatMessageParams); - const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({ - chatMessage: chatMessageMediumGroup, - groupId: destination, - }); - - // we need the return await so that errors are caught in the catch {} - await getMessageQueue().sendToGroup({ - message: closedGroupVisibleMessage, - namespace: SnodeNamespaces.ClosedGroupMessage, - }); - return; - } - - throw new TypeError(`Invalid conversation type: '${this.get('type')}'`); - } catch (e) { - await message.saveErrors(e); - return null; - } - } - public async sendReactionJob(sourceMessage: MessageModel, reaction: Reaction) { try { const destination = this.id; @@ -739,10 +615,6 @@ export class ConversationModel extends Backbone.Model { throw new Error('sendReactMessageJob() sent_at must be set.'); } - if (this.isPublic() && !this.isOpenGroupV2()) { - throw new Error('Only opengroupv2 are supported now'); - } - // an OpenGroupV2 message is just a visible message const chatMessageParams: VisibleMessageParams = { body: '', @@ -910,67 +782,6 @@ export class ConversationModel extends Backbone.Model { this.updateLastMessage(); } - public async sendBlindedMessageRequest(messageParams: VisibleMessageParams) { - const ourSignKeyBytes = await UserUtils.getUserED25519KeyPairBytes(); - const groupUrl = this.getSogsOriginMessage(); - - if (!PubKey.hasBlindedPrefix(this.id)) { - window?.log?.warn('sendBlindedMessageRequest - convo is not a blinded one'); - return; - } - - if (!messageParams.body) { - window?.log?.warn('sendBlindedMessageRequest - needs a body'); - return; - } - - // include our profile (displayName + avatar url + key for the recipient) - messageParams.lokiProfile = getOurProfile(); - - if (!ourSignKeyBytes || !groupUrl) { - window?.log?.error( - 'sendBlindedMessageRequest - Cannot get required information for encrypting blinded message.' - ); - return; - } - - const roomInfo = OpenGroupData.getV2OpenGroupRoom(groupUrl); - - if (!roomInfo || !roomInfo.serverPublicKey) { - ToastUtils.pushToastError('no-sogs-matching', window.i18n('couldntFindServerMatching')); - window?.log?.error('Could not find room with matching server url', groupUrl); - throw new Error(`Could not find room with matching server url: ${groupUrl}`); - } - - const sogsVisibleMessage = new OpenGroupVisibleMessage(messageParams); - const paddedBody = addMessagePadding(sogsVisibleMessage.plainTextBuffer()); - - const serverPubKey = roomInfo.serverPublicKey; - - const encryptedMsg = await SogsBlinding.encryptBlindedMessage({ - rawData: paddedBody, - senderSigningKey: ourSignKeyBytes, - serverPubKey: from_hex(serverPubKey), - recipientBlindedPublicKey: from_hex(this.id.slice(2)), - }); - - if (!encryptedMsg) { - throw new Error('encryptBlindedMessage failed'); - } - if (!messageParams.identifier) { - throw new Error('encryptBlindedMessage messageParams needs an identifier'); - } - - this.set({ active_at: Date.now(), isApproved: true }); - - await getMessageQueue().sendToOpenGroupV2BlindedRequest({ - encryptedContent: encryptedMsg, - roomInfos: roomInfo, - message: sogsVisibleMessage, - recipientBlindedId: this.id, - }); - } - /** * Sends an accepted message request response. * Currently, we never send anything for denied message requests. @@ -1207,14 +1018,20 @@ export class ConversationModel extends Backbone.Model { } } } + + // TODOLATER we should maybe mark things here as read, but we need a readAt timestamp, and I am not too sure what we should use (considering that disappearing messages needs a real readAt) + // const sentAt = messageAttributes.sent_at || messageAttributes.serverTimestamp; + // if (sentAt) { + // await this.markConversationRead(sentAt); + // } return this.addSingleMessage({ ...messageAttributes, conversationId: this.id, source: sender, type: 'outgoing', direction: 'outgoing', - unread: 0, // an outgoing message must be read right? - received_at: messageAttributes.sent_at, // make sure to set an received_at timestamp for an outgoing message, so the order are right. + unread: 0, // an outgoing message must be already read + received_at: messageAttributes.sent_at, // make sure to set a received_at timestamp for an outgoing message, so the order are right. }); } @@ -1521,9 +1338,17 @@ export class ConversationModel extends Backbone.Model { return !!this.get('markedAsUnread'); } + /** + * Mark a private conversation as approved to the specified value. + * Does not do anything on non private chats. + */ public async setIsApproved(value: boolean, shouldCommit: boolean = true) { const valueForced = Boolean(value); + if (!this.isPrivate()) { + return; + } + if (valueForced !== Boolean(this.isApproved())) { window?.log?.info(`Setting ${ed25519Str(this.id)} isApproved to: ${value}`); this.set({ @@ -1536,7 +1361,14 @@ export class ConversationModel extends Backbone.Model { } } + /** + * Mark a private conversation as approved_me to the specified value + * Does not do anything on non private chats. + */ public async setDidApproveMe(value: boolean, shouldCommit: boolean = true) { + if (!this.isPrivate()) { + return; + } const valueForced = Boolean(value); if (valueForced !== Boolean(this.didApproveMe())) { window?.log?.info(`Setting ${ed25519Str(this.id)} didApproveMe to: ${value}`); @@ -1561,8 +1393,19 @@ export class ConversationModel extends Backbone.Model { } /** - * Saves the infos of that room directly on the conversation table. - * This does not write anything to the db if no changes are detected + * Save the pollInfo to the Database or to the in memory redux slice depending on the data. + * things stored to the redux slice of the sogs (ReduxSogsRoomInfos) are: + * - subscriberCount + * - canWrite + * - moderators + * + * things stored in the database are + * - admins (as they are also stored for groups we just reuse the same field, saved in the DB for now) + * - display name of that room + * + * This function also triggers the download of the new avatar if needed. + * + * Does not do anything for non public chats. */ // tslint:disable-next-line: cyclomatic-complexity public async setPollInfo(infos?: { @@ -1579,10 +1422,12 @@ export class ConversationModel extends Backbone.Model { hidden_moderators?: Array; }; }) { + if (!this.isPublic()) { + return; + } if (!infos || isEmpty(infos)) { return; } - let hasChange = false; const { write, active_users, details } = infos; if ( @@ -1597,14 +1442,12 @@ export class ConversationModel extends Backbone.Model { ReduxSogsRoomInfos.setCanWriteOutsideRedux(this.id, !!write); } - const adminChanged = await this.handleSogsModsOrAdminsChanges({ + let hasChange = await this.handleSogsModsOrAdminsChanges({ modsOrAdmins: details.admins, hiddenModsOrAdmins: details.hidden_admins, type: 'admins', }); - hasChange = hasChange || adminChanged; - const modsChanged = await this.handleSogsModsOrAdminsChanges({ modsOrAdmins: details.moderators, hiddenModsOrAdmins: details.hidden_moderators, @@ -1618,7 +1461,7 @@ export class ConversationModel extends Backbone.Model { hasChange = hasChange || modsChanged; - if (this.isOpenGroupV2() && details.image_id && isNumber(details.image_id)) { + if (this.isPublic() && details.image_id && isNumber(details.image_id)) { const roomInfos = OpenGroupData.getV2OpenGroupRoom(this.id); if (roomInfos) { void sogsV3FetchPreviewAndSaveIt({ ...roomInfos, imageID: `${details.image_id}` }); @@ -1896,6 +1739,200 @@ export class ConversationModel extends Backbone.Model { return this.markConversationReadBouncy(newestUnreadDate); } + private async sendMessageJob(message: MessageModel, expireTimer: number | undefined) { + try { + const { body, attachments, preview, quote, fileIdsToLink } = await message.uploadData(); + const { id } = message; + const destination = this.id; + + const sentAt = message.get('sent_at'); + if (!sentAt) { + throw new Error('sendMessageJob() sent_at must be set.'); + } + + // we are trying to send a message to someone. Make sure this convo is not hidden + this.unhideIfNeeded(true); + + // an OpenGroupV2 message is just a visible message + const chatMessageParams: VisibleMessageParams = { + body, + identifier: id, + timestamp: sentAt, + attachments, + expireTimer, + preview: preview ? [preview] : [], + quote, + lokiProfile: UserUtils.getOurProfile(), + }; + + const shouldApprove = !this.isApproved() && this.isPrivate(); + const incomingMessageCount = await Data.getMessageCountByType( + this.id, + MessageDirection.incoming + ); + const hasIncomingMessages = incomingMessageCount > 0; + + if (this.id.startsWith('15')) { + window.log.info('Sending a blinded message to this user: ', this.id); + await this.sendBlindedMessageRequest(chatMessageParams); + return; + } + + if (shouldApprove) { + await this.setIsApproved(true); + if (hasIncomingMessages) { + // have to manually add approval for local client here as DB conditional approval check in config msg handling will prevent this from running + await this.addOutgoingApprovalMessage(Date.now()); + if (!this.didApproveMe()) { + await this.setDidApproveMe(true); + } + // should only send once + await this.sendMessageRequestResponse(); + void forceSyncConfigurationNowIfNeeded(); + } + } + + if (this.isOpenGroupV2()) { + const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams); + const roomInfos = this.toOpenGroupV2(); + if (!roomInfos) { + throw new Error('Could not find this room in db'); + } + const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id); + // send with blinding if we need to + await getMessageQueue().sendToOpenGroupV2({ + message: chatMessageOpenGroupV2, + roomInfos, + blinded: Boolean(roomHasBlindEnabled(openGroup)), + filesToLink: fileIdsToLink, + }); + return; + } + + const destinationPubkey = new PubKey(destination); + + if (this.isPrivate()) { + if (this.isMe()) { + chatMessageParams.syncTarget = this.id; + const chatMessageMe = new VisibleMessage(chatMessageParams); + + await getMessageQueue().sendSyncMessage({ + namespace: SnodeNamespaces.UserMessages, + message: chatMessageMe, + }); + return; + } + + if (message.get('groupInvitation')) { + const groupInvitation = message.get('groupInvitation'); + const groupInvitMessage = new GroupInvitationMessage({ + identifier: id, + timestamp: sentAt, + name: groupInvitation.name, + url: groupInvitation.url, + expireTimer: this.get('expireTimer'), + }); + // we need the return await so that errors are caught in the catch {} + await getMessageQueue().sendToPubKey( + destinationPubkey, + groupInvitMessage, + SnodeNamespaces.UserMessages + ); + return; + } + const chatMessagePrivate = new VisibleMessage(chatMessageParams); + + await getMessageQueue().sendToPubKey( + destinationPubkey, + chatMessagePrivate, + SnodeNamespaces.UserMessages + ); + return; + } + + if (this.isClosedGroup()) { + const chatMessageMediumGroup = new VisibleMessage(chatMessageParams); + const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({ + chatMessage: chatMessageMediumGroup, + groupId: destination, + }); + + // we need the return await so that errors are caught in the catch {} + await getMessageQueue().sendToGroup({ + message: closedGroupVisibleMessage, + namespace: SnodeNamespaces.ClosedGroupMessage, + }); + return; + } + + throw new TypeError(`Invalid conversation type: '${this.get('type')}'`); + } catch (e) { + await message.saveErrors(e); + return null; + } + } + + private async sendBlindedMessageRequest(messageParams: VisibleMessageParams) { + const ourSignKeyBytes = await UserUtils.getUserED25519KeyPairBytes(); + const groupUrl = this.getSogsOriginMessage(); + + if (!PubKey.hasBlindedPrefix(this.id)) { + window?.log?.warn('sendBlindedMessageRequest - convo is not a blinded one'); + return; + } + + if (!messageParams.body) { + window?.log?.warn('sendBlindedMessageRequest - needs a body'); + return; + } + + // include our profile (displayName + avatar url + key for the recipient) + messageParams.lokiProfile = getOurProfile(); + + if (!ourSignKeyBytes || !groupUrl) { + window?.log?.error( + 'sendBlindedMessageRequest - Cannot get required information for encrypting blinded message.' + ); + return; + } + + const roomInfo = OpenGroupData.getV2OpenGroupRoom(groupUrl); + + if (!roomInfo || !roomInfo.serverPublicKey) { + ToastUtils.pushToastError('no-sogs-matching', window.i18n('couldntFindServerMatching')); + window?.log?.error('Could not find room with matching server url', groupUrl); + throw new Error(`Could not find room with matching server url: ${groupUrl}`); + } + + const sogsVisibleMessage = new OpenGroupVisibleMessage(messageParams); + const paddedBody = addMessagePadding(sogsVisibleMessage.plainTextBuffer()); + + const serverPubKey = roomInfo.serverPublicKey; + + const encryptedMsg = await SogsBlinding.encryptBlindedMessage({ + rawData: paddedBody, + senderSigningKey: ourSignKeyBytes, + serverPubKey: from_hex(serverPubKey), + recipientBlindedPublicKey: from_hex(this.id.slice(2)), + }); + + if (!encryptedMsg) { + throw new Error('encryptBlindedMessage failed'); + } + if (!messageParams.identifier) { + throw new Error('encryptBlindedMessage messageParams needs an identifier'); + } + + this.set({ active_at: Date.now(), isApproved: true }); + + await getMessageQueue().sendToOpenGroupV2BlindedRequest({ + encryptedContent: encryptedMsg, + roomInfos: roomInfo, + message: sogsVisibleMessage, + recipientBlindedId: this.id, + }); + } + private async bouncyUpdateLastMessage() { if (!this.id || !this.get('active_at') || this.isHidden()) { return; diff --git a/ts/models/message.ts b/ts/models/message.ts index 3ed767d77..8d34b65f8 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -789,9 +789,6 @@ export class MessageModel extends Backbone.Model { // we want to go for the v1, if this is an OpenGroupV1 or not an open group at all if (conversation?.isPublic()) { - if (!conversation?.isOpenGroupV2()) { - throw new Error('Only opengroupv2 are supported now'); - } const openGroupV2 = conversation.toOpenGroupV2(); attachmentPromise = uploadAttachmentsV3(finalAttachments, openGroupV2); linkPreviewPromise = uploadLinkPreviewsV3(firstPreviewWithData, openGroupV2); diff --git a/ts/session/apis/open_group_api/opengroupV2/ApiUtil.ts b/ts/session/apis/open_group_api/opengroupV2/ApiUtil.ts index 2a9d5be9b..1be6d1227 100644 --- a/ts/session/apis/open_group_api/opengroupV2/ApiUtil.ts +++ b/ts/session/apis/open_group_api/opengroupV2/ApiUtil.ts @@ -38,9 +38,9 @@ export type OpenGroupV2InfoJoinable = OpenGroupV2Info & { // tslint:disable: no-http-string -const legacyDefaultServerIP = '116.203.70.33'; -export const defaultServer = 'https://open.getsession.org'; -const defaultServerHost = new window.URL(defaultServer).host; +const ourSogsLegacyIp = '116.203.70.33'; +const ourSogsDomainName = 'open.getsession.org'; +const ourSogsUrl = `https://${ourSogsDomainName}`; /** * This function returns true if the server url given matches any of the sogs run by Session. @@ -66,7 +66,7 @@ export function isSessionRunOpenGroup(server: string): boolean { serverHost = lowerCased; } - const options = [legacyDefaultServerIP, defaultServerHost]; + const options = [ourSogsLegacyIp, ourSogsDomainName]; return options.includes(serverHost); } @@ -110,12 +110,12 @@ export function hasExistingOpenGroup(server: string, roomId: string) { // If the server is run by Session then include all configurations in case one of the alternate configurations is used if (isSessionRunOpenGroup(serverLowerCase)) { - serverOptions.add(defaultServerHost); - serverOptions.add(`http://${defaultServerHost}`); - serverOptions.add(`https://${defaultServerHost}`); - serverOptions.add(legacyDefaultServerIP); - serverOptions.add(`http://${legacyDefaultServerIP}`); - serverOptions.add(`https://${legacyDefaultServerIP}`); + serverOptions.add(ourSogsDomainName); + serverOptions.add(`http://${ourSogsDomainName}`); + serverOptions.add(`https://${ourSogsDomainName}`); + serverOptions.add(ourSogsLegacyIp); + serverOptions.add(`http://${ourSogsLegacyIp}`); + serverOptions.add(`https://${ourSogsLegacyIp}`); } const rooms = flatten( @@ -139,7 +139,7 @@ export function hasExistingOpenGroup(server: string, roomId: string) { } const defaultServerPublicKey = 'a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238'; -const defaultRoom = `${defaultServer}/main?public_key=${defaultServerPublicKey}`; +const defaultRoom = `${ourSogsUrl}/main?public_key=${defaultServerPublicKey}`; // we want the https for our sogs, so we can avoid duplicates with http const loadDefaultRoomsSingle = (): Promise> => allowOnlyOneAtATime('loadDefaultRoomsSingle', async () => { diff --git a/ts/session/apis/open_group_api/opengroupV2/JoinOpenGroupV2.ts b/ts/session/apis/open_group_api/opengroupV2/JoinOpenGroupV2.ts index 7fe71ce71..a1d12453e 100644 --- a/ts/session/apis/open_group_api/opengroupV2/JoinOpenGroupV2.ts +++ b/ts/session/apis/open_group_api/opengroupV2/JoinOpenGroupV2.ts @@ -62,7 +62,7 @@ async function joinOpenGroupV2( room: OpenGroupV2Room, fromConfigMessage: boolean ): Promise { - if (!room.serverUrl || !room.roomId || room.roomId.length < 2 || !room.serverPublicKey) { + if (!room.serverUrl || !room.roomId || room.roomId.length < 1 || !room.serverPublicKey) { return undefined; } diff --git a/ts/session/apis/open_group_api/opengroupV2/OpenGroupManagerV2.ts b/ts/session/apis/open_group_api/opengroupV2/OpenGroupManagerV2.ts index 0027d3cda..648cf12ad 100644 --- a/ts/session/apis/open_group_api/opengroupV2/OpenGroupManagerV2.ts +++ b/ts/session/apis/open_group_api/opengroupV2/OpenGroupManagerV2.ts @@ -51,6 +51,8 @@ export class OpenGroupManagerV2 { roomId: string, publicKey: string ): Promise { + // TODOLATER we should rewrite serverUrl when it matches our sogs (by ip, domain name with http or https or nothing) + // we should also make sure that whoever calls this function, uses the overriden serverUrl const oneAtaTimeStr = `oneAtaTimeOpenGroupV2Join:${serverUrl}${roomId}`; return allowOnlyOneAtATime(oneAtaTimeStr, async () => { return this.attemptConnectionV2(serverUrl, roomId, publicKey); @@ -177,10 +179,10 @@ export class OpenGroupManagerV2 { roomId, serverPublicKey ); - // here, the convo does not exist. Make sure the db is clean too + // here, the convo does not exist. Make sure the db & wrappers are clean too await OpenGroupData.removeV2OpenGroupRoom(conversationId); - await SessionUtilUserGroups.removeCommunityFromWrapper(conversationId, fullUrl); + const room: OpenGroupV2Room = { serverUrl, roomId, diff --git a/ts/session/apis/open_group_api/utils/OpenGroupUtils.ts b/ts/session/apis/open_group_api/utils/OpenGroupUtils.ts index 5c110fe12..9fecfeea0 100644 --- a/ts/session/apis/open_group_api/utils/OpenGroupUtils.ts +++ b/ts/session/apis/open_group_api/utils/OpenGroupUtils.ts @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import _, { isEmpty } from 'lodash'; import { OpenGroupV2Room } from '../../../../data/opengroups'; import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil'; @@ -33,7 +33,7 @@ export const openGroupV2CompleteURLRegex = new RegExp( * This is the prefix used to identify our open groups in the conversation database (v1 or v2) */ // tslint:disable-next-line: no-http-string -export const openGroupPrefix = 'http'; // can be http:// or https:// +const openGroupPrefix = 'http'; // can be http:// or https:// /** * This function returns a full url on an open group v2 room used for sync messages for instance. @@ -42,9 +42,9 @@ export const openGroupPrefix = 'http'; // can be http:// or https:// */ export function getCompleteUrlFromRoom(roomInfos: OpenGroupV2Room) { if ( - _.isEmpty(roomInfos.serverUrl) || - _.isEmpty(roomInfos.roomId) || - _.isEmpty(roomInfos.serverPublicKey) + isEmpty(roomInfos.serverUrl) || + isEmpty(roomInfos.roomId) || + isEmpty(roomInfos.serverPublicKey) ) { throw new Error('getCompleteUrlFromRoom needs serverPublicKey, roomid and serverUrl to be set'); } @@ -71,6 +71,7 @@ export function prefixify(server: string): string { * @returns `${openGroupPrefix}${roomId}@${serverUrl}` */ export function getOpenGroupV2ConversationId(serverUrl: string, roomId: string) { + // TODOLATER we should probably make this force the serverURL to be our sogs with https when it matches pubkey or domain name if (!roomId.match(`^${roomIdV2Regex}$`)) { throw new Error('getOpenGroupV2ConversationId: Invalid roomId'); } diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index 88be9c6af..c96e1ae4e 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -297,8 +297,8 @@ export class ConversationController { /** * * @returns the reference of the list of conversations stored. - * Warning: You should not not edit things directly from that list. This must only be used for reading things. - * If you need to make change, do the usual getConversationControler().get('the id you want to edit') + * Warning: You should not edit things directly from that list. This must only be used for reading things. + * If you need to make a change, do the usual getConversationControler().get('the id you want to edit') */ public getConversations(): Array { return this.conversations.models; diff --git a/ts/session/group/open-group.ts b/ts/session/group/open-group.ts index 8e69e5aa8..d89558808 100644 --- a/ts/session/group/open-group.ts +++ b/ts/session/group/open-group.ts @@ -21,8 +21,8 @@ export async function initiateOpenGroupUpdate( // For now, the UI is actually not allowing changing the room name so we do not care. const convo = getConversationController().get(groupId); - if (!convo || !convo.isPublic() || !convo.isOpenGroupV2()) { - throw new Error('Only opengroupv2 are supported'); + if (!convo?.isPublic()) { + throw new Error('initiateOpenGroupUpdate can only be used for communities'); } if (avatar && avatar.objectUrl) { const blobAvatarAlreadyScaled = await urlToBlob(avatar.objectUrl); diff --git a/ts/session/utils/job_runners/jobs/ConfigurationSyncJob.ts b/ts/session/utils/job_runners/jobs/ConfigurationSyncJob.ts index d1391c5ea..15dc60231 100644 --- a/ts/session/utils/job_runners/jobs/ConfigurationSyncJob.ts +++ b/ts/session/utils/job_runners/jobs/ConfigurationSyncJob.ts @@ -151,6 +151,8 @@ class ConfigurationSyncJob extends PersistedJob } public async run(): Promise { + const start = Date.now(); + try { if (!window.sessionFeatureFlags.useSharedUtilForUserConfig) { this.triggerConfSyncJobDone(); @@ -237,6 +239,8 @@ class ConfigurationSyncJob extends PersistedJob } catch (e) { throw e; } finally { + window.log.debug(`ConfigurationSyncJob run() took ${Date.now() - start}ms`); + // this is a simple way to make sure whatever happens here, we update the lastest timestamp. // (a finally statement is always executed (no matter if exception or returns in other try/catch block) this.updateLastTickTimestamp(); diff --git a/ts/session/utils/libsession/libsession_utils.ts b/ts/session/utils/libsession/libsession_utils.ts index ee529d02c..f648a505b 100644 --- a/ts/session/utils/libsession/libsession_utils.ts +++ b/ts/session/utils/libsession/libsession_utils.ts @@ -43,9 +43,9 @@ async function initializeLibSessionUtilWrappers() { // fetch the dumps we already have from the database const dumps = await ConfigDumpData.getAllDumpsWithData(); - console.warn( + window.log.info( 'initializeLibSessionUtilWrappers alldumpsInDB already: ', - dumps.map(m => omit(m, 'data')) + JSON.stringify(dumps.map(m => omit(m, 'data'))) ); const userVariantsBuildWithoutErrors = new Set(); @@ -53,7 +53,7 @@ async function initializeLibSessionUtilWrappers() { // load the dumps retrieved from the database into their corresponding wrappers for (let index = 0; index < dumps.length; index++) { const dump = dumps[index]; - console.warn('initializeLibSessionUtilWrappers initing from dump', dump.variant); + window.log.debug('initializeLibSessionUtilWrappers initing from dump', dump.variant); try { await GenericWrapperActions.init( dump.variant, diff --git a/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts b/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts index 65e050823..9ca58193e 100644 --- a/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts +++ b/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts @@ -79,12 +79,13 @@ function getConvoType(convo: ConversationModel): ConvoVolatileType { } /** - * Fetches the specified convo and updates the required field in the wrapper. + * Updates the required field in the wrapper from the data from the `ConversationController` * If that community does not exist in the wrapper, it is created before being updated. * Same applies for a legacy group. */ async function insertConvoFromDBIntoWrapperAndRefresh(convoId: string): Promise { - const foundConvo = await Data.getConversationById(convoId); + // this is too slow to fetch from the database the up to date data here. Let's hope that what we have in memory is up to date enough + const foundConvo = getConversationController().get(convoId); if (!foundConvo || !isConvoToStoreInWrapper(foundConvo)) { return; } diff --git a/ts/session/utils/sync/syncUtils.ts b/ts/session/utils/sync/syncUtils.ts index ba58d3e9c..8318886b4 100644 --- a/ts/session/utils/sync/syncUtils.ts +++ b/ts/session/utils/sync/syncUtils.ts @@ -96,6 +96,8 @@ export const forceSyncConfigurationNowIfNeeded = async (waitForMessageSent = fal window.Whisper.events.once(ConfigurationSyncJobDone, () => { resolve(true); }); + } else { + resolve(true); } return; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 364261ebd..fe804e8e1 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -11,6 +11,7 @@ import { ReplyingToMessageProps } from '../../components/conversation/compositio import { QuotedAttachmentType } from '../../components/conversation/message/message-content/Quote'; import { LightBoxOptions } from '../../components/conversation/SessionConversation'; import { + CONVERSATION_PRIORITIES, ConversationNotificationSettingType, ConversationTypeEnum, } from '../../models/conversationAttributes'; @@ -972,9 +973,22 @@ function applyConversationChanged( return state; } + let selected = selectedConversation; + if ( + data && + data.isPrivate && + data.id === selectedConversation && + data.priority && + data.priority < CONVERSATION_PRIORITIES.default + ) { + // A private conversation hidden cannot be a selected. + // When opening a hidden conversation, we unhide it so it can be selected again. + selected = undefined; + } + return { ...state, - selectedConversation, + selectedConversation: selected, conversationLookup: { ...conversationLookup, [id]: { ...data, isInitialFetchingInProgress: existing.isInitialFetchingInProgress }, @@ -982,7 +996,6 @@ function applyConversationChanged( }; } -// destructures export const { actions, reducer } = conversationsSlice; export const { // conversation and messages list @@ -1020,7 +1033,7 @@ async function unmarkAsForcedUnread(convoId: string) { const convo = getConversationController().get(convoId); if (convo && convo.isMarkedUnread()) { // we just opened it and it was forced "Unread", so we reset the unread state here - await convo.markAsUnread(false); + await convo.markAsUnread(false, true); } } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 9f0f779a2..e4f1f4411 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -26,7 +26,11 @@ import { MessageTextSelectorProps } from '../../components/conversation/message/ import { GenericReadableMessageSelectorProps } from '../../components/conversation/message/message-item/GenericReadableMessage'; import { LightBoxOptions } from '../../components/conversation/SessionConversation'; import { ConversationModel } from '../../models/conversation'; -import { ConversationTypeEnum, isOpenOrClosedGroup } from '../../models/conversationAttributes'; +import { + CONVERSATION_PRIORITIES, + ConversationTypeEnum, + isOpenOrClosedGroup, +} from '../../models/conversationAttributes'; import { getConversationController } from '../../session/conversations'; import { UserUtils } from '../../session/utils'; import { LocalizerType } from '../../types/Util'; @@ -34,7 +38,7 @@ import { BlockedNumberController } from '../../util'; import { Storage } from '../../util/storage'; import { getIntl } from './user'; -import { filter, isEmpty, pick, sortBy } from 'lodash'; +import { filter, isEmpty, isNumber, pick, sortBy } from 'lodash'; import { MessageReactsSelectorProps } from '../../components/conversation/message/message-content/MessageReactions'; import { getModeratorsOutsideRedux } from './sogsRoomInfo'; import { getSelectedConversation, getSelectedConversationKey } from './selectedConversation'; @@ -88,10 +92,7 @@ export const getSortedMessagesOfSelectedConversation = createSelector( export const hasSelectedConversationIncomingMessages = createSelector( getSortedMessagesOfSelectedConversation, (messages: Array): boolean => { - if (messages.length === 0) { - return false; - } - return Boolean(messages.filter(m => m.propsForMessage.direction === 'incoming').length); + return messages.some(m => m.propsForMessage.direction === 'incoming'); } ); @@ -260,7 +261,6 @@ export const _getConversationComparator = (testingi18n?: LocalizerType) => { export const getConversationComparator = createSelector(getIntl, _getConversationComparator); -// export only because we use it in some of our tests // tslint:disable-next-line: cyclomatic-complexity const _getLeftPaneLists = ( sortedConversations: Array @@ -274,28 +274,34 @@ const _getLeftPaneLists = ( let globalUnreadCount = 0; for (const conversation of sortedConversations) { + // Blocked conversation are now only visible from the settings, not in the conversation list, so don't add it neither to the contacts list nor the conversation list + if (conversation.isBlocked) { + continue; + } + // a contact is a private conversation that is approved by us and active if ( conversation.activeAt !== undefined && conversation.type === ConversationTypeEnum.PRIVATE && - conversation.isApproved && - !conversation.isBlocked && - (conversation.priority || 0) >= 0 // filtering non-hidden conversation + conversation.isApproved + // we want to keep the hidden conversation in the direct contact list, so we don't filter based on priority ) { directConversations.push(conversation); } - if (!conversation.isApproved && conversation.isPrivate) { + if ( + (conversation.isPrivate && !conversation.isApproved) || + (conversation.isPrivate && + conversation.priority && + conversation.priority <= CONVERSATION_PRIORITIES.default) // a hidden contact conversation is only visible from the contact list, not from the global conversation list + ) { // dont increase unread counter, don't push to convo list. continue; } - if (conversation.isBlocked) { - continue; - } - if ( globalUnreadCount < 100 && - conversation.unreadCount && + isNumber(conversation.unreadCount) && + isFinite(conversation.unreadCount) && conversation.unreadCount > 0 && conversation.currentNotificationSetting !== 'disabled' ) { @@ -322,30 +328,21 @@ export const _getSortedConversations = ( const sortedConversations: Array = []; - for (let conversation of sorted) { - if (selectedConversation === conversation.id) { - conversation = { - ...conversation, - isSelected: true, - }; - } - - const isBlocked = BlockedNumberController.isBlocked(conversation.id); - - if (isBlocked) { - conversation = { - ...conversation, - isBlocked: true, - }; - } - + for (const conversation of sorted) { // Remove all invalid conversations and conversatons of devices associated // with cancelled attempted links if (!conversation.isPublic && !conversation.activeAt) { continue; } - sortedConversations.push(conversation); + const isBlocked = BlockedNumberController.isBlocked(conversation.id); + const isSelected = selectedConversation === conversation.id; + + sortedConversations.push({ + ...conversation, + isSelected: isSelected || undefined, + isBlocked: isBlocked || undefined, + }); } return sortedConversations; @@ -634,12 +631,17 @@ export const getYoungestMessageId = createSelector( } ); -export const getLoadedMessagesLength = createSelector( - getConversations, - (state: ConversationsStateType): number => { - return state.messages.length || 0; - } -); +function getMessagesFromState(state: StateType) { + return state.conversations.messages; +} + +export function getLoadedMessagesLength(state: StateType) { + return getMessagesFromState(state).length; +} + +export function getSelectedHasMessages(state: StateType): boolean { + return !isEmpty(getMessagesFromState(state)); +} export const isFirstUnreadMessageIdAbove = createSelector( getConversations, @@ -1021,6 +1023,8 @@ export const getOldTopMessageId = createSelector( (state: ConversationsStateType): string | null => state.oldTopMessageId || null ); +// TODOLATER get rid of all the unneeded createSelector calls + export const getOldBottomMessageId = createSelector( getConversations, (state: ConversationsStateType): string | null => state.oldBottomMessageId || null diff --git a/ts/state/selectors/selectedConversation.ts b/ts/state/selectors/selectedConversation.ts index 7aad23448..4ec411610 100644 --- a/ts/state/selectors/selectedConversation.ts +++ b/ts/state/selectors/selectedConversation.ts @@ -3,6 +3,7 @@ import { ConversationTypeEnum, isOpenOrClosedGroup } from '../../models/conversa import { ReduxConversationType } from '../ducks/conversations'; import { StateType } from '../reducer'; import { getCanWrite, getSubscriberCount } from './sogsRoomInfo'; +import { PubKey } from '../../session/types'; /** * Returns the formatted text for notification setting. @@ -180,6 +181,38 @@ export function useSelectedDisplayNameInProfile() { return useSelector((state: StateType) => getSelectedConversation(state)?.displayNameInProfile); } +/** + * For a private chat, this returns the (xxxx...xxxx) shortened pubkey + * If this is a private chat, but somehow, we have no pubkey, this returns the localized `anonymous` string + * Otherwise, this returns the localized `unknown` string + */ +export function useSelectedShortenedPubkeyOrFallback() { + const isPrivate = useSelectedIsPrivate(); + const selected = useSelectedConversationKey(); + if (isPrivate && selected) { + return PubKey.shorten(selected); + } + if (isPrivate) { + return window.i18n('anonymous'); + } + return window.i18n('unknown'); +} + +/** + * That's a very convoluted way to say "nickname or profile name or shortened pubkey or ("Anonymous" or "unknown" depending on the type of conversation). + * This also returns the localized "Note to Self" if the conversation is the note to self. + */ +export function useSelectedNicknameOrProfileNameOrShortenedPubkey() { + const nickname = useSelectedNickname(); + const profileName = useSelectedDisplayNameInProfile(); + const shortenedPubkey = useSelectedShortenedPubkeyOrFallback(); + const isMe = useSelectedisNoteToSelf(); + if (isMe) { + return window.i18n('noteToSelf'); + } + return nickname || profileName || shortenedPubkey; +} + export function useSelectedWeAreAdmin() { return useSelector((state: StateType) => getSelectedConversation(state)?.weAreAdmin || false); } diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts index d2a094676..f928a0a32 100644 --- a/ts/types/LocalizerKeys.ts +++ b/ts/types/LocalizerKeys.ts @@ -485,6 +485,9 @@ export type LocalizerKeys = | 'youHaveANewFriendRequest' | 'clearAllConfirmationTitle' | 'clearAllConfirmationBody' + | 'noMessagesInReadOnly' + | 'noMessagesInNoteToSelf' + | 'noMessagesInEverythingElse' | 'hideBanner' | 'openMessageRequestInboxDescription' | 'clearAllReactions'