feat: add empty states for each types of convo

pull/2620/head
Audric Ackermann 2 years ago
parent 760642e149
commit ef6d9f1d51

@ -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 <b>$name$</b>.",
"noMessagesInNoteToSelf": "You have no messages in <b>$name$</b>.",
"noMessagesInEverythingElse": "You have no messages from <b>$name$</b>. 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$ ?",

@ -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;
}

@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}

@ -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<any, State> {
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 (
<div className="inbox index">
<Provider store={this.store}>
<PersistGate loading={null} persistor={persistor}>
<StyledGutter>{this.renderLeftPane()}</StyledGutter>
<SessionMainPanel />
</PersistGate>
</Provider>
</div>
);
if (!window.inboxStore) {
return null;
}
private renderLeftPane() {
return <LeftPane />;
}
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 (
<div className="inbox index">
<Provider store={window.inboxStore}>
<PersistGate loading={null} persistor={persistor}>
<StyledGutter>
<LeftPane />
</StyledGutter>
<SessionMainPanel />
</PersistGate>
</Provider>
</div>
);
};

@ -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"
/>

@ -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 (
<ConversationRequestTextBottom>
<ConversationRequestTextInner>
{window.i18n('respondingToRequestWarning')}
</ConversationRequestTextInner>
</ConversationRequestTextBottom>
);
};

@ -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<Props, State> {
// return an empty message view
return <MessageView />;
}
// 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<Props, State> {
{lightBoxOptions?.media && this.renderLightBox(lightBoxOptions)}
<div className="conversation-messages">
<NoMessageNoMessageInConversation />
<ConversationMessageRequestButtons />
<SplitViewContainer
top={<InConversationCallContainer />}
@ -283,11 +287,10 @@ export class SessionConversation extends React.Component<Props, State> {
}
disableTop={!this.props.hasOngoingCallWithFocusedConvo}
/>
{isDraggingFile && <SessionFileDropzone />}
</div>
<RespondToMessageRequestWarning />
<ConversationRequestinfo />
<CompositionBox
sendMessage={this.sendMessageFn}
stagedAttachments={this.props.stagedAttachments}

@ -0,0 +1,84 @@
import React from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { useIsRequest } from '../../hooks/useParamSelector';
import {
getSelectedHasMessages,
hasSelectedConversationIncomingMessages,
} from '../../state/selectors/conversations';
import {
getSelectedCanWrite,
useSelectedConversationKey,
useSelectedNicknameOrProfileNameOrShortenedPubkey,
useSelectedisNoteToSelf,
} from '../../state/selectors/selectedConversation';
import { LocalizerKeys } from '../../types/LocalizerKeys';
import { SessionHtmlRenderer } from '../basic/SessionHTMLRenderer';
const Container = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
padding: var(--margins-lg);
background-color: var(--background-secondary-color);
`;
const TextInner = styled.div`
color: var(--text-secondary-color);
text-align: center;
max-width: 390px;
`;
/**
* This component is used to display a warning when the user is responding to a message request.
*
*/
export const RespondToMessageRequestWarning = () => {
const selectedConversation = useSelectedConversationKey();
const isIncomingMessageRequest = useIsRequest(selectedConversation);
const showMsgRequestUI = selectedConversation && isIncomingMessageRequest;
const hasIncomingMessages = useSelector(hasSelectedConversationIncomingMessages);
if (!showMsgRequestUI || !hasIncomingMessages) {
return null;
}
return (
<Container>
<TextInner>{window.i18n('respondingToRequestWarning')}</TextInner>
</Container>
);
};
/**
* 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 (
<Container>
<TextInner>
<SessionHtmlRenderer html={window.i18n(localizedKey, [nameToRender])}></SessionHtmlRenderer>
</TextInner>
</Container>
);
};

@ -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) {

@ -220,9 +220,21 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
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<ConversationAttributes> {
(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<ConversationAttributes> {
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<ConversationAttributes> {
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<ConversationAttributes> {
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<ConversationAttributes> {
}
}
}
// 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<ConversationAttributes> {
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<ConversationAttributes> {
}
}
/**
* 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<ConversationAttributes> {
}
/**
* 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<ConversationAttributes> {
hidden_moderators?: Array<string>;
};
}) {
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<ConversationAttributes> {
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<ConversationAttributes> {
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<ConversationAttributes> {
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;

@ -789,9 +789,6 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
// 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);

@ -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<Array<OpenGroupV2InfoJoinable>> =>
allowOnlyOneAtATime('loadDefaultRoomsSingle', async () => {

@ -62,7 +62,7 @@ async function joinOpenGroupV2(
room: OpenGroupV2Room,
fromConfigMessage: boolean
): Promise<ConversationModel | undefined> {
if (!room.serverUrl || !room.roomId || room.roomId.length < 2 || !room.serverPublicKey) {
if (!room.serverUrl || !room.roomId || room.roomId.length < 1 || !room.serverPublicKey) {
return undefined;
}

@ -51,6 +51,8 @@ export class OpenGroupManagerV2 {
roomId: string,
publicKey: string
): Promise<ConversationModel | undefined> {
// 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,

@ -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');
}

@ -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<ConversationModel> {
return this.conversations.models;

@ -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);

@ -151,6 +151,8 @@ class ConfigurationSyncJob extends PersistedJob<ConfigurationSyncPersistedData>
}
public async run(): Promise<RunJobResult> {
const start = Date.now();
try {
if (!window.sessionFeatureFlags.useSharedUtilForUserConfig) {
this.triggerConfSyncJobDone();
@ -237,6 +239,8 @@ class ConfigurationSyncJob extends PersistedJob<ConfigurationSyncPersistedData>
} 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();

@ -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<ConfigWrapperObjectTypes>();
@ -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,

@ -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<void> {
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;
}

@ -96,6 +96,8 @@ export const forceSyncConfigurationNowIfNeeded = async (waitForMessageSent = fal
window.Whisper.events.once(ConfigurationSyncJobDone, () => {
resolve(true);
});
} else {
resolve(true);
}
return;

@ -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);
}
}

@ -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<MessageModelPropsWithoutConvoProps>): 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<ReduxConversationType>
@ -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<ReduxConversationType> = [];
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

@ -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);
}

@ -485,6 +485,9 @@ export type LocalizerKeys =
| 'youHaveANewFriendRequest'
| 'clearAllConfirmationTitle'
| 'clearAllConfirmationBody'
| 'noMessagesInReadOnly'
| 'noMessagesInNoteToSelf'
| 'noMessagesInEverythingElse'
| 'hideBanner'
| 'openMessageRequestInboxDescription'
| 'clearAllReactions'

Loading…
Cancel
Save