From 027b412fb2dfb639226bcad7401b87830f477d0e Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 14 Jun 2023 16:03:02 +1000 Subject: [PATCH] feat: save conversation interaction errors to a message history --- .../conversation/SessionMessagesList.tsx | 8 ++ .../message-item/InteractionNotification.tsx | 76 +++++++++++++++++++ ts/interactions/conversationInteractions.ts | 49 ++++++++++-- ts/models/message.ts | 65 +++++++++++++++- ts/models/messageType.ts | 8 ++ ts/state/ducks/conversations.ts | 14 ++++ ts/state/selectors/conversations.ts | 16 +++- 7 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 ts/components/conversation/message/message-item/InteractionNotification.tsx diff --git a/ts/components/conversation/SessionMessagesList.tsx b/ts/components/conversation/SessionMessagesList.tsx index 457c705d2..043d955e5 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -11,6 +11,7 @@ import { PropsForExpirationTimer, PropsForGroupInvitation, PropsForGroupUpdate, + PropsForInteractionNotification, } from '../../state/ducks/conversations'; import { getOldBottomMessageId, @@ -28,6 +29,7 @@ import { useSelectedConversationKey } from '../../state/selectors/selectedConver import { DataExtractionNotification } from './message/message-item/DataExtractionNotification'; import { SessionLastSeenIndicator } from './SessionLastSeenIndicator'; import { TimerNotification } from './TimerNotification'; +import { InteractionNotification } from './message/message-item/InteractionNotification'; function isNotTextboxEvent(e: KeyboardEvent) { return (e?.target as any)?.type === undefined; @@ -155,6 +157,12 @@ export const SessionMessagesList = (props: { return [, ...componentToMerge]; } + if (messageProps.message?.messageType === 'interaction-notification') { + const msgProps = messageProps.message.props as PropsForInteractionNotification; + + return [, ...componentToMerge]; + } + if (!messageProps) { return null; } diff --git a/ts/components/conversation/message/message-item/InteractionNotification.tsx b/ts/components/conversation/message/message-item/InteractionNotification.tsx new file mode 100644 index 000000000..6d7b2f7e3 --- /dev/null +++ b/ts/components/conversation/message/message-item/InteractionNotification.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +import { useIsPrivate, useIsPublic } from '../../../../hooks/useParamSelector'; +import { + ConversationInteractionStatus, + ConversationInteractionType, +} from '../../../../interactions/conversationInteractions'; +import styled from 'styled-components'; +import { assertUnreachable } from '../../../../types/sqlSharedTypes'; +import { isEmpty } from 'lodash'; +import { Flex } from '../../../basic/Flex'; +import { PropsForInteractionNotification } from '../../../../state/ducks/conversations'; +import { ReadableMessage } from './ReadableMessage'; + +const StyledFailText = styled.div` + color: var(--danger-color); +`; + +export const InteractionNotification = (props: PropsForInteractionNotification) => { + const { notificationType, convoId, messageId, receivedAt, isUnread } = props; + + const { interactionStatus, interactionType } = notificationType; + + const isGroup = !useIsPrivate(convoId); + const isCommunity = useIsPublic(convoId); + + // NOTE For now we only show interaction errors in the message history + if (interactionStatus !== ConversationInteractionStatus.Error) { + return null; + } + + let text = ''; + + switch (interactionType) { + case ConversationInteractionType.Hide: + text = window.i18n('hideConversationFailed'); + break; + case ConversationInteractionType.Leave: + text = isCommunity + ? window.i18n('leaveCommunityFailed') + : isGroup + ? window.i18n('leaveGroupFailed') + : window.i18n('deleteConversationFailed'); + break; + default: + assertUnreachable( + interactionType, + `InteractionErrorMessage: Missing case error "${interactionType}"` + ); + } + + if (isEmpty(text)) { + return null; + } + + return ( + + + {text} + + + ); +}; diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index a42e702c9..02caf8352 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -1,6 +1,7 @@ import { ConversationNotificationSettingType, ConversationTypeEnum, + READ_MESSAGE_STATE, } from '../models/conversationAttributes'; import { CallManager, SyncUtils, ToastUtils, UserUtils } from '../session/utils'; @@ -42,6 +43,7 @@ import { encryptProfile } from '../util/crypto/profileEncrypter'; import { ReleasedFeatures } from '../util/releaseFeature'; import { Storage, setLastProfileUpdateTimestamp } from '../util/storage'; import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface'; +import { ConversationModel } from '../models/conversation'; export enum ConversationInteractionStatus { Start = 'start', @@ -274,10 +276,11 @@ export function showLeavePrivateConversationbyConvoId( await clearConversationInteractionState({ conversationId }); } catch (err) { window.log.warn(`showLeavePrivateConversationbyConvoId error: ${err}`); - await updateConversationInteractionState({ + await handleConversationInteractionError({ conversationId, - type: isMe ? ConversationInteractionType.Hide : ConversationInteractionType.Leave, - status: ConversationInteractionStatus.Error, + interactionType: isMe + ? ConversationInteractionType.Hide + : ConversationInteractionType.Leave, }); } }; @@ -339,10 +342,9 @@ export function showLeaveGroupByConvoId(conversationId: string, name: string | u await clearConversationInteractionState({ conversationId }); } catch (err) { window.log.warn(`showLeaveGroupByConvoId error: ${err}`); - await updateConversationInteractionState({ + await handleConversationInteractionError({ conversationId, - type: ConversationInteractionType.Leave, - status: ConversationInteractionStatus.Error, + interactionType: ConversationInteractionType.Leave, }); } }; @@ -734,3 +736,38 @@ export async function clearConversationInteractionState({ window.log.debug(`WIP: clearConversationInteractionState() for ${conversationId}`); } } + +async function handleConversationInteractionError({ + conversationId, + interactionType, +}: { + conversationId: string; + interactionType: ConversationInteractionType; +}) { + const conversation = getConversationController().get(conversationId); + if (!conversation) { + return; + } + + const interactionStatus = ConversationInteractionStatus.Error; + + await updateConversationInteractionState({ + conversationId, + type: interactionType, + status: interactionStatus, + }); + + // Add an error message to the database so we can view it in the message history + await conversation?.addSingleIncomingMessage({ + source: UserUtils.getOurPubKeyStrFromCache(), + sent_at: Date.now(), + interactionNotification: { + interactionType, + interactionStatus, + }, + unread: READ_MESSAGE_STATE.read, + expireTimer: 0, + }); + + conversation.updateLastMessage(); +} diff --git a/ts/models/message.ts b/ts/models/message.ts index c0281c84a..b72064bd8 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -93,8 +93,12 @@ import { LinkPreviews } from '../util/linkPreviews'; import { Notifications } from '../util/notifications'; import { Storage } from '../util/storage'; import { ConversationModel } from './conversation'; -import { roomHasBlindEnabled } from '../types/sqlSharedTypes'; +import { assertUnreachable, roomHasBlindEnabled } from '../types/sqlSharedTypes'; import { READ_MESSAGE_STATE } from './conversationAttributes'; +import { + ConversationInteractionStatus, + ConversationInteractionType, +} from '../interactions/conversationInteractions'; // tslint:disable: cyclomatic-complexity /** @@ -145,6 +149,7 @@ export class MessageModel extends Backbone.Model { const propsForTimerNotification = this.getPropsForTimerNotification(); const propsForMessageRequestResponse = this.getPropsForMessageRequestResponse(); const callNotificationType = this.get('callNotificationType'); + const interactionNotification = this.get('interactionNotification'); const messageProps: MessageModelPropsWithoutConvoProps = { propsForMessage: this.getPropsForMessage(), }; @@ -172,6 +177,17 @@ export class MessageModel extends Backbone.Model { isUnread: this.isUnread(), }; } + + if (interactionNotification) { + messageProps.propsForInteractionNotification = { + notificationType: interactionNotification, + convoId: this.get('conversationId'), + messageId: this.id, + receivedAt: this.get('received_at') || Date.now(), + isUnread: this.isUnread(), + }; + } + perfEnd(`getPropsMessage-${this.id}`, 'getPropsMessage'); return messageProps; } @@ -434,7 +450,11 @@ export class MessageModel extends Backbone.Model { return undefined; } - if (this.isDataExtractionNotification() || this.get('callNotificationType')) { + if ( + this.isDataExtractionNotification() || + this.get('callNotificationType') || + this.get('interactionNotification') + ) { return undefined; } @@ -1273,6 +1293,7 @@ export class MessageModel extends Backbone.Model { if (arrayContainsUsOnly(groupUpdate.kicked)) { return window.i18n('youGotKickedFromGroup'); } + if (arrayContainsUsOnly(groupUpdate.left)) { return window.i18n('youLeftTheGroup'); } @@ -1284,12 +1305,15 @@ export class MessageModel extends Backbone.Model { } const messages = []; + if (!groupUpdate.name && !groupUpdate.joined && !groupUpdate.kicked && !groupUpdate.kicked) { return window.i18n('updatedTheGroup'); // Group Updated } + if (groupUpdate.name) { return window.i18n('titleIsNow', [groupUpdate.name]); } + if (groupUpdate.joined && groupUpdate.joined.length) { const names = groupUpdate.joined.map( getConversationController().getContactProfileNameOrShortenedPubKey @@ -1317,9 +1341,11 @@ export class MessageModel extends Backbone.Model { } return messages.join(' '); } + if (this.isIncoming() && this.hasErrors()) { return window.i18n('incomingError'); } + if (this.isGroupInvitation()) { return `😎 ${window.i18n('openGroupInvitation')}`; } @@ -1338,6 +1364,7 @@ export class MessageModel extends Backbone.Model { getConversationController().getContactProfileNameOrShortenedPubKey(dataExtraction.source), ]); } + if (this.get('callNotificationType')) { const displayName = getConversationController().getContactProfileNameOrShortenedPubKey( this.get('conversationId') @@ -1353,6 +1380,40 @@ export class MessageModel extends Backbone.Model { return window.i18n('answeredACall', [displayName]); } } + + if (this.get('interactionNotification')) { + const interactionNotification = this.get('interactionNotification'); + if (interactionNotification) { + const { interactionType, interactionStatus } = interactionNotification; + + // NOTE For now we only show interaction errors in the message history + if (interactionStatus === ConversationInteractionStatus.Error) { + const convo = getConversationController().get(this.get('conversationId')); + + if (convo) { + const isGroup = !convo.isPrivate(); + const isCommunity = convo.isPublic(); + + switch (interactionType) { + case ConversationInteractionType.Hide: + return window.i18n('hideConversationFailed'); + 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'); if (reaction && reaction.emoji && reaction.emoji !== '') { diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index 74728ba2d..e6ff51ffd 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -2,6 +2,7 @@ import { defaultsDeep } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { CallNotificationType, + InteractionNotificationType, LastMessageStatusType, PropsForMessageWithConvoProps, } from '../state/ducks/conversations'; @@ -115,6 +116,12 @@ export interface MessageAttributes { isDeleted?: boolean; callNotificationType?: CallNotificationType; + + /** + * This is used when a user has performed an interaction (hiding, leaving, etc.) on a conversation. At the moment, this is only used for showing interaction errors. + * Will 14/06/2023 + */ + interactionNotification?: InteractionNotificationType; } export interface DataExtractionNotificationMsg { @@ -214,6 +221,7 @@ export interface MessageAttributesOptionals { messageHash?: string; isDeleted?: boolean; callNotificationType?: CallNotificationType; + interactionNotification?: InteractionNotificationType; } /** diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index d147daf92..ca2536f03 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -37,6 +37,7 @@ export type MessageModelPropsWithoutConvoProps = { propsForGroupUpdateMessage?: PropsForGroupUpdate; propsForCallNotification?: PropsForCallNotification; propsForMessageRequestResponse?: PropsForMessageRequestResponse; + propsForInteractionNotification?: PropsForInteractionNotification; }; export type MessageModelPropsWithConvoProps = SortedMessageModelProps & { @@ -165,6 +166,14 @@ export type PropsForAttachment = { } | null; }; +export type PropsForInteractionNotification = { + notificationType: InteractionNotificationType; + convoId: string; + messageId: string; + receivedAt: number; + isUnread: boolean; +}; + export type PropsForMessageWithoutConvoProps = { id: string; // messageId direction: MessageModelType; @@ -222,6 +231,11 @@ export type LastMessageType = { text: string | null; }; +export type InteractionNotificationType = { + interactionType: ConversationInteractionType; + interactionStatus: ConversationInteractionStatus; +}; + /** * This closely matches ConversationAttributes except making a lot of fields optional. * The size of the redux store is an issue considering the number of conversations we have, so having optional fields here diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 1d3716f15..afa0f4797 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -112,7 +112,8 @@ export type MessagePropsType = | 'timer-notification' | 'regular-message' | 'unread-indicator' - | 'call-notification'; + | 'call-notification' + | 'interaction-notification'; export const getSortedMessagesTypesOfSelectedConversation = createSelector( getSortedMessagesOfSelectedConversation, @@ -202,6 +203,19 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector( }; } + if (msg.propsForInteractionNotification) { + return { + ...common, + message: { + messageType: 'interaction-notification', + props: { + ...msg.propsForInteractionNotification, + messageId: msg.propsForMessage.id, + }, + }, + }; + } + return { showUnreadIndicator: isFirstUnread, showDateBreak,