diff --git a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx index d6c25980a..6a6a9c989 100644 --- a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx +++ b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx @@ -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, names: Array): Array { - 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, - us: PubkeyType -): { - sortedWithUsFirst: Array; -} { - 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; - 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, withHistory: boolean): string => { if (!added.length) { @@ -105,8 +23,8 @@ const ChangeItemJoined = (added: Array, 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): 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): 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): string => { const names = useConversationsUsernameWithQuoteOrShortPk(left); - if (arrayContainsUsOnly(left)) { + if (FormatNotifications.arrayContainsUsOnly(left)) { return window.i18n('youLeftTheGroup'); } diff --git a/ts/components/conversation/right-panel/overlay/OverlayRightPanelSettings.tsx b/ts/components/conversation/right-panel/overlay/OverlayRightPanelSettings.tsx index e6e68abb7..78980902c 100644 --- a/ts/components/conversation/right-panel/overlay/OverlayRightPanelSettings.tsx +++ b/ts/components/conversation/right-panel/overlay/OverlayRightPanelSettings.tsx @@ -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 ? ( + { + void triggerFakeAvatarUpdate(selectedConvoKey); + }} + dataTestId="edit-group-name" + /> + ) : null} + {showAddRemoveModeratorsButton && ( <> { if (isEmpty(text)) { return null; } + const withoutHtmlTags = text.replaceAll(/(<([^>]+)>)/gi, ''); return (
@@ -54,7 +55,12 @@ export const MessageItem = () => { {isConvoTyping ? ( ) : ( - + )}
{!isSearchingMode && lastMessage && lastMessage.status && !isMessageRequest ? ( diff --git a/ts/hooks/useParamSelector.ts b/ts/hooks/useParamSelector.ts index 6b6984876..3f7e61b8f 100644 --- a/ts/hooks/useParamSelector.ts +++ b/ts/hooks/useParamSelector.ts @@ -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 */ diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 6482373e6..00baaf519 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -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()) { diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index c075da9b1..899443e6e 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -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 { // 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 { * - 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 { 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 { 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, }); diff --git a/ts/models/message.ts b/ts/models/message.ts index 27845722e..8362c9aea 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -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 | undefined) { - return ( - arrayToCheck && - arrayToCheck.length === 1 && - (arrayToCheck[0] === UserUtils.getOurPubKeyStrFromCache() || - arrayToCheck[0].toLowerCase() === 'you') - ); -} - -export function arrayContainsOneItemOnly(arrayToCheck: Array | undefined) { - return arrayToCheck && arrayToCheck.length === 1; -} - -function formatJoined(joined: Array) { - 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 { constructor(attributes: MessageAttributesOptionals & { skipTimerInit?: boolean }) { const filledAttrs = fillMessageAttributesWithDefaults(attributes); @@ -258,8 +217,11 @@ export class MessageModel extends Backbone.Model { 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 { } 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 { } 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 { 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 { public getPropsForGroupUpdateMessage(): PropsForGroupUpdate | null { const groupUpdate = this.getGroupUpdateAsArray(); - if (!groupUpdate || isEmpty(groupUpdate)) { return null; } @@ -1267,179 +1235,84 @@ export class MessageModel extends Backbone.Model { } /** - * 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 | undefined = Array.isArray(groupUpdate.left) - ? groupUpdate.left - : groupUpdate.left - ? [groupUpdate.left] - : undefined; - const kicked: Array | undefined = Array.isArray(groupUpdate.kicked) - ? groupUpdate.kicked - : groupUpdate.kicked - ? [groupUpdate.kicked] - : undefined; - const joined: Array | undefined = Array.isArray(groupUpdate.joined) + const forcedArrayUpdate: MessageGroupUpdate = {}; + + forcedArrayUpdate.joined = Array.isArray(groupUpdate.joined) ? groupUpdate.joined : groupUpdate.joined ? [groupUpdate.joined] : undefined; - const joinedWithHistory: Array | 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'); } diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index bcaeea3ac..8070dda76 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -40,7 +40,7 @@ export interface MessageAttributes { read_by: Array; // 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; - joined?: Array; - joinedWithHistory?: Array; - kicked?: Array; + left?: Array; + joined?: Array; + joinedWithHistory?: Array; + kicked?: Array; promoted?: Array; name?: string; avatarChange?: boolean; @@ -188,7 +188,7 @@ export interface MessageAttributesOptionals { read_by?: Array; // 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; diff --git a/ts/notifications/formatNotifications.ts b/ts/notifications/formatNotifications.ts new file mode 100644 index 000000000..2ffa67bf7 --- /dev/null +++ b/ts/notifications/formatNotifications.ts @@ -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 | 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, + us: PubkeyType +): { + sortedWithUsFirst: Array; +} { + 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; + 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, names: Array): Array { + 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, +}; diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index 16ad8d0fa..78ceffddb 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -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,