From cf899ee18c0f001d5664273c7313d90bde2587b5 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 12 Nov 2024 16:34:51 +1100 Subject: [PATCH] fix: add group members sorting not good but hopefully we won't have to keep for too long --- ts/components/MemberListItem.tsx | 43 +---- .../dialog/UpdateGroupMembersDialog.tsx | 20 ++- .../SwarmPollingGroupConfig.ts | 4 +- .../conversations/ConversationController.ts | 2 +- ts/session/utils/job_runners/PersistedJob.ts | 1 + .../utils/job_runners/jobs/GroupInviteJob.ts | 38 +++- ts/state/ducks/metaGroups.ts | 20 +-- ts/state/selectors/groups.ts | 167 ++++++++++++++++-- .../libsession_wrapper_metagroup_test.ts | 16 +- 9 files changed, 223 insertions(+), 88 deletions(-) diff --git a/ts/components/MemberListItem.tsx b/ts/components/MemberListItem.tsx index 6cdd70a94..4db539d7b 100644 --- a/ts/components/MemberListItem.tsx +++ b/ts/components/MemberListItem.tsx @@ -13,11 +13,10 @@ import { useMemberInviteFailed, useMemberInviteSending, useMemberInviteSent, - useMemberIsPromoted, useMemberPromoteSending, useMemberPromotionFailed, - useMemberPromotionNotSent, useMemberPromotionSent, + useMemberIsNominatedAdmin, } from '../state/selectors/groups'; import { Avatar, AvatarSize, CrownIcon } from './avatar/Avatar'; import { Flex } from './basic/Flex'; @@ -28,10 +27,6 @@ import { SessionButtonType, } from './basic/SessionButton'; import { SessionRadio } from './basic/SessionRadio'; -import { GroupSync } from '../session/utils/job_runners/jobs/GroupSyncJob'; -import { RunJobResult } from '../session/utils/job_runners/PersistedJob'; -import { SubaccountUnrevokeSubRequest } from '../session/apis/snode_api/SnodeRequestTypes'; -import { NetworkTime } from '../util/NetworkTime'; import { MetaGroupWrapperActions, UserGroupsWrapperActions, @@ -221,15 +216,10 @@ const GroupStatusContainer = ({ const ResendButton = ({ groupPk, pubkey }: { pubkey: PubkeyType; groupPk: GroupPubkeyType }) => { const acceptedInvite = useMemberHasAcceptedInvite(pubkey, groupPk); - const promotionFailed = useMemberPromotionFailed(pubkey, groupPk); - const promotionSent = useMemberPromotionSent(pubkey, groupPk); - const promotionNotSent = useMemberPromotionNotSent(pubkey, groupPk); - const promoted = useMemberIsPromoted(pubkey, groupPk); + const nominatedAdmin = useMemberIsNominatedAdmin(pubkey, groupPk); // as soon as the `admin` flag is set in the group for that member, we should be able to resend a promote as we cannot remove an admin. - const canResendPromotion = - hasClosedGroupV2QAButtons() && - (promotionFailed || promotionSent || promotionNotSent || promoted); + const canResendPromotion = hasClosedGroupV2QAButtons() && nominatedAdmin; // we can always remove/and readd a non-admin member. So we consider that a member who accepted the invite cannot be resent an invite. const canResendInvite = !acceptedInvite; @@ -252,32 +242,13 @@ const ResendButton = ({ groupPk, pubkey }: { pubkey: PubkeyType; groupPk: GroupP window.log.warn('tried to resend invite but we do not have correct details'); return; } - const token = await MetaGroupWrapperActions.swarmSubAccountToken(groupPk, pubkey); - const unrevokeSubRequest = new SubaccountUnrevokeSubRequest({ - groupPk, - revokeTokenHex: [token], - timestamp: NetworkTime.now(), - secretKey: group.secretKey, - }); - const sequenceResult = await GroupSync.pushChangesToGroupSwarmIfNeeded({ - groupPk, - unrevokeSubRequest, - extraStoreRequests: [], - }); - if (sequenceResult !== RunJobResult.Success) { - throw new Error('resend invite: pushChangesToGroupSwarmIfNeeded did not return success'); - } - // if we tried to invite that member as admin right away, let's retry it as such. - const inviteAsAdmin = - member.promotionNotSent || - member.promotionFailed || - member.promotionPending || - member.promoted; + const inviteAsAdmin = member.nominatedAdmin; await GroupInvite.addJob({ groupPk, member: pubkey, inviteAsAdmin, + forceUnrevoke: true, }); }} /> @@ -286,11 +257,11 @@ const ResendButton = ({ groupPk, pubkey }: { pubkey: PubkeyType; groupPk: GroupP const PromoteButton = ({ groupPk, pubkey }: { pubkey: PubkeyType; groupPk: GroupPubkeyType }) => { const memberAcceptedInvite = useMemberHasAcceptedInvite(pubkey, groupPk); - const memberIsPromoted = useMemberIsPromoted(pubkey, groupPk); + const memberIsNominatedAdmin = useMemberIsNominatedAdmin(pubkey, groupPk); // When invite-as-admin was used to invite that member, the resend button is available to resend the promote message. // We want to show that button only to promote a normal member who accepted a normal invite but wasn't promoted yet. // ^ this is only the case for testing. The UI will be different once we release the promotion process - if (!hasClosedGroupV2QAButtons() || !memberAcceptedInvite || memberIsPromoted) { + if (!hasClosedGroupV2QAButtons() || !memberAcceptedInvite || memberIsNominatedAdmin) { return null; } return ( diff --git a/ts/components/dialog/UpdateGroupMembersDialog.tsx b/ts/components/dialog/UpdateGroupMembersDialog.tsx index 3558fad20..63081243d 100644 --- a/ts/components/dialog/UpdateGroupMembersDialog.tsx +++ b/ts/components/dialog/UpdateGroupMembersDialog.tsx @@ -27,7 +27,10 @@ import { ClosedGroup } from '../../session/group/closed-group'; import { PubKey } from '../../session/types'; import { hasClosedGroupV2QAButtons } from '../../shared/env_vars'; import { groupInfoActions } from '../../state/ducks/metaGroups'; -import { useMemberGroupChangePending } from '../../state/selectors/groups'; +import { + useMemberGroupChangePending, + useStateOf03GroupMembers, +} from '../../state/selectors/groups'; import { useSelectedIsGroupV2 } from '../../state/selectors/selectedConversation'; import { SessionSpinner } from '../loading'; import { SessionToggle } from '../basic/SessionToggle'; @@ -36,7 +39,7 @@ type Props = { conversationId: string; }; -const StyledClassicMemberList = styled.div` +const StyledMemberList = styled.div` max-height: 240px; `; @@ -44,7 +47,7 @@ const StyledClassicMemberList = styled.div` * Admins are always put first in the list of group members. * Also, admins have a little crown on their avatar. */ -const ClassicMemberList = (props: { +const MemberList = (props: { convoId: string; selectedMembers: Array; onSelect: (m: string) => void; @@ -56,12 +59,15 @@ const ClassicMemberList = (props: { const groupAdmins = useGroupAdmins(convoId); const groupMembers = useSortedGroupMembers(convoId); + const groupMembers03Group = useStateOf03GroupMembers(convoId); - const sortedMembers = useMemo( + const sortedMembersNon03 = useMemo( () => [...groupMembers].sort(m => (groupAdmins?.includes(m) ? -1 : 0)), [groupMembers, groupAdmins] ); + const sortedMembers = isV2Group ? groupMembers03Group.map(m => m.pubkeyHex) : sortedMembersNon03; + return ( <> {sortedMembers.map(member => { @@ -230,14 +236,14 @@ export const UpdateGroupMembersDialog = (props: Props) => { /> ) : null} - - + - + {showNoMembersMessage &&

{window.i18n('groupMembersNone')}

} diff --git a/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts b/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts index b102826f2..36207386a 100644 --- a/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts +++ b/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts @@ -125,11 +125,11 @@ async function handleMetaMergeResults(groupPk: GroupPubkeyType) { } } // mark ourselves as accepting the invite if needed - if (usMember?.invitePending && keysAlreadyHaveAdmin) { + if (usMember?.memberStatus === 'INVITE_SENT' && keysAlreadyHaveAdmin) { await MetaGroupWrapperActions.memberSetAccepted(groupPk, us); } // mark ourselves as accepting the promotion if needed - if (usMember?.promotionPending && keysAlreadyHaveAdmin) { + if (usMember?.memberStatus === 'PROMOTION_SENT' && keysAlreadyHaveAdmin) { await MetaGroupWrapperActions.memberSetPromotionAccepted(groupPk, us); } // this won't do anything if there is no need for a sync, so we can safely plan one diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index 197763c17..a11f372ad 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -327,7 +327,7 @@ class ConvoController { const us = UserUtils.getOurPubKeyStrFromCache(); const allMembers = await MetaGroupWrapperActions.memberGetAll(groupPk); const otherAdminsCount = allMembers - .filter(m => m.promoted) + .filter(m => m.nominatedAdmin) .filter(m => m.pubkeyHex !== us).length; const weAreLastAdmin = otherAdminsCount === 0; const infos = await MetaGroupWrapperActions.infoGet(groupPk); diff --git a/ts/session/utils/job_runners/PersistedJob.ts b/ts/session/utils/job_runners/PersistedJob.ts index 290f285d4..c52a4ccc5 100644 --- a/ts/session/utils/job_runners/PersistedJob.ts +++ b/ts/session/utils/job_runners/PersistedJob.ts @@ -43,6 +43,7 @@ export interface GroupInvitePersistedData extends PersistedJobData { groupPk: GroupPubkeyType; member: PubkeyType; inviteAsAdmin: boolean; + forceUnrevoke: boolean; } export interface GroupPromotePersistedData extends PersistedJobData { diff --git a/ts/session/utils/job_runners/jobs/GroupInviteJob.ts b/ts/session/utils/job_runners/jobs/GroupInviteJob.ts index d9413e805..ed2b413e6 100644 --- a/ts/session/utils/job_runners/jobs/GroupInviteJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupInviteJob.ts @@ -21,6 +21,9 @@ import { LibSessionUtil } from '../../libsession/libsession_utils'; import { showUpdateGroupMembersByConvoId } from '../../../../interactions/conversationInteractions'; import { ConvoHub } from '../../../conversations'; import { MessageQueue } from '../../../sending'; +import { NetworkTime } from '../../../../util/NetworkTime'; +import { SubaccountUnrevokeSubRequest } from '../../../apis/snode_api/SnodeRequestTypes'; +import { GroupSync } from './GroupSyncJob'; const defaultMsBetweenRetries = 10000; const defaultMaxAttempts = 1; @@ -29,6 +32,12 @@ type JobExtraArgs = { groupPk: GroupPubkeyType; member: PubkeyType; inviteAsAdmin: boolean; + /** + * When inviting a member, we usually only want to sent a message to his swarm. + * In the case of a invitation resend process though, we also want to make sure his token is unrevoked from the group's swarm. + * + */ + forceUnrevoke: boolean; }; export function shouldAddJob(args: JobExtraArgs) { @@ -47,12 +56,13 @@ const invitesFailed = new Map< } >(); -async function addJob({ groupPk, member, inviteAsAdmin }: JobExtraArgs) { - if (shouldAddJob({ groupPk, member, inviteAsAdmin })) { +async function addJob({ groupPk, member, inviteAsAdmin, forceUnrevoke }: JobExtraArgs) { + if (shouldAddJob({ groupPk, member, inviteAsAdmin, forceUnrevoke })) { const groupInviteJob = new GroupInviteJob({ groupPk, member, inviteAsAdmin, + forceUnrevoke, nextAttemptTimestamp: Date.now(), }); window.log.debug(`addGroupInviteJob: adding group invite for ${groupPk}:${member} `); @@ -135,8 +145,9 @@ class GroupInviteJob extends PersistedJob { nextAttemptTimestamp, maxAttempts, currentRetry, + forceUnrevoke, identifier, - }: Pick & + }: Pick & Partial< Pick< GroupInvitePersistedData, @@ -153,6 +164,7 @@ class GroupInviteJob extends PersistedJob { member, groupPk, inviteAsAdmin, + forceUnrevoke, delayBetweenRetries: defaultMsBetweenRetries, maxAttempts: isNumber(maxAttempts) ? maxAttempts : defaultMaxAttempts, nextAttemptTimestamp: nextAttemptTimestamp || Date.now() + defaultMsBetweenRetries, @@ -177,6 +189,26 @@ class GroupInviteJob extends PersistedJob { } let failed = true; try { + if (this.persistedData.forceUnrevoke) { + const token = await MetaGroupWrapperActions.swarmSubAccountToken(groupPk, member); + const unrevokeSubRequest = new SubaccountUnrevokeSubRequest({ + groupPk, + revokeTokenHex: [token], + timestamp: NetworkTime.now(), + secretKey: group.secretKey, + }); + const sequenceResult = await GroupSync.pushChangesToGroupSwarmIfNeeded({ + groupPk, + unrevokeSubRequest, + extraStoreRequests: [], + }); + if (sequenceResult !== RunJobResult.Success) { + throw new Error( + 'GroupInviteJob: SubaccountUnrevokeSubRequest push() did not return success' + ); + } + } + const inviteDetails = inviteAsAdmin ? await SnodeGroupSignature.getGroupPromoteMessage({ groupName: group.name, diff --git a/ts/state/ducks/metaGroups.ts b/ts/state/ducks/metaGroups.ts index ef57e4a31..b2cac4c71 100644 --- a/ts/state/ducks/metaGroups.ts +++ b/ts/state/ducks/metaGroups.ts @@ -87,7 +87,7 @@ async function checkWeAreAdmin(groupPk: GroupPubkeyType) { const usInGroup = await MetaGroupWrapperActions.memberGet(groupPk, us); const inUserGroup = await UserGroupsWrapperActions.getGroup(groupPk); // if the secretKey is not empty AND we are a member of the group, we are a current admin - return Boolean(!isEmpty(inUserGroup?.secretKey) && usInGroup?.promoted); + return Boolean(!isEmpty(inUserGroup?.secretKey) && usInGroup?.nominatedAdmin); } async function checkWeAreAdminOrThrow(groupPk: GroupPubkeyType, context: string) { @@ -243,14 +243,12 @@ const initNewGroupInWrapper = createAsyncThunk( // privately and asynchronously, and gracefully handle errors with toasts. // Let's do all of this part of a job to handle app crashes and make sure we // can update the group wrapper with a failed state if a message fails to be sent. - for (let index = 0; index < membersFromWrapper.length; index++) { - const member = membersFromWrapper[index]; - await GroupInvite.addJob({ - member: member.pubkeyHex, - groupPk, - inviteAsAdmin: window.sessionFeatureFlags.useGroupV2InviteAsAdmin, - }); - } + await scheduleGroupInviteJobs( + groupPk, + membersFromWrapper.map(m => m.pubkeyHex), + [], + window.sessionFeatureFlags.useGroupV2InviteAsAdmin + ); await openConversationWithMessages({ conversationKey: groupPk, messageId: null }); @@ -1429,6 +1427,8 @@ async function scheduleGroupInviteJobs( const merged = uniq(concat(withHistory, withoutHistory)); for (let index = 0; index < merged.length; index++) { const member = merged[index]; - await GroupInvite.addJob({ groupPk, member, inviteAsAdmin }); + // Note: forceUnrevoke is false, because `scheduleGroupInviteJobs` is always called after we've done + // a batch unrevoke of all the members' pk + await GroupInvite.addJob({ groupPk, member, inviteAsAdmin, forceUnrevoke: false }); } } diff --git a/ts/state/selectors/groups.ts b/ts/state/selectors/groups.ts index e1e7d6bb0..dc1f8a312 100644 --- a/ts/state/selectors/groups.ts +++ b/ts/state/selectors/groups.ts @@ -1,8 +1,17 @@ -import { GroupMemberGet, GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs'; +import { + GroupMemberGet, + GroupPubkeyType, + MemberStateGroupV2, + PubkeyType, +} from 'libsession_util_nodejs'; import { useSelector } from 'react-redux'; +import { compact, concat, differenceBy, sortBy, uniqBy } from 'lodash'; import { PubKey } from '../../session/types'; import { GroupState } from '../ducks/metaGroups'; import { StateType } from '../reducer'; +import { assertUnreachable } from '../../types/sqlSharedTypes'; +import { UserUtils } from '../../session/utils'; +import { useConversationsNicknameRealNameOrShortenPubkey } from '../../hooks/useParamSelector'; const getLibGroupsState = (state: StateType): GroupState => state.groups; const getInviteSendingState = (state: StateType) => getLibGroupsState(state).membersInviteSending; @@ -44,48 +53,57 @@ function getGroupNameChangeFromUIPending(state: StateType): boolean { export function getLibAdminsPubkeys(state: StateType, convo?: string): Array { const members = getMembersOfGroup(state, convo); - return members.filter(m => m.promoted).map(m => m.pubkeyHex); + return members.filter(m => m.nominatedAdmin).map(m => m.pubkeyHex); } function getMemberInviteFailed(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { const members = getMembersOfGroup(state, convo); - return findMemberInMembers(members, pubkey)?.inviteFailed || false; + return findMemberInMembers(members, pubkey)?.memberStatus === 'INVITE_FAILED' || false; } function getMemberInviteNotSent(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { const members = getMembersOfGroup(state, convo); - return findMemberInMembers(members, pubkey)?.inviteNotSent || false; + return findMemberInMembers(members, pubkey)?.memberStatus === 'INVITE_NOT_SENT' || false; } function getMemberInviteSent(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { const members = getMembersOfGroup(state, convo); - return findMemberInMembers(members, pubkey)?.invitePending || false; + return findMemberInMembers(members, pubkey)?.memberStatus === 'INVITE_SENT' || false; } -function getMemberIsPromoted(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { +function getMemberHasAcceptedPromotion( + state: StateType, + pubkey: PubkeyType, + convo?: GroupPubkeyType +) { const members = getMembersOfGroup(state, convo); - return findMemberInMembers(members, pubkey)?.promoted || false; + return findMemberInMembers(members, pubkey)?.memberStatus === 'PROMOTION_ACCEPTED' || false; +} + +function getMemberIsNominatedAdmin(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { + const members = getMembersOfGroup(state, convo); + return findMemberInMembers(members, pubkey)?.nominatedAdmin || false; } function getMemberHasAcceptedInvite(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { const members = getMembersOfGroup(state, convo); - return findMemberInMembers(members, pubkey)?.inviteAccepted || false; + return findMemberInMembers(members, pubkey)?.memberStatus === 'INVITE_ACCEPTED' || false; } function getMemberPromotionFailed(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { const members = getMembersOfGroup(state, convo); - return findMemberInMembers(members, pubkey)?.promotionFailed || false; + return findMemberInMembers(members, pubkey)?.memberStatus === 'PROMOTION_FAILED' || false; } function getMemberPromotionSent(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { const members = getMembersOfGroup(state, convo); - return findMemberInMembers(members, pubkey)?.promotionPending || false; + return findMemberInMembers(members, pubkey)?.memberStatus === 'PROMOTION_SENT' || false; } function getMemberPromotionNotSent(state: StateType, pubkey: PubkeyType, convo?: GroupPubkeyType) { const members = getMembersOfGroup(state, convo); - return findMemberInMembers(members, pubkey)?.promotionNotSent || false; + return findMemberInMembers(members, pubkey)?.memberStatus === 'PROMOTION_NOT_SENT' || false; } export function getLibMembersCount(state: StateType, convo?: GroupPubkeyType): Array { @@ -155,9 +173,14 @@ export function useMemberInviteNotSent(member: PubkeyType, groupPk: GroupPubkeyT return useSelector((state: StateType) => getMemberInviteNotSent(state, member, groupPk)); } -export function useMemberIsPromoted(member: PubkeyType, groupPk: GroupPubkeyType) { - return useSelector((state: StateType) => getMemberIsPromoted(state, member, groupPk)); +export function useMemberHasAcceptedPromotion(member: PubkeyType, groupPk: GroupPubkeyType) { + return useSelector((state: StateType) => getMemberHasAcceptedPromotion(state, member, groupPk)); +} + +export function useMemberIsNominatedAdmin(member: PubkeyType, groupPk: GroupPubkeyType) { + return useSelector((state: StateType) => getMemberIsNominatedAdmin(state, member, groupPk)); } + export function useMemberHasAcceptedInvite(member: PubkeyType, groupPk: GroupPubkeyType) { return useSelector((state: StateType) => getMemberHasAcceptedInvite(state, member, groupPk)); } @@ -188,18 +211,128 @@ export function useGroupNameChangeFromUIPending() { * An example is the "sending invite" or "sending promote" state of a member in a group. */ -function useMembersInviteSending(groupPk: GroupPubkeyType) { - return useSelector((state: StateType) => getInviteSendingState(state)[groupPk] || []); +function useMembersInviteSending(groupPk?: string) { + return useSelector((state: StateType) => + groupPk && PubKey.is03Pubkey(groupPk) ? getInviteSendingState(state)[groupPk] || [] : [] + ); } export function useMemberInviteSending(groupPk: GroupPubkeyType, memberPk: PubkeyType) { return useMembersInviteSending(groupPk).includes(memberPk); } -function useMembersPromoteSending(groupPk: GroupPubkeyType) { - return useSelector((state: StateType) => getPromoteSendingState(state)[groupPk] || []); +function useMembersPromoteSending(groupPk?: string) { + return useSelector((state: StateType) => + groupPk && PubKey.is03Pubkey(groupPk) ? getPromoteSendingState(state)[groupPk] || [] : [] + ); } export function useMemberPromoteSending(groupPk: GroupPubkeyType, memberPk: PubkeyType) { return useMembersPromoteSending(groupPk).includes(memberPk); } + +type MemberStateGroupV2WithSending = MemberStateGroupV2 | 'INVITE_SENDING' | 'PROMOTION_SENDING'; + +export function useStateOf03GroupMembers(convoId?: string) { + const us = UserUtils.getOurPubKeyStrFromCache(); + let unsortedMembers = useSelector((state: StateType) => getMembersOfGroup(state, convoId)); + const invitesSendingPk = useMembersInviteSending(convoId); + const promotionsSendingPk = useMembersPromoteSending(convoId); + let invitesSending = compact( + invitesSendingPk.map(sending => unsortedMembers.find(m => m.pubkeyHex === sending)) + ); + const promotionSending = compact( + promotionsSendingPk.map(sending => unsortedMembers.find(m => m.pubkeyHex === sending)) + ); + + // promotionSending has priority against invitesSending, so removing anything in invitesSending found in promotionSending + invitesSending = differenceBy(invitesSending, promotionSending, value => value.pubkeyHex); + + const bothSending = concat(promotionSending, invitesSending); + + // promotionSending and invitesSending has priority against anything else, so remove anything found in one of those two + // from the unsorted list of members + unsortedMembers = differenceBy(unsortedMembers, bothSending, value => value.pubkeyHex); + + // at this point, merging invitesSending, promotionSending and unsortedMembers should create an array of unique members + const sortedByPriorities = concat(bothSending, unsortedMembers); + if (sortedByPriorities.length !== uniqBy(sortedByPriorities, m => m.pubkeyHex).length) { + throw new Error( + 'merging invitesSending, promotionSending and unsortedMembers should create an array of unique members' + ); + } + + // This could have been done now with a `sortedByPriorities.map()` call, + // but we don't want the order as sorted by `sortedByPriorities`, **only** to respect the priorities from it. + // What that means is that a member with a state as inviteSending, should have that state, but not be sorted first. + + // The order we (for now) want is: + // - (Invite failed + Invite Not Sent) merged together, sorted as NameSortingOrder + // - Sending invite, sorted as NameSortingOrder + // - Invite sent, sorted as NameSortingOrder + // - (Promotion failed + Promotion Not Sent) merged together, sorted as NameSortingOrder + // - Sending invite, sorted as NameSortingOrder + // - Invite sent, sorted as NameSortingOrder + // - Admin, sorted as NameSortingOrder + // - Accepted Member, sorted as NameSortingOrder + // NameSortingOrder: You first, then "nickname || name || pubkey -> aA-zZ" + + const unsortedWithStatuses: Array< + Pick & { memberStatus: MemberStateGroupV2WithSending } + > = []; + unsortedWithStatuses.push(...promotionSending); + unsortedWithStatuses.push(...differenceBy(invitesSending, promotionSending)); + unsortedWithStatuses.push(...differenceBy(unsortedMembers, invitesSending, promotionSending)); + + const names = useConversationsNicknameRealNameOrShortenPubkey( + unsortedWithStatuses.map(m => m.pubkeyHex) + ); + + // needing an index like this outside of lodash is not pretty, + // but sortBy doesn't provide the index in the callback + let index = 0; + + const sorted = sortBy(unsortedWithStatuses, item => { + let stateSortingOrder = 0; + switch (item.memberStatus) { + case 'INVITE_FAILED': + case 'INVITE_NOT_SENT': + stateSortingOrder = -5; + break; + case 'INVITE_SENDING': + stateSortingOrder = -4; + break; + case 'INVITE_SENT': + stateSortingOrder = -3; + break; + case 'PROMOTION_FAILED': + case 'PROMOTION_NOT_SENT': + stateSortingOrder = -2; + break; + case 'PROMOTION_SENDING': + stateSortingOrder = -1; + break; + case 'PROMOTION_SENT': + stateSortingOrder = 0; + break; + case 'PROMOTION_ACCEPTED': + stateSortingOrder = 1; + break; + case 'INVITE_ACCEPTED': + stateSortingOrder = 2; + break; + + default: + assertUnreachable(item.memberStatus, 'Unhandled switch case'); + } + const sortingOrder = [ + stateSortingOrder, + // per section, we want "us first", then "nickname || displayName || pubkey" + item.pubkeyHex === us ? -1 : names[index]?.toLocaleLowerCase(), + ]; + index++; + return sortingOrder; + }); + + return sorted; +} diff --git a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts index 3da57731e..830ff031b 100644 --- a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts +++ b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_metagroup_test.ts @@ -18,20 +18,14 @@ function profilePicture() { function emptyMember(pubkeyHex: PubkeyType): GroupMemberGet { return { - inviteFailed: false, - invitePending: false, + memberStatus: 'INVITE_NOT_SENT', name: '', profilePicture: { key: null, url: null, }, - promoted: false, - promotionFailed: false, - promotionPending: false, - inviteAccepted: false, - inviteNotSent: false, isRemoved: false, - promotionNotSent: false, + nominatedAdmin: false, shouldRemoveMessages: false, pubkeyHex, }; @@ -271,10 +265,8 @@ describe('libsession_metagroup', () => { expect(metaGroupWrapper.memberGetAll().length).to.be.deep.eq(1); const expected: GroupMemberGet = { ...emptyMember(member), - promoted: true, - promotionFailed: false, - promotionPending: false, - promotionNotSent: false, + nominatedAdmin: true, + memberStatus: 'PROMOTION_ACCEPTED', }; expect(metaGroupWrapper.memberGetAll()[0]).to.be.deep.eq(expected);