From 1dbcd157a0495c6a16fb9e6425adb33292de98fa Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 27 Oct 2023 13:12:36 +1100 Subject: [PATCH] feat: add invite failed toast debounced also make the toast replace pubkeys with nicknames/names or shortened pks --- _locales/en/messages.json | 5 + ts/components/basic/SessionToast.tsx | 29 +++- ts/hooks/useParamSelector.ts | 38 +++++- ts/session/conversations/createClosedGroup.ts | 8 +- ts/session/sending/MessageQueue.ts | 4 +- .../utils/job_runners/jobs/GroupInviteJob.ts | 127 ++++++++++++++---- ts/types/LocalizerKeys.ts | 4 + 7 files changed, 178 insertions(+), 37 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0f7c305e6..64e7dd22b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -258,6 +258,11 @@ "groupTwoPromoted": "$first$ and $second$ were promoted to Admin.", "groupOthersPromoted": "$name$ and $count$ others were promoted to Admin.", + "inviteFailed": "Invite Failed", + "groupInviteFailedOne": "Failed to invite $name$ to $groupname$", + "groupInviteFailedTwo": "Failed to invite $first$ and $second$ to $groupname$", + "groupInviteFailedOthers": "Failed to invite $first$ and $count$ others to $groupname$", + "groupOneLeft": "$name$ left the group.", "groupYouLeft": "You left the group.", diff --git a/ts/components/basic/SessionToast.tsx b/ts/components/basic/SessionToast.tsx index d1ae11259..9cbbe8237 100644 --- a/ts/components/basic/SessionToast.tsx +++ b/ts/components/basic/SessionToast.tsx @@ -1,10 +1,12 @@ -import { noop } from 'lodash'; +import { clone, noop } from 'lodash'; import React from 'react'; import styled from 'styled-components'; import { Flex } from './Flex'; +import { useConversationsUsernameWithQuoteOrShortPk } from '../../hooks/useParamSelector'; import { SessionIcon, SessionIconType } from '../icon'; +import { SessionHtmlRenderer } from './SessionHTMLRenderer'; // NOTE We don't change the color strip on the left based on the type. 16/09/2022 export enum SessionToastType { @@ -46,6 +48,29 @@ const IconDiv = styled.div` margin: 0 var(--margins-xs); `; +function useReplacePkInTextWithNames(description: string) { + const pubkeysToLookup = [...description.matchAll(/0[3,5][0-9a-fA-F]{64}/g)] || []; + const memberNames = useConversationsUsernameWithQuoteOrShortPk(pubkeysToLookup.map(m => m[0])); + + let replacedWithNames = clone(description); + for (let index = 0; index < memberNames.length; index++) { + const name = memberNames[index]; + const pk = pubkeysToLookup[index][0]; + replacedWithNames = replacedWithNames.replace(pk, name); + } + + return replacedWithNames; +} + +function DescriptionPubkeysReplaced({ description }: { description: string }) { + const replacedWithNames = useReplacePkInTextWithNames(description); + return ( + + + + ); +} + export const SessionToast = (props: Props) => { const { title, description, type, icon } = props; @@ -93,7 +118,7 @@ export const SessionToast = (props: Props) => { className="session-toast" > {title} - {toastDesc && {toastDesc}} + {toastDesc && } ); diff --git a/ts/hooks/useParamSelector.ts b/ts/hooks/useParamSelector.ts index f40b5c7f2..1a2a569a2 100644 --- a/ts/hooks/useParamSelector.ts +++ b/ts/hooks/useParamSelector.ts @@ -60,18 +60,44 @@ export function useConversationRealName(convoId?: string) { return convoProps?.isPrivate ? convoProps?.displayNameInProfile : undefined; } +function usernameForQuoteOrFullPk(pubkey: string, state: StateType) { + if (pubkey === UserUtils.getOurPubKeyStrFromCache() || pubkey.toLowerCase() === 'you') { + return window.i18n('you'); + } + // use the name from the cached libsession wrappers if available + if (PubKey.isClosedGroupV2(pubkey)) { + const info = state.groups.infos[pubkey]; + if (info && info.name) { + return info.name; + } + } + const convo = state.conversations.conversationLookup[pubkey]; + + const nameGot = convo?.nickname || convo?.displayNameInProfile; + return nameGot?.length ? nameGot : null; +} + /** * Returns either the nickname, the profileName, in '"' or the full pubkeys given */ export function useConversationsUsernameWithQuoteOrFullPubkey(pubkeys: Array) { return useSelector((state: StateType) => { return pubkeys.map(pubkey => { - if (pubkey === UserUtils.getOurPubKeyStrFromCache() || pubkey.toLowerCase() === 'you') { - return window.i18n('you'); - } - const convo = state.conversations.conversationLookup[pubkey]; - const nameGot = convo?.displayNameInProfile; - return nameGot?.length ? `"${nameGot}"` : pubkey; + const nameGot = usernameForQuoteOrFullPk(pubkey, state); + return nameGot?.length ? nameGot : pubkey; + }); + }); +} + +/** + * Returns either the nickname, the profileName, a shortened pubkey, or "you" for our own pubkey + */ +export function useConversationsUsernameWithQuoteOrShortPk(pubkeys: Array) { + return useSelector((state: StateType) => { + return pubkeys.map(pubkey => { + const nameGot = usernameForQuoteOrFullPk(pubkey, state); + + return nameGot?.length ? nameGot : PubKey.shorten(pubkey); }); }); } diff --git a/ts/session/conversations/createClosedGroup.ts b/ts/session/conversations/createClosedGroup.ts index b7a523be4..0668ac285 100644 --- a/ts/session/conversations/createClosedGroup.ts +++ b/ts/session/conversations/createClosedGroup.ts @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import _, { isFinite, isNumber } from 'lodash'; import { ClosedGroup, getMessageQueue } from '..'; import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { addKeyPairToCacheAndDBIfNeeded } from '../../receiver/closedGroups'; @@ -119,7 +119,9 @@ async function sendToGroupMembers( window?.log?.info(`Sending invites for group ${groupPublicKey} to ${listOfMembers}`); // evaluating if all invites sent, if failed give the option to retry failed invites via modal dialog const inviteResults = await Promise.all(promises); - const allInvitesSent = _.every(inviteResults, inviteResult => inviteResult !== false); + const allInvitesSent = _.every(inviteResults, inviteResult => { + return isNumber(inviteResult) && isFinite(inviteResult); + }); if (allInvitesSent) { // if (true) { @@ -157,7 +159,7 @@ async function sendToGroupMembers( inviteResults.forEach((result, index) => { const member = listOfMembers[index]; // group invite must always contain the admin member. - if (result !== true || admins.includes(member)) { + if (result === null || admins.includes(member)) { membersToResend.push(member); } }); diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts index 7a607132c..a1e795d91 100644 --- a/ts/session/sending/MessageQueue.ts +++ b/ts/session/sending/MessageQueue.ts @@ -256,7 +256,7 @@ export class MessageQueue { | ClosedGroupMemberLeftMessage | GroupUpdateInviteMessage; namespace: SnodeNamespaces; - }): Promise { + }): Promise { let rawMessage; try { rawMessage = await MessageUtils.toRawMessage(pubkey, message, namespace); @@ -271,7 +271,7 @@ export class MessageQueue { if (rawMessage) { await MessageSentHandler.handleMessageSentFailure(rawMessage, error); } - return false; + return null; } } diff --git a/ts/session/utils/job_runners/jobs/GroupInviteJob.ts b/ts/session/utils/job_runners/jobs/GroupInviteJob.ts index 5d2e315a0..e0a892a57 100644 --- a/ts/session/utils/job_runners/jobs/GroupInviteJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupInviteJob.ts @@ -1,8 +1,11 @@ import { GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs'; -import { isNumber } from 'lodash'; +import { debounce, difference, isNumber } from 'lodash'; import { v4 } from 'uuid'; -import { UserUtils } from '../..'; -import { UserGroupsWrapperActions } from '../../../../webworker/workers/browser/libsession_worker_interface'; +import { ToastUtils, UserUtils } from '../..'; +import { + MetaGroupWrapperActions, + UserGroupsWrapperActions, +} from '../../../../webworker/workers/browser/libsession_worker_interface'; import { SnodeNamespaces } from '../../../apis/snode_api/namespaces'; import { SnodeGroupSignature } from '../../../apis/snode_api/signature/groupSignature'; import { getMessageQueue } from '../../../sending'; @@ -31,6 +34,14 @@ export function shouldAddGroupInviteJob(args: JobExtraArgs) { return true; } +const invitesFailed = new Map< + GroupPubkeyType, + { + debouncedCall: (groupPk: GroupPubkeyType) => void; + failedMembers: Array; + } +>(); + async function addGroupInviteJob({ groupPk, member }: JobExtraArgs) { if (shouldAddGroupInviteJob({ groupPk, member })) { const groupInviteJob = new GroupInviteJob({ @@ -43,6 +54,42 @@ async function addGroupInviteJob({ groupPk, member }: JobExtraArgs) { } } +function displayFailedInvitesForGroup(groupPk: GroupPubkeyType) { + const thisGroupFailures = invitesFailed.get(groupPk); + if (!thisGroupFailures || thisGroupFailures.failedMembers.length === 0) { + return; + } + const count = thisGroupFailures.failedMembers.length; + switch (count) { + case 1: + ToastUtils.pushToastWarning( + `invite-failed${groupPk}`, + window.i18n('inviteFailed'), + window.i18n('groupInviteFailedOne', [...thisGroupFailures.failedMembers, groupPk]) + ); + break; + case 2: + ToastUtils.pushToastWarning( + `invite-failed${groupPk}`, + window.i18n('inviteFailed'), + window.i18n('groupInviteFailedTwo', [...thisGroupFailures.failedMembers, groupPk]) + ); + break; + default: + ToastUtils.pushToastWarning( + `invite-failed${groupPk}`, + window.i18n('inviteFailed'), + window.i18n('groupInviteFailedOthers', [ + thisGroupFailures.failedMembers[0], + `${thisGroupFailures.failedMembers.length - 1}`, + groupPk, + ]) + ); + } + // toast was displayed empty the list + thisGroupFailures.failedMembers = []; +} + class GroupInviteJob extends PersistedJob { constructor({ groupPk, @@ -75,12 +122,12 @@ class GroupInviteJob extends PersistedJob { } public async run(): Promise { - const { groupPk, member } = this.persistedData; + const { groupPk, member, jobType, identifier } = this.persistedData; window.log.info( - `running job ${this.persistedData.jobType} with groupPk:"${groupPk}" member: ${member} id:"${this.persistedData.identifier}" ` + `running job ${jobType} with groupPk:"${groupPk}" member: ${member} id:"${identifier}" ` ); - const group = await UserGroupsWrapperActions.getGroup(this.persistedData.groupPk); + const group = await UserGroupsWrapperActions.getGroup(groupPk); if (!group || !group.secretKey || !group.name) { window.log.warn(`GroupInviteJob: Did not find group in wrapper or no valid info in wrapper`); return RunJobResult.PermanentFailure; @@ -89,25 +136,31 @@ class GroupInviteJob extends PersistedJob { if (UserUtils.isUsFromCache(member)) { return RunJobResult.Success; // nothing to do for us, we get the update from our user's libsession wrappers } - - const inviteDetails = await SnodeGroupSignature.getGroupInviteMessage({ - groupName: group.name, - member, - secretKey: group.secretKey, - groupPk, - }); - if (!inviteDetails) { - window.log.warn(`GroupInviteJob: Did not find group in wrapper or no valid info in wrapper`); - - return RunJobResult.PermanentFailure; + let failed = true; + try { + const inviteDetails = await SnodeGroupSignature.getGroupInviteMessage({ + groupName: group.name, + member, + secretKey: group.secretKey, + groupPk, + }); + + const storedAt = await getMessageQueue().sendToPubKeyNonDurably({ + message: inviteDetails, + namespace: SnodeNamespaces.Default, + pubkey: PubKey.cast(member), + }); + if (storedAt !== null) { + failed = false; + } + } finally { + updateFailedStateForMember(groupPk, member, failed); + try { + await MetaGroupWrapperActions.memberSetInvited(groupPk, member, failed); + } catch (e) { + window.log.warn('GroupInviteJob memberSetInvited failed with', e.message); + } } - - await getMessageQueue().sendToPubKeyNonDurably({ - message: inviteDetails, - namespace: SnodeNamespaces.Default, - pubkey: PubKey.cast(member), - }); - // return true so this job is marked as a success and we don't need to retry it return RunJobResult.Success; } @@ -142,3 +195,29 @@ export const GroupInvite = { GroupInviteJob, addGroupInviteJob, }; +function updateFailedStateForMember(groupPk: GroupPubkeyType, member: PubkeyType, failed: boolean) { + let thisGroupFailure = invitesFailed.get(groupPk); + + if (!failed) { + // invite sent success, remove a pending failure state from the list of toasts to display + if (thisGroupFailure) { + thisGroupFailure.failedMembers = difference(thisGroupFailure.failedMembers, [member]); + } + + return; + } + // invite sent failed, append the member to that groupFailure member list, and trigger the debounce call + if (!thisGroupFailure) { + thisGroupFailure = { + failedMembers: [], + debouncedCall: debounce(displayFailedInvitesForGroup, 1000), // TODO change to 5000 + }; + } + + if (!thisGroupFailure.failedMembers.includes(member)) { + thisGroupFailure.failedMembers.push(member); + } + + invitesFailed.set(groupPk, thisGroupFailure); + thisGroupFailure.debouncedCall(groupPk); +} diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts index 1c71658d2..17c81014f 100644 --- a/ts/types/LocalizerKeys.ts +++ b/ts/types/LocalizerKeys.ts @@ -190,6 +190,9 @@ export type LocalizerKeys = | 'goToReleaseNotes' | 'goToSupportPage' | 'groupAvatarChange' + | 'groupInviteFailedOne' + | 'groupInviteFailedOthers' + | 'groupInviteFailedTwo' | 'groupMembers' | 'groupNameChange' | 'groupNameChangeFallback' @@ -230,6 +233,7 @@ export type LocalizerKeys = | 'invalidPubkeyFormat' | 'invalidSessionId' | 'inviteContacts' + | 'inviteFailed' | 'join' | 'joinACommunity' | 'joinOpenGroup'