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 { PubkeyType } from 'libsession_util_nodejs';
import { cloneDeep } from 'lodash';
import { useConversationsUsernameWithQuoteOrShortPk } from '../../../../hooks/useParamSelector';
import { arrayContainsUsOnly } from '../../../../models/message';
import { FormatNotifications } from '../../../../notifications/formatNotifications';
import { PreConditionFailed } from '../../../../session/utils/errors';
import {
PropsForGroupUpdate,
@ -15,87 +14,6 @@ import { assertUnreachable } from '../../../../types/sqlSharedTypes';
import { ExpirableReadableMessage } from './ExpirableReadableMessage';
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
const ChangeItemJoined = (added: Array<PubkeyType>, withHistory: boolean): string => {
if (!added.length) {
@ -105,8 +23,8 @@ const ChangeItemJoined = (added: Array<PubkeyType>, withHistory: boolean): strin
const isGroupV2 = useSelectedIsGroupV2();
const us = useOurPkStr();
if (isGroupV2) {
return changeOfMembersV2({
changedWithNames: mapIdsWithNames(added, names),
return FormatNotifications.changeOfMembersV2({
changedWithNames: FormatNotifications.mapIdsWithNames(added, names),
type: withHistory ? 'addedWithHistory' : 'added',
us,
});
@ -123,14 +41,15 @@ const ChangeItemKicked = (removed: Array<PubkeyType>): string => {
const isGroupV2 = useSelectedIsGroupV2();
const us = useOurPkStr();
if (isGroupV2) {
return changeOfMembersV2({
changedWithNames: mapIdsWithNames(removed, names),
return FormatNotifications.changeOfMembersV2({
changedWithNames: FormatNotifications.mapIdsWithNames(removed, names),
type: 'removed',
us,
});
}
if (arrayContainsUsOnly(removed)) {
// legacy groups
if (FormatNotifications.arrayContainsUsOnly(removed)) {
return window.i18n('youGotKickedFromGroup');
}
@ -146,8 +65,8 @@ const ChangeItemPromoted = (promoted: Array<PubkeyType>): string => {
const isGroupV2 = useSelectedIsGroupV2();
const us = useOurPkStr();
if (isGroupV2) {
return changeOfMembersV2({
changedWithNames: mapIdsWithNames(promoted, names),
return FormatNotifications.changeOfMembersV2({
changedWithNames: FormatNotifications.mapIdsWithNames(promoted, names),
type: 'promoted',
us,
});
@ -170,7 +89,7 @@ const ChangeItemLeft = (left: Array<PubkeyType>): string => {
const names = useConversationsUsernameWithQuoteOrShortPk(left);
if (arrayContainsUsOnly(left)) {
if (FormatNotifications.arrayContainsUsOnly(left)) {
return window.i18n('youLeftTheGroup');
}

@ -21,8 +21,10 @@ import {
showRemoveModeratorsByConvoId,
showUpdateGroupMembersByConvoId,
showUpdateGroupNameByConvoId,
triggerFakeAvatarUpdate,
} from '../../../../interactions/conversationInteractions';
import { Constants } from '../../../../session';
import { isDevProd } from '../../../../shared/env_vars';
import { closeRightPanel } from '../../../../state/ducks/conversations';
import { resetRightOverlayMode, setRightOverlayMode } from '../../../../state/ducks/section';
import {
@ -204,6 +206,7 @@ export const OverlayRightPanelSettings = () => {
const isBlocked = useSelectedIsBlocked();
const isKickedFromGroup = useSelectedIsKickedFromGroup();
const isGroup = useSelectedIsGroupOrCommunity();
const isGroupV2 = useSelectedIsGroupV2();
const isPublic = useSelectedIsPublic();
const weAreAdmin = useSelectedWeAreAdmin();
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 && (
<>
<PanelIconButton

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

@ -93,6 +93,13 @@ function usernameForQuoteOrFullPk(pubkey: string, state: StateType) {
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
*/

@ -1,4 +1,4 @@
import { isNil } from 'lodash';
import { isEmpty, isNil } from 'lodash';
import {
ConversationNotificationSettingType,
ConversationTypeEnum,
@ -10,6 +10,7 @@ import { SessionButtonColor } from '../components/basic/SessionButton';
import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings';
import { Data } from '../data/data';
import { SettingsKey } from '../data/settings-key';
import { SignalService } from '../protobuf';
import { GroupV2Receiver } from '../receiver/groupv2/handleGroupV2Message';
import { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi';
import { OpenGroupUtils } from '../session/apis/open_group_api/utils';
@ -19,11 +20,13 @@ import { ConvoHub } from '../session/conversations';
import { getSodiumRenderer } from '../session/crypto';
import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager';
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 { PubKey } from '../session/types';
import { perfEnd, perfStart } from '../session/utils/Performance';
import { sleepFor } from '../session/utils/Promise';
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 { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts';
import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils';
@ -322,6 +325,42 @@ export async function showUpdateGroupNameByConvoId(conversationId: string) {
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) {
const conversation = ConvoHub.use().get(conversationId);
if (conversation.isClosedGroup()) {

@ -131,6 +131,7 @@ import { handleAcceptConversationRequest } from '../interactions/conversationInt
import { DisappearingMessages } from '../session/disappearing_messages';
import { GroupUpdateInfoChangeMessage } from '../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage';
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 { getLibGroupKickedOutsideRedux } from '../state/selectors/userGroups';
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
if (this.isPublic()) {
throw new Error("updateExpireTimer() Disappearing messages aren't supported in communities");
if (!this.isClosedGroup() && !this.isPrivate()) {
throw new Error(
'updateExpireTimer() Disappearing messages are only supported int groups and private chats'
);
}
let expirationMode = providedDisappearingMode;
let expireTimer = providedExpireTimer;
@ -916,14 +919,18 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
* - 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
*/
const shouldAddExpireUpdateMsgGroup =
const shouldAddExpireUpdateMsgLegacyGroup =
isLegacyGroup &&
!fromConfigMessage &&
(expirationMode !== this.get('expirationMode') || expireTimer !== this.get('expireTimer')) &&
expirationMode !== 'off';
const shouldAddExpireUpdateMsgGroupV2 = this.isClosedGroupV2() && !fromConfigMessage;
const shouldAddExpireUpdateMessage =
shouldAddExpireUpdateMsgPrivate || shouldAddExpireUpdateMsgGroup;
shouldAddExpireUpdateMsgPrivate ||
shouldAddExpireUpdateMsgLegacyGroup ||
shouldAddExpireUpdateMsgGroupV2;
// 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.
@ -1106,8 +1113,9 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
updatedExpirationSeconds: expireUpdate.expireTimer,
});
await getMessageQueue().sendToGroupV2({
message: v2groupMessage,
await GroupSync.storeGroupUpdateMessages({
groupPk: this.id,
updateMessages: [v2groupMessage],
});
return true;
}
@ -2094,13 +2102,14 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return;
}
if (message.get('groupInvitation')) {
const groupInvitation = message.get('groupInvitation');
const communityInvitation = message.getCommunityInvitation();
if (communityInvitation && communityInvitation.url) {
const groupInviteMessage = new GroupInvitationMessage({
identifier: id,
createAtNetworkTimestamp: networkTimestamp,
name: groupInvitation.name,
url: groupInvitation.url,
name: communityInvitation.name,
url: communityInvitation.url,
expirationType: chatMessageParams.expirationType,
expireTimer: chatMessageParams.expireTimer,
});

@ -3,16 +3,7 @@ import Backbone from 'backbone';
import autoBind from 'auto-bind';
import filesize from 'filesize';
import { GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs';
import {
cloneDeep,
debounce,
isEmpty,
size as lodashSize,
map,
partition,
pick,
uniq,
} from 'lodash';
import { cloneDeep, debounce, isEmpty, size as lodashSize, partition, pick, uniq } from 'lodash';
import { SignalService } from '../protobuf';
import { getMessageQueue } from '../session';
import { ConvoHub } from '../session/conversations';
@ -29,7 +20,6 @@ import {
uploadQuoteThumbnailsToFileServer,
} from '../session/utils';
import {
DataExtractionNotificationMsg,
MessageAttributes,
MessageAttributesOptionals,
MessageGroupUpdate,
@ -42,10 +32,7 @@ import {
import { Data } from '../data/data';
import { OpenGroupData } from '../data/opengroups';
import { SettingsKey } from '../data/settings-key';
import {
ConversationInteractionStatus,
ConversationInteractionType,
} from '../interactions/conversationInteractions';
import { FormatNotifications } from '../notifications/formatNotifications';
import { isUsAnySogsFromCache } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime';
import { SnodeNamespaces } from '../session/apis/snode_api/namespaces';
@ -96,7 +83,7 @@ import {
} from '../types/MessageAttachment';
import { ReactionList } from '../types/Reaction';
import { getAttachmentMetadata } from '../types/message/initializeAttachmentMetadata';
import { assertUnreachable, roomHasBlindEnabled } from '../types/sqlSharedTypes';
import { roomHasBlindEnabled } from '../types/sqlSharedTypes';
import { LinkPreviews } from '../util/linkPreviews';
import { Notifications } from '../util/notifications';
import { Storage } from '../util/storage';
@ -104,34 +91,6 @@ import { ConversationModel } from './conversation';
import { READ_MESSAGE_STATE } from './conversationAttributes';
// tslint:disable: cyclomatic-complexity
/**
* @returns true if the array contains only a single item being 'You', 'you' or our device pubkey
*/
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> {
constructor(attributes: MessageAttributesOptionals & { skipTimerInit?: boolean }) {
const filledAttrs = fillMessageAttributesWithDefaults(attributes);
@ -258,8 +217,11 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
this.set(attributes);
}
public isGroupInvitation() {
return !!this.get('groupInvitation');
public isCommunityInvitation() {
return !!this.getCommunityInvitation();
}
public getCommunityInvitation() {
return this.get('groupInvitation');
}
public isMessageRequestResponse() {
@ -267,15 +229,22 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
public isDataExtractionNotification() {
return !!this.get('dataExtractionNotification');
return !!this.getDataExtractionNotification();
}
public getDataExtractionNotification() {
return this.get('dataExtractionNotification');
}
public isCallNotification() {
return !!this.get('callNotificationType');
return !!this.getCallNotification();
}
public getCallNotification() {
return this.get('callNotificationType');
}
public isInteractionNotification() {
return !!this.getInteractionNotification();
}
public getInteractionNotification() {
return this.get('interactionNotification');
}
@ -406,10 +375,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
public getPropsForGroupInvitation(): PropsForGroupInvitation | null {
if (!this.isGroupInvitation()) {
const invitation = this.getCommunityInvitation();
if (!invitation || !invitation.url) {
return null;
}
const invitation = this.get('groupInvitation');
let serverAddress = '';
try {
@ -433,7 +402,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
if (!this.isDataExtractionNotification()) {
return null;
}
const dataExtractionNotification = this.get('dataExtractionNotification');
const dataExtractionNotification = this.getDataExtractionNotification();
if (!dataExtractionNotification) {
window.log.warn('dataExtractionNotification should not happen');
@ -477,7 +446,6 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
public getPropsForGroupUpdateMessage(): PropsForGroupUpdate | null {
const groupUpdate = this.getGroupUpdateAsArray();
if (!groupUpdate || isEmpty(groupUpdate)) {
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.
* Using this method to get the group update makes sure than the joined, kicked, or left are always an array of string, or undefined
* 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.
* This is legacy code, our joined, kicked, left, etc should have been saved as an Array for a long time now.
*/
private getGroupUpdateAsArray() {
const groupUpdate = this.get('group_update');
if (!groupUpdate || isEmpty(groupUpdate)) {
return undefined;
}
const left: Array<string> | undefined = Array.isArray(groupUpdate.left)
? groupUpdate.left
: groupUpdate.left
? [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)
const forcedArrayUpdate: MessageGroupUpdate = {};
forcedArrayUpdate.joined = Array.isArray(groupUpdate.joined)
? groupUpdate.joined
: groupUpdate.joined
? [groupUpdate.joined]
: undefined;
const joinedWithHistory: Array<string> | undefined = Array.isArray(
groupUpdate.joinedWithHistory
)
forcedArrayUpdate.joinedWithHistory = Array.isArray(groupUpdate.joinedWithHistory)
? groupUpdate.joinedWithHistory
: groupUpdate.joinedWithHistory
? [groupUpdate.joinedWithHistory]
: 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;
}
private getDescription() {
const groupUpdate = this.getGroupUpdateAsArray();
if (groupUpdate) {
if (arrayContainsUsOnly(groupUpdate.kicked)) {
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 (!isEmpty(groupUpdate)) {
return FormatNotifications.formatGroupUpdateNotification(groupUpdate);
}
if (this.isGroupInvitation()) {
const communityInvitation = this.getCommunityInvitation();
if (communityInvitation) {
return `😎 ${window.i18n('openGroupInvitation')}`;
}
if (this.isDataExtractionNotification()) {
const dataExtraction = this.get(
'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),
]);
const dataExtractionNotification = this.getDataExtractionNotification();
if (dataExtractionNotification) {
return FormatNotifications.formatDataExtractionNotification(dataExtractionNotification);
}
if (this.isCallNotification()) {
const displayName = ConvoHub.use().getContactProfileNameOrShortenedPubKey(
const callNotification = this.getCallNotification();
if (callNotification) {
return FormatNotifications.formatCallNotification(
callNotification,
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();
if (interactionNotification) {
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(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}"`
);
}
}
}
return FormatNotifications.formatInteractionNotification(
interactionNotification,
this.get('conversationId')
);
}
if (this.get('reaction')) {
const reaction = this.get('reaction');
if (reaction && reaction.emoji && reaction.emoji !== '') {
return window.i18n('reactionNotification', [reaction.emoji]);
}
const reaction = this.get('reaction');
if (reaction && reaction.emoji && reaction.emoji !== '') {
return window.i18n('reactionNotification', [reaction.emoji]);
}
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
type: MessageModelType;
group_update?: MessageGroupUpdate;
groupInvitation?: any;
groupInvitation?: { url: string | undefined; name: string } | undefined;
attachments?: any;
conversationId: string;
errors?: any;
@ -160,10 +160,10 @@ export type PropsForMessageRequestResponse = MessageRequestResponseMsg & {
};
export type MessageGroupUpdate = {
left?: Array<string>;
joined?: Array<string>;
joinedWithHistory?: Array<string>;
kicked?: Array<string>;
left?: Array<PubkeyType>;
joined?: Array<PubkeyType>;
joinedWithHistory?: Array<PubkeyType>;
kicked?: Array<PubkeyType>;
promoted?: Array<PubkeyType>;
name?: string;
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
type: MessageModelType;
group_update?: MessageGroupUpdate;
groupInvitation?: any;
groupInvitation?: { url: string | undefined; name: string } | undefined;
attachments?: any;
conversationId: string;
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: {
const newTimerSeconds = change.updatedExpiration;
if (
newTimerSeconds &&
isNumber(newTimerSeconds) &&
isFinite(newTimerSeconds) &&
newTimerSeconds >= 0
) {
if (isNumber(newTimerSeconds) && isFinite(newTimerSeconds) && newTimerSeconds >= 0) {
await convo.updateExpireTimer({
providedExpireTimer: newTimerSeconds,
providedSource: author,

Loading…
Cancel
Save