fix: notification text based on msg content

pull/3052/head
Audric Ackermann 1 year ago
parent df586d6e15
commit cbccc8c76c

@ -1,9 +1,8 @@
import React from 'react'; import React from 'react';
import { PubkeyType } from 'libsession_util_nodejs'; import { PubkeyType } from 'libsession_util_nodejs';
import { cloneDeep } from 'lodash';
import { useConversationsUsernameWithQuoteOrShortPk } from '../../../../hooks/useParamSelector'; import { useConversationsUsernameWithQuoteOrShortPk } from '../../../../hooks/useParamSelector';
import { arrayContainsUsOnly } from '../../../../models/message'; import { FormatNotifications } from '../../../../notifications/formatNotifications';
import { PreConditionFailed } from '../../../../session/utils/errors'; import { PreConditionFailed } from '../../../../session/utils/errors';
import { import {
PropsForGroupUpdate, PropsForGroupUpdate,
@ -15,87 +14,6 @@ import { assertUnreachable } from '../../../../types/sqlSharedTypes';
import { ExpirableReadableMessage } from './ExpirableReadableMessage'; import { ExpirableReadableMessage } from './ExpirableReadableMessage';
import { NotificationBubble } from './notification-bubble/NotificationBubble'; import { NotificationBubble } from './notification-bubble/NotificationBubble';
type IdWithName = { sessionId: PubkeyType; name: string };
function mapIdsWithNames(changed: Array<PubkeyType>, names: Array<string>): Array<IdWithName> {
if (!changed.length || !names.length) {
throw new PreConditionFailed('mapIdsWithNames needs a change');
}
if (changed.length !== names.length) {
throw new PreConditionFailed('mapIdsWithNames needs a the same length to map them together');
}
return changed.map((sessionId, index) => {
return { sessionId, name: names[index] };
});
}
/**
* When we are part of a change, we display the You first, and then others.
* This function is used to check if we are part of the list.
* - if yes: returns {weArePart: true, others: changedWithoutUs}
* - if yes: returns {weArePart: false, others: changed}
*/
function moveUsToStart(
changed: Array<IdWithName>,
us: PubkeyType
): {
sortedWithUsFirst: Array<IdWithName>;
} {
const usAt = changed.findIndex(m => m.sessionId === us);
if (usAt <= -1) {
// we are not in it
return { sortedWithUsFirst: changed };
}
const usItem = changed.at(usAt);
if (!usItem) {
throw new PreConditionFailed('"we" should have been there');
}
// deepClone because splice mutates the array
const changedCopy = cloneDeep(changed);
changedCopy.splice(usAt, 1);
return { sortedWithUsFirst: [usItem, ...changedCopy] };
}
function changeOfMembersV2({
changedWithNames,
type,
us,
}: {
type: 'added' | 'addedWithHistory' | 'promoted' | 'removed';
changedWithNames: Array<IdWithName>;
us: PubkeyType;
}): string {
const { sortedWithUsFirst } = moveUsToStart(changedWithNames, us);
if (changedWithNames.length === 0) {
throw new PreConditionFailed('change must always have an associated change');
}
const subject =
sortedWithUsFirst.length === 1 && sortedWithUsFirst[0].sessionId === us
? 'You'
: sortedWithUsFirst.length === 1
? 'One'
: sortedWithUsFirst.length === 2
? 'Two'
: 'Others';
const action =
type === 'addedWithHistory'
? 'JoinedWithHistory'
: type === 'added'
? 'Joined'
: type === 'promoted'
? 'Promoted'
: ('Removed' as const);
const key = `group${subject}${action}` as const;
const sortedWithUsOrCount =
subject === 'Others'
? [sortedWithUsFirst[0].name, (sortedWithUsFirst.length - 1).toString()]
: sortedWithUsFirst.map(m => m.name);
return window.i18n(key, sortedWithUsOrCount);
}
// TODO those lookups might need to be memoized // TODO those lookups might need to be memoized
const ChangeItemJoined = (added: Array<PubkeyType>, withHistory: boolean): string => { const ChangeItemJoined = (added: Array<PubkeyType>, withHistory: boolean): string => {
if (!added.length) { if (!added.length) {
@ -105,8 +23,8 @@ const ChangeItemJoined = (added: Array<PubkeyType>, withHistory: boolean): strin
const isGroupV2 = useSelectedIsGroupV2(); const isGroupV2 = useSelectedIsGroupV2();
const us = useOurPkStr(); const us = useOurPkStr();
if (isGroupV2) { if (isGroupV2) {
return changeOfMembersV2({ return FormatNotifications.changeOfMembersV2({
changedWithNames: mapIdsWithNames(added, names), changedWithNames: FormatNotifications.mapIdsWithNames(added, names),
type: withHistory ? 'addedWithHistory' : 'added', type: withHistory ? 'addedWithHistory' : 'added',
us, us,
}); });
@ -123,14 +41,15 @@ const ChangeItemKicked = (removed: Array<PubkeyType>): string => {
const isGroupV2 = useSelectedIsGroupV2(); const isGroupV2 = useSelectedIsGroupV2();
const us = useOurPkStr(); const us = useOurPkStr();
if (isGroupV2) { if (isGroupV2) {
return changeOfMembersV2({ return FormatNotifications.changeOfMembersV2({
changedWithNames: mapIdsWithNames(removed, names), changedWithNames: FormatNotifications.mapIdsWithNames(removed, names),
type: 'removed', type: 'removed',
us, us,
}); });
} }
if (arrayContainsUsOnly(removed)) { // legacy groups
if (FormatNotifications.arrayContainsUsOnly(removed)) {
return window.i18n('youGotKickedFromGroup'); return window.i18n('youGotKickedFromGroup');
} }
@ -146,8 +65,8 @@ const ChangeItemPromoted = (promoted: Array<PubkeyType>): string => {
const isGroupV2 = useSelectedIsGroupV2(); const isGroupV2 = useSelectedIsGroupV2();
const us = useOurPkStr(); const us = useOurPkStr();
if (isGroupV2) { if (isGroupV2) {
return changeOfMembersV2({ return FormatNotifications.changeOfMembersV2({
changedWithNames: mapIdsWithNames(promoted, names), changedWithNames: FormatNotifications.mapIdsWithNames(promoted, names),
type: 'promoted', type: 'promoted',
us, us,
}); });
@ -170,7 +89,7 @@ const ChangeItemLeft = (left: Array<PubkeyType>): string => {
const names = useConversationsUsernameWithQuoteOrShortPk(left); const names = useConversationsUsernameWithQuoteOrShortPk(left);
if (arrayContainsUsOnly(left)) { if (FormatNotifications.arrayContainsUsOnly(left)) {
return window.i18n('youLeftTheGroup'); return window.i18n('youLeftTheGroup');
} }

@ -21,8 +21,10 @@ import {
showRemoveModeratorsByConvoId, showRemoveModeratorsByConvoId,
showUpdateGroupMembersByConvoId, showUpdateGroupMembersByConvoId,
showUpdateGroupNameByConvoId, showUpdateGroupNameByConvoId,
triggerFakeAvatarUpdate,
} from '../../../../interactions/conversationInteractions'; } from '../../../../interactions/conversationInteractions';
import { Constants } from '../../../../session'; import { Constants } from '../../../../session';
import { isDevProd } from '../../../../shared/env_vars';
import { closeRightPanel } from '../../../../state/ducks/conversations'; import { closeRightPanel } from '../../../../state/ducks/conversations';
import { resetRightOverlayMode, setRightOverlayMode } from '../../../../state/ducks/section'; import { resetRightOverlayMode, setRightOverlayMode } from '../../../../state/ducks/section';
import { import {
@ -204,6 +206,7 @@ export const OverlayRightPanelSettings = () => {
const isBlocked = useSelectedIsBlocked(); const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup(); const isKickedFromGroup = useSelectedIsKickedFromGroup();
const isGroup = useSelectedIsGroupOrCommunity(); const isGroup = useSelectedIsGroupOrCommunity();
const isGroupV2 = useSelectedIsGroupV2();
const isPublic = useSelectedIsPublic(); const isPublic = useSelectedIsPublic();
const weAreAdmin = useSelectedWeAreAdmin(); const weAreAdmin = useSelectedWeAreAdmin();
const disappearingMessagesSubtitle = useDisappearingMessageSettingText({ const disappearingMessagesSubtitle = useDisappearingMessageSettingText({
@ -293,6 +296,17 @@ export const OverlayRightPanelSettings = () => {
/> />
)} )}
{isDevProd() && isGroupV2 ? (
<PanelIconButton
iconType={'group'}
text={'trigger avatar message'} // debugger FIXME Audric
onClick={() => {
void triggerFakeAvatarUpdate(selectedConvoKey);
}}
dataTestId="edit-group-name"
/>
) : null}
{showAddRemoveModeratorsButton && ( {showAddRemoveModeratorsButton && (
<> <>
<PanelIconButton <PanelIconButton

@ -42,6 +42,7 @@ export const MessageItem = () => {
if (isEmpty(text)) { if (isEmpty(text)) {
return null; return null;
} }
const withoutHtmlTags = text.replaceAll(/(<([^>]+)>)/gi, '');
return ( return (
<div className="module-conversation-list-item__message"> <div className="module-conversation-list-item__message">
@ -54,7 +55,12 @@ export const MessageItem = () => {
{isConvoTyping ? ( {isConvoTyping ? (
<TypingAnimation /> <TypingAnimation />
) : ( ) : (
<MessageBody text={text} disableJumbomoji={true} disableLinks={true} isGroup={isGroup} /> <MessageBody
text={withoutHtmlTags}
disableJumbomoji={true}
disableLinks={true}
isGroup={isGroup}
/>
)} )}
</div> </div>
{!isSearchingMode && lastMessage && lastMessage.status && !isMessageRequest ? ( {!isSearchingMode && lastMessage && lastMessage.status && !isMessageRequest ? (

@ -93,6 +93,13 @@ function usernameForQuoteOrFullPk(pubkey: string, state: StateType) {
return nameGot?.length ? nameGot : null; return nameGot?.length ? nameGot : null;
} }
export function usernameForQuoteOrFullPkOutsideRedux(pubkey: string) {
if (window?.inboxStore?.getState()) {
return usernameForQuoteOrFullPk(pubkey, window.inboxStore.getState()) || PubKey.shorten(pubkey);
}
return PubKey.shorten(pubkey);
}
/** /**
* Returns either the nickname, the profileName, in '"' or the full pubkeys given * Returns either the nickname, the profileName, in '"' or the full pubkeys given
*/ */

@ -1,4 +1,4 @@
import { isNil } from 'lodash'; import { isEmpty, isNil } from 'lodash';
import { import {
ConversationNotificationSettingType, ConversationNotificationSettingType,
ConversationTypeEnum, ConversationTypeEnum,
@ -10,6 +10,7 @@ import { SessionButtonColor } from '../components/basic/SessionButton';
import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings'; import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings';
import { Data } from '../data/data'; import { Data } from '../data/data';
import { SettingsKey } from '../data/settings-key'; import { SettingsKey } from '../data/settings-key';
import { SignalService } from '../protobuf';
import { GroupV2Receiver } from '../receiver/groupv2/handleGroupV2Message'; import { GroupV2Receiver } from '../receiver/groupv2/handleGroupV2Message';
import { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi'; import { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi';
import { OpenGroupUtils } from '../session/apis/open_group_api/utils'; import { OpenGroupUtils } from '../session/apis/open_group_api/utils';
@ -19,11 +20,13 @@ import { ConvoHub } from '../session/conversations';
import { getSodiumRenderer } from '../session/crypto'; import { getSodiumRenderer } from '../session/crypto';
import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager'; import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager';
import { DisappearingMessageConversationModeType } from '../session/disappearing_messages/types'; import { DisappearingMessageConversationModeType } from '../session/disappearing_messages/types';
import { GroupUpdateInfoChangeMessage } from '../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage';
import { ed25519Str } from '../session/onions/onionPath'; import { ed25519Str } from '../session/onions/onionPath';
import { PubKey } from '../session/types'; import { PubKey } from '../session/types';
import { perfEnd, perfStart } from '../session/utils/Performance'; import { perfEnd, perfStart } from '../session/utils/Performance';
import { sleepFor } from '../session/utils/Promise'; import { sleepFor } from '../session/utils/Promise';
import { fromHexToArray, toHex } from '../session/utils/String'; import { fromHexToArray, toHex } from '../session/utils/String';
import { GroupSync } from '../session/utils/job_runners/jobs/GroupSyncJob';
import { UserSync } from '../session/utils/job_runners/jobs/UserSyncJob'; import { UserSync } from '../session/utils/job_runners/jobs/UserSyncJob';
import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts'; import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts';
import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils'; import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils';
@ -322,6 +325,42 @@ export async function showUpdateGroupNameByConvoId(conversationId: string) {
window.inboxStore?.dispatch(updateGroupNameModal({ conversationId })); window.inboxStore?.dispatch(updateGroupNameModal({ conversationId }));
} }
export async function triggerFakeAvatarUpdate(conversationId: string) {
if (!PubKey.is03Pubkey(conversationId)) {
throw new Error('triggerAvatarUpdate only works for groupv2');
}
const convo = ConvoHub.use().get(conversationId);
const group = await UserGroupsWrapperActions.getGroup(conversationId);
if (!convo || !group || !group.secretKey || isEmpty(group.secretKey)) {
throw new Error(
'triggerFakeAvatarUpdate: tried to make change to group but we do not have the admin secret key'
);
}
const createdAt = GetNetworkTime.now();
const msgModel = await convo.addSingleOutgoingMessage({
group_update: { avatarChange: true },
sent_at: createdAt,
// the store below will mark the message as sent based on msgModel.id
});
await msgModel.commit();
const updateMsg = new GroupUpdateInfoChangeMessage({
createAtNetworkTimestamp: createdAt,
typeOfChange: SignalService.GroupUpdateInfoChangeMessage.Type.AVATAR,
expirationType: 'unknown',
expireTimer: 0,
groupPk: conversationId,
identifier: msgModel.id,
secretKey: group.secretKey,
sodium: await getSodiumRenderer(),
});
await GroupSync.storeGroupUpdateMessages({
groupPk: conversationId,
updateMessages: [updateMsg],
});
}
export async function showUpdateGroupMembersByConvoId(conversationId: string) { export async function showUpdateGroupMembersByConvoId(conversationId: string) {
const conversation = ConvoHub.use().get(conversationId); const conversation = ConvoHub.use().get(conversationId);
if (conversation.isClosedGroup()) { if (conversation.isClosedGroup()) {

@ -131,6 +131,7 @@ import { handleAcceptConversationRequest } from '../interactions/conversationInt
import { DisappearingMessages } from '../session/disappearing_messages'; import { DisappearingMessages } from '../session/disappearing_messages';
import { GroupUpdateInfoChangeMessage } from '../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage'; import { GroupUpdateInfoChangeMessage } from '../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage';
import { FetchMsgExpirySwarm } from '../session/utils/job_runners/jobs/FetchMsgExpirySwarmJob'; import { FetchMsgExpirySwarm } from '../session/utils/job_runners/jobs/FetchMsgExpirySwarmJob';
import { GroupSync } from '../session/utils/job_runners/jobs/GroupSyncJob';
import { UpdateMsgExpirySwarm } from '../session/utils/job_runners/jobs/UpdateMsgExpirySwarmJob'; import { UpdateMsgExpirySwarm } from '../session/utils/job_runners/jobs/UpdateMsgExpirySwarmJob';
import { getLibGroupKickedOutsideRedux } from '../state/selectors/userGroups'; import { getLibGroupKickedOutsideRedux } from '../state/selectors/userGroups';
import { ReleasedFeatures } from '../util/releaseFeature'; import { ReleasedFeatures } from '../util/releaseFeature';
@ -896,8 +897,10 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
// we don't add an update message when this comes from a config message, as we already have the SyncedMessage itself with the right timestamp to display // we don't add an update message when this comes from a config message, as we already have the SyncedMessage itself with the right timestamp to display
if (this.isPublic()) { if (!this.isClosedGroup() && !this.isPrivate()) {
throw new Error("updateExpireTimer() Disappearing messages aren't supported in communities"); throw new Error(
'updateExpireTimer() Disappearing messages are only supported int groups and private chats'
);
} }
let expirationMode = providedDisappearingMode; let expirationMode = providedDisappearingMode;
let expireTimer = providedExpireTimer; let expireTimer = providedExpireTimer;
@ -916,14 +919,18 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
* - effectively changes the setting * - effectively changes the setting
* - ignores a off setting for a legacy group (as we can get a setting from restored from configMessage, and a newgroup can still be in the swarm when linking a device * - ignores a off setting for a legacy group (as we can get a setting from restored from configMessage, and a newgroup can still be in the swarm when linking a device
*/ */
const shouldAddExpireUpdateMsgGroup = const shouldAddExpireUpdateMsgLegacyGroup =
isLegacyGroup && isLegacyGroup &&
!fromConfigMessage && !fromConfigMessage &&
(expirationMode !== this.get('expirationMode') || expireTimer !== this.get('expireTimer')) && (expirationMode !== this.get('expirationMode') || expireTimer !== this.get('expireTimer')) &&
expirationMode !== 'off'; expirationMode !== 'off';
const shouldAddExpireUpdateMsgGroupV2 = this.isClosedGroupV2() && !fromConfigMessage;
const shouldAddExpireUpdateMessage = const shouldAddExpireUpdateMessage =
shouldAddExpireUpdateMsgPrivate || shouldAddExpireUpdateMsgGroup; shouldAddExpireUpdateMsgPrivate ||
shouldAddExpireUpdateMsgLegacyGroup ||
shouldAddExpireUpdateMsgGroupV2;
// When we add a disappearing messages notification to the conversation, we want it // When we add a disappearing messages notification to the conversation, we want it
// to be above the message that initiated that change, hence the subtraction. // to be above the message that initiated that change, hence the subtraction.
@ -1106,8 +1113,9 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
updatedExpirationSeconds: expireUpdate.expireTimer, updatedExpirationSeconds: expireUpdate.expireTimer,
}); });
await getMessageQueue().sendToGroupV2({ await GroupSync.storeGroupUpdateMessages({
message: v2groupMessage, groupPk: this.id,
updateMessages: [v2groupMessage],
}); });
return true; return true;
} }
@ -2094,13 +2102,14 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return; return;
} }
if (message.get('groupInvitation')) { const communityInvitation = message.getCommunityInvitation();
const groupInvitation = message.get('groupInvitation');
if (communityInvitation && communityInvitation.url) {
const groupInviteMessage = new GroupInvitationMessage({ const groupInviteMessage = new GroupInvitationMessage({
identifier: id, identifier: id,
createAtNetworkTimestamp: networkTimestamp, createAtNetworkTimestamp: networkTimestamp,
name: groupInvitation.name, name: communityInvitation.name,
url: groupInvitation.url, url: communityInvitation.url,
expirationType: chatMessageParams.expirationType, expirationType: chatMessageParams.expirationType,
expireTimer: chatMessageParams.expireTimer, expireTimer: chatMessageParams.expireTimer,
}); });

@ -3,16 +3,7 @@ import Backbone from 'backbone';
import autoBind from 'auto-bind'; import autoBind from 'auto-bind';
import filesize from 'filesize'; import filesize from 'filesize';
import { GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs'; import { GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs';
import { import { cloneDeep, debounce, isEmpty, size as lodashSize, partition, pick, uniq } from 'lodash';
cloneDeep,
debounce,
isEmpty,
size as lodashSize,
map,
partition,
pick,
uniq,
} from 'lodash';
import { SignalService } from '../protobuf'; import { SignalService } from '../protobuf';
import { getMessageQueue } from '../session'; import { getMessageQueue } from '../session';
import { ConvoHub } from '../session/conversations'; import { ConvoHub } from '../session/conversations';
@ -29,7 +20,6 @@ import {
uploadQuoteThumbnailsToFileServer, uploadQuoteThumbnailsToFileServer,
} from '../session/utils'; } from '../session/utils';
import { import {
DataExtractionNotificationMsg,
MessageAttributes, MessageAttributes,
MessageAttributesOptionals, MessageAttributesOptionals,
MessageGroupUpdate, MessageGroupUpdate,
@ -42,10 +32,7 @@ import {
import { Data } from '../data/data'; import { Data } from '../data/data';
import { OpenGroupData } from '../data/opengroups'; import { OpenGroupData } from '../data/opengroups';
import { SettingsKey } from '../data/settings-key'; import { SettingsKey } from '../data/settings-key';
import { import { FormatNotifications } from '../notifications/formatNotifications';
ConversationInteractionStatus,
ConversationInteractionType,
} from '../interactions/conversationInteractions';
import { isUsAnySogsFromCache } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import { isUsAnySogsFromCache } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime'; import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime';
import { SnodeNamespaces } from '../session/apis/snode_api/namespaces'; import { SnodeNamespaces } from '../session/apis/snode_api/namespaces';
@ -96,7 +83,7 @@ import {
} from '../types/MessageAttachment'; } from '../types/MessageAttachment';
import { ReactionList } from '../types/Reaction'; import { ReactionList } from '../types/Reaction';
import { getAttachmentMetadata } from '../types/message/initializeAttachmentMetadata'; import { getAttachmentMetadata } from '../types/message/initializeAttachmentMetadata';
import { assertUnreachable, roomHasBlindEnabled } from '../types/sqlSharedTypes'; import { roomHasBlindEnabled } from '../types/sqlSharedTypes';
import { LinkPreviews } from '../util/linkPreviews'; import { LinkPreviews } from '../util/linkPreviews';
import { Notifications } from '../util/notifications'; import { Notifications } from '../util/notifications';
import { Storage } from '../util/storage'; import { Storage } from '../util/storage';
@ -104,34 +91,6 @@ import { ConversationModel } from './conversation';
import { READ_MESSAGE_STATE } from './conversationAttributes'; import { READ_MESSAGE_STATE } from './conversationAttributes';
// tslint:disable: cyclomatic-complexity // tslint:disable: cyclomatic-complexity
/**
* @returns true if the array contains only a single item being 'You', 'you' or our device pubkey
*/
export function arrayContainsUsOnly(arrayToCheck: Array<string> | undefined) {
return (
arrayToCheck &&
arrayToCheck.length === 1 &&
(arrayToCheck[0] === UserUtils.getOurPubKeyStrFromCache() ||
arrayToCheck[0].toLowerCase() === 'you')
);
}
export function arrayContainsOneItemOnly(arrayToCheck: Array<string> | undefined) {
return arrayToCheck && arrayToCheck.length === 1;
}
function formatJoined(joined: Array<string>) {
const names = joined.map(ConvoHub.use().getContactProfileNameOrShortenedPubKey);
const messages = [];
if (names.length > 1) {
messages.push(window.i18n('multipleJoinedTheGroup', [names.join(', ')]));
} else {
messages.push(window.i18n('joinedTheGroup', names));
}
return messages.join(' ');
}
export class MessageModel extends Backbone.Model<MessageAttributes> { export class MessageModel extends Backbone.Model<MessageAttributes> {
constructor(attributes: MessageAttributesOptionals & { skipTimerInit?: boolean }) { constructor(attributes: MessageAttributesOptionals & { skipTimerInit?: boolean }) {
const filledAttrs = fillMessageAttributesWithDefaults(attributes); const filledAttrs = fillMessageAttributesWithDefaults(attributes);
@ -258,8 +217,11 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
this.set(attributes); this.set(attributes);
} }
public isGroupInvitation() { public isCommunityInvitation() {
return !!this.get('groupInvitation'); return !!this.getCommunityInvitation();
}
public getCommunityInvitation() {
return this.get('groupInvitation');
} }
public isMessageRequestResponse() { public isMessageRequestResponse() {
@ -267,15 +229,22 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
} }
public isDataExtractionNotification() { public isDataExtractionNotification() {
return !!this.get('dataExtractionNotification'); return !!this.getDataExtractionNotification();
}
public getDataExtractionNotification() {
return this.get('dataExtractionNotification');
} }
public isCallNotification() { public isCallNotification() {
return !!this.get('callNotificationType'); return !!this.getCallNotification();
}
public getCallNotification() {
return this.get('callNotificationType');
} }
public isInteractionNotification() { public isInteractionNotification() {
return !!this.getInteractionNotification(); return !!this.getInteractionNotification();
} }
public getInteractionNotification() { public getInteractionNotification() {
return this.get('interactionNotification'); return this.get('interactionNotification');
} }
@ -406,10 +375,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
} }
public getPropsForGroupInvitation(): PropsForGroupInvitation | null { public getPropsForGroupInvitation(): PropsForGroupInvitation | null {
if (!this.isGroupInvitation()) { const invitation = this.getCommunityInvitation();
if (!invitation || !invitation.url) {
return null; return null;
} }
const invitation = this.get('groupInvitation');
let serverAddress = ''; let serverAddress = '';
try { try {
@ -433,7 +402,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
if (!this.isDataExtractionNotification()) { if (!this.isDataExtractionNotification()) {
return null; return null;
} }
const dataExtractionNotification = this.get('dataExtractionNotification'); const dataExtractionNotification = this.getDataExtractionNotification();
if (!dataExtractionNotification) { if (!dataExtractionNotification) {
window.log.warn('dataExtractionNotification should not happen'); window.log.warn('dataExtractionNotification should not happen');
@ -477,7 +446,6 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
public getPropsForGroupUpdateMessage(): PropsForGroupUpdate | null { public getPropsForGroupUpdateMessage(): PropsForGroupUpdate | null {
const groupUpdate = this.getGroupUpdateAsArray(); const groupUpdate = this.getGroupUpdateAsArray();
if (!groupUpdate || isEmpty(groupUpdate)) { if (!groupUpdate || isEmpty(groupUpdate)) {
return null; return null;
} }
@ -1267,179 +1235,84 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
} }
/** /**
* Before, group_update attributes could be just the string 'You' and not an array. * A long time ago, group_update attributes could be just the string 'You' and not an array of pubkeys.
* Using this method to get the group update makes sure than the joined, kicked, or left are always an array of string, or undefined * Using this method to get the group update makes sure than the joined, kicked, or left are always an array of string, or undefined.
* This is legacy code, our joined, kicked, left, etc should have been saved as an Array for a long time now.
*/ */
private getGroupUpdateAsArray() { private getGroupUpdateAsArray() {
const groupUpdate = this.get('group_update'); const groupUpdate = this.get('group_update');
if (!groupUpdate || isEmpty(groupUpdate)) { if (!groupUpdate || isEmpty(groupUpdate)) {
return undefined; return undefined;
} }
const left: Array<string> | undefined = Array.isArray(groupUpdate.left) const forcedArrayUpdate: MessageGroupUpdate = {};
? groupUpdate.left
: groupUpdate.left forcedArrayUpdate.joined = Array.isArray(groupUpdate.joined)
? [groupUpdate.left]
: undefined;
const kicked: Array<string> | undefined = Array.isArray(groupUpdate.kicked)
? groupUpdate.kicked
: groupUpdate.kicked
? [groupUpdate.kicked]
: undefined;
const joined: Array<string> | undefined = Array.isArray(groupUpdate.joined)
? groupUpdate.joined ? groupUpdate.joined
: groupUpdate.joined : groupUpdate.joined
? [groupUpdate.joined] ? [groupUpdate.joined]
: undefined; : undefined;
const joinedWithHistory: Array<string> | undefined = Array.isArray(
groupUpdate.joinedWithHistory forcedArrayUpdate.joinedWithHistory = Array.isArray(groupUpdate.joinedWithHistory)
)
? groupUpdate.joinedWithHistory ? groupUpdate.joinedWithHistory
: groupUpdate.joinedWithHistory : groupUpdate.joinedWithHistory
? [groupUpdate.joinedWithHistory] ? [groupUpdate.joinedWithHistory]
: undefined; : undefined;
const forcedArrayUpdate: MessageGroupUpdate = {}; forcedArrayUpdate.kicked = Array.isArray(groupUpdate.kicked)
? groupUpdate.kicked
: groupUpdate.kicked
? [groupUpdate.kicked]
: undefined;
forcedArrayUpdate.left = Array.isArray(groupUpdate.left)
? groupUpdate.left
: groupUpdate.left
? [groupUpdate.left]
: undefined;
forcedArrayUpdate.name = groupUpdate.name;
forcedArrayUpdate.avatarChange = groupUpdate.avatarChange;
if (left) {
forcedArrayUpdate.left = left;
}
if (joinedWithHistory) {
forcedArrayUpdate.joinedWithHistory = joinedWithHistory;
}
if (joined) {
forcedArrayUpdate.joined = joined;
}
if (kicked) {
forcedArrayUpdate.kicked = kicked;
}
if (groupUpdate.name) {
forcedArrayUpdate.name = groupUpdate.name;
}
return forcedArrayUpdate; return forcedArrayUpdate;
} }
private getDescription() { private getDescription() {
const groupUpdate = this.getGroupUpdateAsArray(); const groupUpdate = this.getGroupUpdateAsArray();
if (groupUpdate) { if (!isEmpty(groupUpdate)) {
if (arrayContainsUsOnly(groupUpdate.kicked)) { return FormatNotifications.formatGroupUpdateNotification(groupUpdate);
return window.i18n('youGotKickedFromGroup');
}
if (arrayContainsUsOnly(groupUpdate.left)) {
return window.i18n('youLeftTheGroup');
}
if (groupUpdate.left && groupUpdate.left.length === 1) {
return window.i18n('leftTheGroup', [
ConvoHub.use().getContactProfileNameOrShortenedPubKey(groupUpdate.left[0]),
]);
}
if (groupUpdate.name) {
return window.i18n('titleIsNow', [groupUpdate.name]);
}
if (groupUpdate.joined && groupUpdate.joined.length) {
return formatJoined(groupUpdate.joined);
}
if (groupUpdate.joinedWithHistory && groupUpdate.joinedWithHistory.length) {
return formatJoined(groupUpdate.joinedWithHistory);
}
if (groupUpdate.kicked && groupUpdate.kicked.length) {
const names = map(
groupUpdate.kicked,
ConvoHub.use().getContactProfileNameOrShortenedPubKey
);
const messages = [];
if (names.length > 1) {
messages.push(window.i18n('multipleKickedFromTheGroup', [names.join(', ')]));
} else {
messages.push(window.i18n('kickedFromTheGroup', names));
}
return messages.join(' ');
}
return null;
}
if (this.isIncoming() && this.hasErrors()) {
return window.i18n('incomingError');
} }
if (this.isGroupInvitation()) { const communityInvitation = this.getCommunityInvitation();
if (communityInvitation) {
return `😎 ${window.i18n('openGroupInvitation')}`; return `😎 ${window.i18n('openGroupInvitation')}`;
} }
if (this.isDataExtractionNotification()) { const dataExtractionNotification = this.getDataExtractionNotification();
const dataExtraction = this.get( if (dataExtractionNotification) {
'dataExtractionNotification' return FormatNotifications.formatDataExtractionNotification(dataExtractionNotification);
) as DataExtractionNotificationMsg;
if (dataExtraction.type === SignalService.DataExtractionNotification.Type.SCREENSHOT) {
return window.i18n('tookAScreenshot', [
ConvoHub.use().getContactProfileNameOrShortenedPubKey(dataExtraction.source),
]);
}
return window.i18n('savedTheFile', [
ConvoHub.use().getContactProfileNameOrShortenedPubKey(dataExtraction.source),
]);
} }
if (this.isCallNotification()) {
const displayName = ConvoHub.use().getContactProfileNameOrShortenedPubKey( const callNotification = this.getCallNotification();
if (callNotification) {
return FormatNotifications.formatCallNotification(
callNotification,
this.get('conversationId') this.get('conversationId')
); );
const callNotificationType = this.get('callNotificationType');
if (callNotificationType === 'missed-call') {
return window.i18n('callMissed', [displayName]);
}
if (callNotificationType === 'started-call') {
return window.i18n('startedACall', [displayName]);
}
if (callNotificationType === 'answered-a-call') {
return window.i18n('answeredACall', [displayName]);
}
} }
const interactionNotification = this.getInteractionNotification(); const interactionNotification = this.getInteractionNotification();
if (interactionNotification) { if (interactionNotification) {
const { interactionType, interactionStatus } = interactionNotification; return FormatNotifications.formatInteractionNotification(
interactionNotification,
// NOTE For now we only show interaction errors in the message history this.get('conversationId')
if (interactionStatus === ConversationInteractionStatus.Error) { );
const convo = ConvoHub.use().get(this.get('conversationId'));
if (convo) {
const isGroup = !convo.isPrivate();
const isCommunity = convo.isPublic();
switch (interactionType) {
case ConversationInteractionType.Hide:
// there is no text for hiding changes
return '';
case ConversationInteractionType.Leave:
return isCommunity
? window.i18n('leaveCommunityFailed')
: isGroup
? window.i18n('leaveGroupFailed')
: window.i18n('deleteConversationFailed');
default:
assertUnreachable(
interactionType,
`Message.getDescription: Missing case error "${interactionType}"`
);
}
}
}
} }
if (this.get('reaction')) { const reaction = this.get('reaction');
const reaction = this.get('reaction'); if (reaction && reaction.emoji && reaction.emoji !== '') {
if (reaction && reaction.emoji && reaction.emoji !== '') { return window.i18n('reactionNotification', [reaction.emoji]);
return window.i18n('reactionNotification', [reaction.emoji]);
}
} }
return this.get('body'); return this.get('body');
} }

@ -40,7 +40,7 @@ export interface MessageAttributes {
read_by: Array<string>; // we actually only care about the length of this. values are not used for anything read_by: Array<string>; // we actually only care about the length of this. values are not used for anything
type: MessageModelType; type: MessageModelType;
group_update?: MessageGroupUpdate; group_update?: MessageGroupUpdate;
groupInvitation?: any; groupInvitation?: { url: string | undefined; name: string } | undefined;
attachments?: any; attachments?: any;
conversationId: string; conversationId: string;
errors?: any; errors?: any;
@ -160,10 +160,10 @@ export type PropsForMessageRequestResponse = MessageRequestResponseMsg & {
}; };
export type MessageGroupUpdate = { export type MessageGroupUpdate = {
left?: Array<string>; left?: Array<PubkeyType>;
joined?: Array<string>; joined?: Array<PubkeyType>;
joinedWithHistory?: Array<string>; joinedWithHistory?: Array<PubkeyType>;
kicked?: Array<string>; kicked?: Array<PubkeyType>;
promoted?: Array<PubkeyType>; promoted?: Array<PubkeyType>;
name?: string; name?: string;
avatarChange?: boolean; avatarChange?: boolean;
@ -188,7 +188,7 @@ export interface MessageAttributesOptionals {
read_by?: Array<string>; // we actually only care about the length of this. values are not used for anything read_by?: Array<string>; // we actually only care about the length of this. values are not used for anything
type: MessageModelType; type: MessageModelType;
group_update?: MessageGroupUpdate; group_update?: MessageGroupUpdate;
groupInvitation?: any; groupInvitation?: { url: string | undefined; name: string } | undefined;
attachments?: any; attachments?: any;
conversationId: string; conversationId: string;
errors?: any; errors?: any;

@ -0,0 +1,264 @@
import { PubkeyType } from 'libsession_util_nodejs';
import { cloneDeep } from 'lodash';
import { usernameForQuoteOrFullPkOutsideRedux } from '../hooks/useParamSelector';
import {
ConversationInteractionStatus,
ConversationInteractionType,
} from '../interactions/conversationInteractions';
import { DataExtractionNotificationMsg, MessageGroupUpdate } from '../models/messageType';
import { SignalService } from '../protobuf';
import { ConvoHub } from '../session/conversations';
import { UserUtils } from '../session/utils';
import { PreConditionFailed } from '../session/utils/errors';
import { CallNotificationType, InteractionNotificationType } from '../state/ducks/conversations';
import { assertUnreachable } from '../types/sqlSharedTypes';
/**
* @returns true if the array contains only a single item being 'You', 'you' or our device pubkey
*/
export function arrayContainsUsOnly(arrayToCheck: Array<string> | undefined) {
return (
arrayToCheck &&
arrayToCheck.length === 1 &&
(arrayToCheck[0] === UserUtils.getOurPubKeyStrFromCache() ||
arrayToCheck[0].toLowerCase() === 'you')
);
}
function formatGroupUpdateNotification(groupUpdate: MessageGroupUpdate) {
const us = UserUtils.getOurPubKeyStrFromCache();
if (groupUpdate.name) {
return window.i18n('titleIsNow', [groupUpdate.name]);
}
if (groupUpdate.avatarChange) {
return window.i18n('groupAvatarChange');
}
if (groupUpdate.left) {
if (groupUpdate.left.length !== 1) {
return null;
}
if (arrayContainsUsOnly(groupUpdate.left)) {
return window.i18n('youLeftTheGroup');
}
// no more than one can send a leave message at a time
return window.i18n('leftTheGroup', [
ConvoHub.use().getContactProfileNameOrShortenedPubKey(groupUpdate.left[0]),
]);
}
if (groupUpdate.joined) {
if (!groupUpdate.joined.length) {
return null;
}
return changeOfMembersV2({
type: 'added',
us,
changedWithNames: mapIdsWithNames(
groupUpdate.joined,
groupUpdate.joined.map(usernameForQuoteOrFullPkOutsideRedux)
),
});
}
if (groupUpdate.joinedWithHistory) {
if (!groupUpdate.joinedWithHistory.length) {
return null;
}
return changeOfMembersV2({
type: 'addedWithHistory',
us,
changedWithNames: mapIdsWithNames(
groupUpdate.joinedWithHistory,
groupUpdate.joinedWithHistory.map(usernameForQuoteOrFullPkOutsideRedux)
),
});
}
if (groupUpdate.kicked) {
if (!groupUpdate.kicked.length) {
return null;
}
if (arrayContainsUsOnly(groupUpdate.kicked)) {
return window.i18n('youGotKickedFromGroup');
}
return changeOfMembersV2({
type: 'removed',
us,
changedWithNames: mapIdsWithNames(
groupUpdate.kicked,
groupUpdate.kicked.map(usernameForQuoteOrFullPkOutsideRedux)
),
});
}
if (groupUpdate.promoted) {
if (!groupUpdate.promoted.length) {
return null;
}
return changeOfMembersV2({
type: 'promoted',
us,
changedWithNames: mapIdsWithNames(
groupUpdate.promoted,
groupUpdate.promoted.map(usernameForQuoteOrFullPkOutsideRedux)
),
});
}
throw new Error('group_update getDescription() case not taken care of');
}
function formatDataExtractionNotification(
dataExtractionNotification: DataExtractionNotificationMsg
) {
const { Type } = SignalService.DataExtractionNotification;
const isScreenshot = dataExtractionNotification.type === Type.SCREENSHOT;
return window.i18n(isScreenshot ? 'tookAScreenshot' : 'savedTheFile', [
ConvoHub.use().getContactProfileNameOrShortenedPubKey(dataExtractionNotification.source),
]);
}
function formatInteractionNotification(
interactionNotification: InteractionNotificationType,
conversationId: string
) {
const { interactionType, interactionStatus } = interactionNotification;
// NOTE For now we only show interaction errors in the message history
if (interactionStatus === ConversationInteractionStatus.Error) {
const convo = ConvoHub.use().get(conversationId);
if (convo) {
const isGroup = !convo.isPrivate();
const isCommunity = convo.isPublic();
switch (interactionType) {
case ConversationInteractionType.Hide:
// there is no text for hiding changes
return '';
case ConversationInteractionType.Leave:
return isCommunity
? window.i18n('leaveCommunityFailed')
: isGroup
? window.i18n('leaveGroupFailed')
: window.i18n('deleteConversationFailed');
default:
assertUnreachable(
interactionType,
`Message.getDescription: Missing case error "${interactionType}"`
);
}
}
}
window.log.error('formatInteractionNotification: Unsupported case');
return null;
}
function formatCallNotification(
callNotificationType: CallNotificationType,
conversationId: string
) {
const displayName = ConvoHub.use().getContactProfileNameOrShortenedPubKey(conversationId);
if (callNotificationType === 'missed-call') {
return window.i18n('callMissed', [displayName]);
}
if (callNotificationType === 'started-call') {
return window.i18n('startedACall', [displayName]);
}
if (callNotificationType === 'answered-a-call') {
return window.i18n('answeredACall', [displayName]);
}
window.log.error('formatCallNotification: Unsupported notification type');
return null;
}
export type IdWithName = { sessionId: PubkeyType; name: string };
/**
* When we are part of a change, we display the You first, and then others.
* This function is used to check if we are part of the list.
* - if yes: returns {weArePart: true, others: changedWithoutUs}
* - if yes: returns {weArePart: false, others: changed}
*/
function moveUsToStart(
changed: Array<IdWithName>,
us: PubkeyType
): {
sortedWithUsFirst: Array<IdWithName>;
} {
const usAt = changed.findIndex(m => m.sessionId === us);
if (usAt <= -1) {
// we are not in it
return { sortedWithUsFirst: changed };
}
const usItem = changed.at(usAt);
if (!usItem) {
throw new PreConditionFailed('"we" should have been there');
}
// deepClone because splice mutates the array
const changedCopy = cloneDeep(changed);
changedCopy.splice(usAt, 1);
return { sortedWithUsFirst: [usItem, ...changedCopy] };
}
function changeOfMembersV2({
changedWithNames,
type,
us,
}: {
type: 'added' | 'addedWithHistory' | 'promoted' | 'removed';
changedWithNames: Array<IdWithName>;
us: PubkeyType;
}): string {
const { sortedWithUsFirst } = moveUsToStart(changedWithNames, us);
if (changedWithNames.length === 0) {
throw new PreConditionFailed('change must always have an associated change');
}
const subject =
sortedWithUsFirst.length === 1 && sortedWithUsFirst[0].sessionId === us
? 'You'
: sortedWithUsFirst.length === 1
? 'One'
: sortedWithUsFirst.length === 2
? 'Two'
: 'Others';
const action =
type === 'addedWithHistory'
? 'JoinedWithHistory'
: type === 'added'
? 'Joined'
: type === 'promoted'
? 'Promoted'
: ('Removed' as const);
const key = `group${subject}${action}` as const;
const sortedWithUsOrCount =
subject === 'Others'
? [sortedWithUsFirst[0].name, (sortedWithUsFirst.length - 1).toString()]
: sortedWithUsFirst.map(m => m.name);
return window.i18n(key, sortedWithUsOrCount);
}
function mapIdsWithNames(changed: Array<PubkeyType>, names: Array<string>): Array<IdWithName> {
if (!changed.length || !names.length) {
throw new PreConditionFailed('mapIdsWithNames needs a change');
}
if (changed.length !== names.length) {
throw new PreConditionFailed('mapIdsWithNames needs a the same length to map them together');
}
return changed.map((sessionId, index) => {
return { sessionId, name: names[index] };
});
}
export const FormatNotifications = {
arrayContainsUsOnly,
formatCallNotification,
formatInteractionNotification,
formatDataExtractionNotification,
formatGroupUpdateNotification,
changeOfMembersV2,
mapIdsWithNames,
};

@ -223,12 +223,7 @@ async function handleGroupInfoChangeMessage({
} }
case SignalService.GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES: { case SignalService.GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES: {
const newTimerSeconds = change.updatedExpiration; const newTimerSeconds = change.updatedExpiration;
if ( if (isNumber(newTimerSeconds) && isFinite(newTimerSeconds) && newTimerSeconds >= 0) {
newTimerSeconds &&
isNumber(newTimerSeconds) &&
isFinite(newTimerSeconds) &&
newTimerSeconds >= 0
) {
await convo.updateExpireTimer({ await convo.updateExpireTimer({
providedExpireTimer: newTimerSeconds, providedExpireTimer: newTimerSeconds,
providedSource: author, providedSource: author,

Loading…
Cancel
Save