Moves conversation.getProps out of models
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
parent
a0d7c36e3b
commit
7c1957c30d
21 changed files with 810 additions and 598 deletions
19
ts/groups.ts
19
ts/groups.ts
|
@ -68,6 +68,7 @@ import { getGroupSizeHardLimit } from './groups/limits';
|
|||
import {
|
||||
isGroupV1 as getIsGroupV1,
|
||||
isGroupV2 as getIsGroupV2,
|
||||
isGroupV2,
|
||||
isMe,
|
||||
} from './util/whatTypeOfConversation';
|
||||
import * as Bytes from './Bytes';
|
||||
|
@ -356,14 +357,20 @@ export async function getPreJoinGroupInfo(
|
|||
});
|
||||
}
|
||||
|
||||
export function buildGroupLink(conversation: ConversationModel): string {
|
||||
const { masterKey, groupInviteLinkPassword } = conversation.attributes;
|
||||
export function buildGroupLink(
|
||||
conversation: ConversationAttributesType
|
||||
): string | undefined {
|
||||
if (!isGroupV2(conversation)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { masterKey, groupInviteLinkPassword } = conversation;
|
||||
|
||||
if (!groupInviteLinkPassword) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
strictAssert(masterKey, 'buildGroupLink requires the master key!');
|
||||
strictAssert(
|
||||
groupInviteLinkPassword,
|
||||
'buildGroupLink requires the groupInviteLinkPassword!'
|
||||
);
|
||||
|
||||
const bytes = Proto.GroupInviteLink.encode({
|
||||
v1Contents: {
|
||||
|
|
|
@ -1,15 +1,7 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import {
|
||||
compact,
|
||||
has,
|
||||
isNumber,
|
||||
throttle,
|
||||
debounce,
|
||||
head,
|
||||
sortBy,
|
||||
} from 'lodash';
|
||||
import { compact, has, isNumber, throttle, debounce } from 'lodash';
|
||||
import { batch as batchDispatch } from 'react-redux';
|
||||
import { v4 as generateGuid } from 'uuid';
|
||||
import PQueue from 'p-queue';
|
||||
|
@ -23,16 +15,21 @@ import type {
|
|||
QuotedMessageType,
|
||||
SenderKeyInfoType,
|
||||
} from '../model-types.d';
|
||||
import { getConversation } from '../util/getConversation';
|
||||
import { drop } from '../util/drop';
|
||||
import { isShallowEqual } from '../util/isShallowEqual';
|
||||
import { memoizeByThis } from '../util/memoizeByThis';
|
||||
import { getInitials } from '../util/getInitials';
|
||||
import { normalizeUuid } from '../util/normalizeUuid';
|
||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
||||
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
|
||||
import type { AttachmentType, ThumbnailType } from '../types/Attachment';
|
||||
import { toDayMillis } from '../util/timestamp';
|
||||
import { isVoiceMessage } from '../types/Attachment';
|
||||
import { areWeAdmin } from '../util/areWeAdmin';
|
||||
import { isBlocked } from '../util/isBlocked';
|
||||
import { getAboutText } from '../util/getAboutText';
|
||||
import { getAvatarPath } from '../util/avatarUtils';
|
||||
import { getDraftPreview } from '../util/getDraftPreview';
|
||||
import { hasDraft } from '../util/hasDraft';
|
||||
import type { CallHistoryDetailsType } from '../types/Calling';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import * as Conversation from '../types/Conversation';
|
||||
|
@ -50,7 +47,6 @@ import type {
|
|||
import type {
|
||||
ConversationType,
|
||||
DraftPreviewType,
|
||||
LastMessageType,
|
||||
} from '../state/ducks/conversations';
|
||||
import type {
|
||||
AvatarColorType,
|
||||
|
@ -83,10 +79,9 @@ import {
|
|||
} from '../Crypto';
|
||||
import * as Bytes from '../Bytes';
|
||||
import type { DraftBodyRanges } from '../types/BodyRange';
|
||||
import { BodyRange, hydrateRanges } from '../types/BodyRange';
|
||||
import { BodyRange } from '../types/BodyRange';
|
||||
import { migrateColor } from '../util/migrateColor';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
import { dropNull } from '../util/dropNull';
|
||||
import { notificationService } from '../services/notifications';
|
||||
import { storageServiceUploadJob } from '../services/storage';
|
||||
import { scheduleOptimizeFTS } from '../services/ftsOptimizer';
|
||||
|
@ -135,7 +130,6 @@ import type { ReactionModel } from '../messageModifiers/Reactions';
|
|||
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
||||
import { getProfile } from '../util/getProfile';
|
||||
import { SEALED_SENDER } from '../types/SealedSender';
|
||||
import { getAvatarData } from '../util/getAvatarData';
|
||||
import { createIdenticon } from '../util/createIdenticon';
|
||||
import * as log from '../logging/log';
|
||||
import * as Errors from '../types/errors';
|
||||
|
@ -150,21 +144,21 @@ import { getSendTarget } from '../util/getSendTarget';
|
|||
import { getRecipients } from '../util/getRecipients';
|
||||
import { validateConversation } from '../util/validateConversation';
|
||||
import { isSignalConversation } from '../util/isSignalConversation';
|
||||
import { isMemberRequestingToJoin } from '../util/isMemberRequestingToJoin';
|
||||
import { removePendingMember } from '../util/removePendingMember';
|
||||
import { isMemberPending } from '../util/isMemberPending';
|
||||
import {
|
||||
isMember,
|
||||
isMemberAwaitingApproval,
|
||||
isMemberBanned,
|
||||
isMemberPending,
|
||||
isMemberRequestingToJoin,
|
||||
} from '../util/groupMembershipUtils';
|
||||
import { imageToBlurHash } from '../util/imageToBlurHash';
|
||||
import { ReceiptType } from '../types/Receipt';
|
||||
import { getQuoteAttachment } from '../util/makeQuote';
|
||||
import { stripNewlinesForLeftPane } from '../util/stripNewlinesForLeftPane';
|
||||
import { findAndFormatContact } from '../util/findAndFormatContact';
|
||||
import { deriveProfileKeyVersion } from '../util/zkgroup';
|
||||
import { incrementMessageCounter } from '../util/incrementMessageCounter';
|
||||
import { validateTransition } from '../util/callHistoryDetails';
|
||||
|
||||
const EMPTY_ARRAY: Readonly<[]> = [];
|
||||
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
|
@ -260,7 +254,7 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
private cachedIdenticon?: CachedIdenticon;
|
||||
|
||||
private isFetchingUUID?: boolean;
|
||||
public isFetchingUUID?: boolean;
|
||||
|
||||
private lastIsTyping?: boolean;
|
||||
|
||||
|
@ -465,45 +459,12 @@ export class ConversationModel extends window.Backbone
|
|||
return isMemberPending(this.attributes, uuid);
|
||||
}
|
||||
|
||||
private isMemberBanned(uuid: UUID): boolean {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return false;
|
||||
}
|
||||
const bannedMembersV2 = this.get('bannedMembersV2');
|
||||
|
||||
if (!bannedMembersV2 || !bannedMembersV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return bannedMembersV2.some(member => member.uuid === uuid.toString());
|
||||
}
|
||||
|
||||
isMemberAwaitingApproval(uuid: UUID): boolean {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return false;
|
||||
}
|
||||
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
|
||||
|
||||
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pendingAdminApprovalV2.some(
|
||||
member => member.uuid === uuid.toString()
|
||||
);
|
||||
return isMemberAwaitingApproval(this.attributes, uuid);
|
||||
}
|
||||
|
||||
isMember(uuid: UUID): boolean {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return false;
|
||||
}
|
||||
const membersV2 = this.get('membersV2');
|
||||
|
||||
if (!membersV2 || !membersV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return membersV2.some(item => item.uuid === uuid.toString());
|
||||
return isMember(this.attributes, uuid);
|
||||
}
|
||||
|
||||
async updateExpirationTimerInGroupV2(
|
||||
|
@ -898,22 +859,7 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
|
||||
isBlocked(): boolean {
|
||||
const uuid = this.get('uuid');
|
||||
if (uuid) {
|
||||
return window.storage.blocked.isUuidBlocked(uuid);
|
||||
}
|
||||
|
||||
const e164 = this.get('e164');
|
||||
if (e164) {
|
||||
return window.storage.blocked.isBlocked(e164);
|
||||
}
|
||||
|
||||
const groupId = this.get('groupId');
|
||||
if (groupId) {
|
||||
return window.storage.blocked.isGroupBlocked(groupId);
|
||||
}
|
||||
|
||||
return false;
|
||||
return isBlocked(this.attributes);
|
||||
}
|
||||
|
||||
block({ viaStorageServiceSync = false } = {}): void {
|
||||
|
@ -1090,49 +1036,11 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
|
||||
hasDraft(): boolean {
|
||||
const draftAttachments = this.get('draftAttachments') || [];
|
||||
|
||||
return (this.get('draft') ||
|
||||
this.get('quotedMessageId') ||
|
||||
draftAttachments.length > 0) as boolean;
|
||||
return hasDraft(this.attributes);
|
||||
}
|
||||
|
||||
getDraftPreview(): DraftPreviewType {
|
||||
const draft = this.get('draft');
|
||||
|
||||
const rawBodyRanges = this.get('draftBodyRanges') || [];
|
||||
const bodyRanges = hydrateRanges(rawBodyRanges, findAndFormatContact);
|
||||
|
||||
if (draft) {
|
||||
return {
|
||||
text: stripNewlinesForLeftPane(draft),
|
||||
bodyRanges,
|
||||
};
|
||||
}
|
||||
|
||||
const draftAttachments = this.get('draftAttachments') || [];
|
||||
if (draftAttachments.length > 0) {
|
||||
if (isVoiceMessage(draftAttachments[0])) {
|
||||
return {
|
||||
text: window.i18n('icu:message--getNotificationText--voice-message'),
|
||||
prefix: '🎤',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: window.i18n('icu:Conversation--getDraftPreview--attachment'),
|
||||
};
|
||||
}
|
||||
|
||||
const quotedMessageId = this.get('quotedMessageId');
|
||||
if (quotedMessageId) {
|
||||
return {
|
||||
text: window.i18n('icu:Conversation--getDraftPreview--quote'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: window.i18n('icu:Conversation--getDraftPreview--draft'),
|
||||
};
|
||||
return getDraftPreview(this.attributes);
|
||||
}
|
||||
|
||||
bumpTyping(): void {
|
||||
|
@ -1891,7 +1799,7 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
try {
|
||||
const { oldCachedProps } = this;
|
||||
const newCachedProps = this.getProps();
|
||||
const newCachedProps = getConversation(this);
|
||||
|
||||
if (oldCachedProps && isShallowEqual(oldCachedProps, newCachedProps)) {
|
||||
this.cachedProps = oldCachedProps;
|
||||
|
@ -1905,179 +1813,6 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
}
|
||||
|
||||
// Note: this should never be called directly. Use conversation.format() instead, which
|
||||
// maintains a cache, and protects against reentrant calls.
|
||||
// Note: When writing code inside this function, do not call .format() on a conversation
|
||||
// unless you are sure that it's not this very same conversation.
|
||||
// Note: If you start relying on an attribute that is in
|
||||
// `ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE`, remove it from that list.
|
||||
private getProps(): ConversationType {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const color = this.getColor()!;
|
||||
|
||||
const typingValues = Object.values(this.contactTypingTimers || {});
|
||||
const typingMostRecent = head(sortBy(typingValues, 'timestamp'));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const timestamp = this.get('timestamp')!;
|
||||
const draftTimestamp = this.get('draftTimestamp');
|
||||
const draftPreview = this.getDraftPreview();
|
||||
const draftText = dropNull(this.get('draft'));
|
||||
const draftEditMessage = this.get('draftEditMessage');
|
||||
const shouldShowDraft = Boolean(
|
||||
this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp
|
||||
);
|
||||
const inboxPosition = this.get('inbox_position');
|
||||
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
||||
'desktop.messageRequests'
|
||||
);
|
||||
const ourConversationId =
|
||||
window.ConversationController.getOurConversationId();
|
||||
|
||||
let groupVersion: undefined | 1 | 2;
|
||||
if (isGroupV1(this.attributes)) {
|
||||
groupVersion = 1;
|
||||
} else if (isGroupV2(this.attributes)) {
|
||||
groupVersion = 2;
|
||||
}
|
||||
|
||||
const sortedGroupMembers = isGroupV2(this.attributes)
|
||||
? this.getMembers()
|
||||
.sort((left, right) =>
|
||||
sortConversationTitles(left, right, this.intlCollator)
|
||||
)
|
||||
.map(member => member.format())
|
||||
.filter(isNotNil)
|
||||
: undefined;
|
||||
|
||||
const { customColor, customColorId } = this.getCustomColorData();
|
||||
|
||||
const ourACI = window.textsecure.storage.user.getUuid(UUIDKind.ACI);
|
||||
const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI);
|
||||
|
||||
// TODO: DESKTOP-720
|
||||
return {
|
||||
id: this.id,
|
||||
uuid: this.get('uuid'),
|
||||
pni: this.get('pni'),
|
||||
e164: this.get('e164'),
|
||||
|
||||
// We had previously stored `null` instead of `undefined` in some cases. We should
|
||||
// be able to remove this `dropNull` once usernames have gone to production.
|
||||
username: canHaveUsername(this.attributes, ourConversationId)
|
||||
? dropNull(this.get('username'))
|
||||
: undefined,
|
||||
|
||||
about: this.getAboutText(),
|
||||
aboutText: this.get('about'),
|
||||
aboutEmoji: this.get('aboutEmoji'),
|
||||
acceptedMessageRequest: this.getAccepted(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
activeAt: this.get('active_at')!,
|
||||
areWePending:
|
||||
ourACI &&
|
||||
(this.isMemberPending(ourACI) ||
|
||||
Boolean(
|
||||
ourPNI && !this.isMember(ourACI) && this.isMemberPending(ourPNI)
|
||||
)),
|
||||
areWePendingApproval: Boolean(
|
||||
ourConversationId && ourACI && this.isMemberAwaitingApproval(ourACI)
|
||||
),
|
||||
areWeAdmin: this.areWeAdmin(),
|
||||
avatars: getAvatarData(this.attributes),
|
||||
badges: this.get('badges') ?? EMPTY_ARRAY,
|
||||
canChangeTimer: this.canChangeTimer(),
|
||||
canEditGroupInfo: this.canEditGroupInfo(),
|
||||
canAddNewMembers: this.canAddNewMembers(),
|
||||
avatarPath: this.getAbsoluteAvatarPath(),
|
||||
avatarHash: this.getAvatarHash(),
|
||||
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
|
||||
profileAvatarPath: this.getAbsoluteProfileAvatarPath(),
|
||||
color,
|
||||
conversationColor: this.getConversationColor(),
|
||||
customColor,
|
||||
customColorId,
|
||||
discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'),
|
||||
draftBodyRanges: this.getDraftBodyRanges(),
|
||||
draftPreview,
|
||||
draftText,
|
||||
draftEditMessage,
|
||||
familyName: this.get('profileFamilyName'),
|
||||
firstName: this.get('profileName'),
|
||||
groupDescription: this.get('description'),
|
||||
groupVersion,
|
||||
groupId: this.get('groupId'),
|
||||
groupLink: this.getGroupLink(),
|
||||
hideStory: Boolean(this.get('hideStory')),
|
||||
inboxPosition,
|
||||
isArchived: this.get('isArchived'),
|
||||
isBlocked: this.isBlocked(),
|
||||
removalStage: this.get('removalStage'),
|
||||
isMe: isMe(this.attributes),
|
||||
isGroupV1AndDisabled: this.isGroupV1AndDisabled(),
|
||||
isPinned: this.get('isPinned'),
|
||||
isUntrusted: this.isUntrusted(),
|
||||
isVerified: this.isVerified(),
|
||||
isFetchingUUID: this.isFetchingUUID,
|
||||
lastMessage: this.getLastMessage(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
lastUpdated: this.get('timestamp')!,
|
||||
left: Boolean(this.get('left')),
|
||||
markedUnread: this.get('markedUnread'),
|
||||
membersCount: this.getMembersCount(),
|
||||
memberships: this.getMemberships(),
|
||||
hasMessages: (this.get('messageCount') ?? 0) > 0,
|
||||
pendingMemberships: this.getPendingMemberships(),
|
||||
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
||||
bannedMemberships: this.getBannedMemberships(),
|
||||
profileKey: this.get('profileKey'),
|
||||
messageRequestsEnabled,
|
||||
accessControlAddFromInviteLink:
|
||||
this.get('accessControl')?.addFromInviteLink,
|
||||
accessControlAttributes: this.get('accessControl')?.attributes,
|
||||
accessControlMembers: this.get('accessControl')?.members,
|
||||
announcementsOnly: Boolean(this.get('announcementsOnly')),
|
||||
announcementsOnlyReady: this.canBeAnnouncementGroup(),
|
||||
expireTimer: this.get('expireTimer'),
|
||||
muteExpiresAt: this.get('muteExpiresAt'),
|
||||
dontNotifyForMentionsIfMuted: this.get('dontNotifyForMentionsIfMuted'),
|
||||
name: this.get('name'),
|
||||
systemGivenName: this.get('systemGivenName'),
|
||||
systemFamilyName: this.get('systemFamilyName'),
|
||||
systemNickname: this.get('systemNickname'),
|
||||
phoneNumber: this.getNumber(),
|
||||
profileName: this.getProfileName(),
|
||||
profileSharing: this.get('profileSharing'),
|
||||
publicParams: this.get('publicParams'),
|
||||
secretParams: this.get('secretParams'),
|
||||
shouldShowDraft,
|
||||
sortedGroupMembers,
|
||||
timestamp,
|
||||
title: this.getTitle(),
|
||||
titleNoDefault: this.getTitleNoDefault(),
|
||||
typingContactId: typingMostRecent?.senderId,
|
||||
searchableTitle: isMe(this.attributes)
|
||||
? window.i18n('icu:noteToSelf')
|
||||
: this.getTitle(),
|
||||
unreadCount: this.get('unreadCount') || 0,
|
||||
unreadMentionsCount: this.get('unreadMentionsCount'),
|
||||
...(isDirectConversation(this.attributes)
|
||||
? {
|
||||
type: 'direct' as const,
|
||||
sharedGroupNames: this.get('sharedGroupNames') || EMPTY_ARRAY,
|
||||
}
|
||||
: {
|
||||
type: 'group' as const,
|
||||
acknowledgedGroupNameCollisions:
|
||||
this.get('acknowledgedGroupNameCollisions') ||
|
||||
EMPTY_GROUP_COLLISIONS,
|
||||
sharedGroupNames: EMPTY_ARRAY,
|
||||
storySendMode: this.getGroupStorySendMode(),
|
||||
}),
|
||||
voiceNotePlaybackRate: this.get('voiceNotePlaybackRate'),
|
||||
};
|
||||
}
|
||||
|
||||
updateE164(e164?: string | null): void {
|
||||
const oldValue = this.get('e164');
|
||||
if (e164 === oldValue) {
|
||||
|
@ -2246,26 +1981,6 @@ export class ConversationModel extends window.Backbone
|
|||
window.Signal.Data.updateConversation(this.attributes);
|
||||
}
|
||||
|
||||
getMembersCount(): number | undefined {
|
||||
if (isDirectConversation(this.attributes)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const memberList = this.get('membersV2') || this.get('members');
|
||||
|
||||
// We'll fail over if the member list is empty
|
||||
if (memberList && memberList.length) {
|
||||
return memberList.length;
|
||||
}
|
||||
|
||||
const temporaryMemberCount = this.get('temporaryMemberCount');
|
||||
if (isNumber(temporaryMemberCount)) {
|
||||
return temporaryMemberCount;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
incrementSentMessageCount({ dry = false }: { dry?: boolean } = {}):
|
||||
| Partial<ConversationAttributesType>
|
||||
| undefined {
|
||||
|
@ -2627,7 +2342,7 @@ export class ConversationModel extends window.Backbone
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.isMemberBanned(uuid)) {
|
||||
if (isMemberBanned(this.attributes, uuid)) {
|
||||
log.warn('addBannedMember: Member is already banned!');
|
||||
|
||||
return;
|
||||
|
@ -3037,21 +2752,7 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
|
||||
getAboutText(): string | undefined {
|
||||
if (!this.get('about')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emoji = this.get('aboutEmoji');
|
||||
const text = this.get('about');
|
||||
|
||||
if (!emoji) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return window.i18n('icu:message--getNotificationText--text-with-emoji', {
|
||||
text,
|
||||
emoji,
|
||||
});
|
||||
return getAboutText(this.attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -3859,54 +3560,6 @@ export class ConversationModel extends window.Backbone
|
|||
return result;
|
||||
}
|
||||
|
||||
private getDraftBodyRanges = memoizeByThis(
|
||||
(): DraftBodyRanges | undefined => {
|
||||
return this.get('draftBodyRanges');
|
||||
}
|
||||
);
|
||||
|
||||
private getLastMessage = memoizeByThis((): LastMessageType | undefined => {
|
||||
if (this.get('lastMessageDeletedForEveryone')) {
|
||||
return { deletedForEveryone: true };
|
||||
}
|
||||
const lastMessageText = this.get('lastMessage');
|
||||
if (!lastMessageText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawBodyRanges = this.get('lastMessageBodyRanges') || [];
|
||||
const bodyRanges = hydrateRanges(rawBodyRanges, findAndFormatContact);
|
||||
|
||||
const text = stripNewlinesForLeftPane(lastMessageText);
|
||||
const prefix = this.get('lastMessagePrefix');
|
||||
|
||||
return {
|
||||
author: dropNull(this.get('lastMessageAuthor')),
|
||||
bodyRanges,
|
||||
deletedForEveryone: false,
|
||||
prefix,
|
||||
status: dropNull(this.get('lastMessageStatus')),
|
||||
text,
|
||||
};
|
||||
});
|
||||
|
||||
private getMemberships = memoizeByThis(
|
||||
(): ReadonlyArray<{
|
||||
uuid: UUIDStringType;
|
||||
isAdmin: boolean;
|
||||
}> => {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
const members = this.get('membersV2') || [];
|
||||
return members.map(member => ({
|
||||
isAdmin: member.role === Proto.Member.Role.ADMINISTRATOR,
|
||||
uuid: member.uuid,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
getGroupLink(): string | undefined {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return undefined;
|
||||
|
@ -3916,49 +3569,9 @@ export class ConversationModel extends window.Backbone
|
|||
return undefined;
|
||||
}
|
||||
|
||||
return window.Signal.Groups.buildGroupLink(this);
|
||||
return window.Signal.Groups.buildGroupLink(this.attributes);
|
||||
}
|
||||
|
||||
private getPendingMemberships = memoizeByThis(
|
||||
(): ReadonlyArray<{
|
||||
addedByUserId?: UUIDStringType;
|
||||
uuid: UUIDStringType;
|
||||
}> => {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
const members = this.get('pendingMembersV2') || [];
|
||||
return members.map(member => ({
|
||||
addedByUserId: member.addedByUserId,
|
||||
uuid: member.uuid,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private getPendingApprovalMemberships = memoizeByThis(
|
||||
(): ReadonlyArray<{ uuid: UUIDStringType }> => {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
const members = this.get('pendingAdminApprovalV2') || [];
|
||||
return members.map(member => ({
|
||||
uuid: member.uuid,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private getBannedMemberships = memoizeByThis(
|
||||
(): ReadonlyArray<UUIDStringType> => {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
return (this.get('bannedMembersV2') || []).map(member => member.uuid);
|
||||
}
|
||||
);
|
||||
|
||||
getMembers(
|
||||
options: { includePendingMembers?: boolean } = {}
|
||||
): Array<ConversationModel> {
|
||||
|
@ -5324,45 +4937,8 @@ export class ConversationModel extends window.Backbone
|
|||
};
|
||||
}
|
||||
|
||||
private getAvatarPath(): undefined | string {
|
||||
const shouldShowProfileAvatar =
|
||||
isMe(this.attributes) ||
|
||||
window.storage.get('preferContactAvatars') === false;
|
||||
const avatar = shouldShowProfileAvatar
|
||||
? this.get('profileAvatar') || this.get('avatar')
|
||||
: this.get('avatar') || this.get('profileAvatar');
|
||||
return avatar?.path || undefined;
|
||||
}
|
||||
|
||||
private getAvatarHash(): undefined | string {
|
||||
const avatar = isMe(this.attributes)
|
||||
? this.get('profileAvatar') || this.get('avatar')
|
||||
: this.get('avatar') || this.get('profileAvatar');
|
||||
return avatar?.hash || undefined;
|
||||
}
|
||||
|
||||
getAbsoluteAvatarPath(): string | undefined {
|
||||
const avatarPath = this.getAvatarPath();
|
||||
if (isSignalConversation(this.attributes)) {
|
||||
return avatarPath;
|
||||
}
|
||||
return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined;
|
||||
}
|
||||
|
||||
getAbsoluteProfileAvatarPath(): string | undefined {
|
||||
const avatarPath = this.get('profileAvatar')?.path;
|
||||
return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined;
|
||||
}
|
||||
|
||||
getAbsoluteUnblurredAvatarPath(): string | undefined {
|
||||
const unblurredAvatarPath = this.get('unblurredAvatarPath');
|
||||
return unblurredAvatarPath
|
||||
? getAbsoluteAttachmentPath(unblurredAvatarPath)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
unblurAvatar(): void {
|
||||
const avatarPath = this.getAvatarPath();
|
||||
const avatarPath = getAvatarPath(this.attributes);
|
||||
if (avatarPath) {
|
||||
this.set('unblurredAvatarPath', avatarPath);
|
||||
} else {
|
||||
|
@ -5370,78 +4946,8 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
}
|
||||
|
||||
private canChangeTimer(): boolean {
|
||||
if (isDirectConversation(this.attributes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isGroupV1AndDisabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const accessControlEnum = Proto.AccessControl.AccessRequired;
|
||||
const accessControl = this.get('accessControl');
|
||||
const canAnyoneChangeTimer =
|
||||
accessControl &&
|
||||
(accessControl.attributes === accessControlEnum.ANY ||
|
||||
accessControl.attributes === accessControlEnum.MEMBER);
|
||||
if (canAnyoneChangeTimer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.areWeAdmin();
|
||||
}
|
||||
|
||||
canEditGroupInfo(): boolean {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.get('left')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.areWeAdmin() ||
|
||||
this.get('accessControl')?.attributes ===
|
||||
Proto.AccessControl.AccessRequired.MEMBER
|
||||
);
|
||||
}
|
||||
|
||||
canAddNewMembers(): boolean {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.get('left')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.areWeAdmin() ||
|
||||
this.get('accessControl')?.members ===
|
||||
Proto.AccessControl.AccessRequired.MEMBER
|
||||
);
|
||||
}
|
||||
|
||||
areWeAdmin(): boolean {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const memberEnum = Proto.Member.Role;
|
||||
const members = this.get('membersV2') || [];
|
||||
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
|
||||
const me = members.find(item => item.uuid === ourUuid);
|
||||
if (!me) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return me.role === memberEnum.ADMINISTRATOR;
|
||||
return areWeAdmin(this.attributes);
|
||||
}
|
||||
|
||||
// Set of items to captureChanges on:
|
||||
|
@ -5582,7 +5088,7 @@ export class ConversationModel extends window.Backbone
|
|||
});
|
||||
|
||||
let notificationIconUrl;
|
||||
const avatarPath = this.getAvatarPath();
|
||||
const avatarPath = getAvatarPath(this.attributes);
|
||||
if (avatarPath) {
|
||||
notificationIconUrl = getAbsoluteAttachmentPath(avatarPath);
|
||||
} else if (isMessageInDirectConversation) {
|
||||
|
@ -6034,15 +5540,3 @@ window.Whisper.ConversationCollection = window.Backbone.Collection.extend({
|
|||
return -(m.get('active_at') || 0);
|
||||
},
|
||||
});
|
||||
|
||||
type SortableByTitle = {
|
||||
getTitle: () => string;
|
||||
};
|
||||
|
||||
const sortConversationTitles = (
|
||||
left: SortableByTitle,
|
||||
right: SortableByTitle,
|
||||
collator: Intl.Collator
|
||||
) => {
|
||||
return collator.compare(left.getTitle(), right.getTitle());
|
||||
};
|
||||
|
|
|
@ -117,7 +117,7 @@ import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMe
|
|||
import type { ShowToastActionType } from './toast';
|
||||
import { SHOW_TOAST } from './toast';
|
||||
import { ToastType } from '../../types/Toast';
|
||||
import { isMemberRequestingToJoin } from '../../util/isMemberRequestingToJoin';
|
||||
import { isMemberRequestingToJoin } from '../../util/groupMembershipUtils';
|
||||
import { removePendingMember } from '../../util/removePendingMember';
|
||||
import { denyPendingApprovalRequest } from '../../util/denyPendingApprovalRequest';
|
||||
import { SignalService as Proto } from '../../protobuf';
|
||||
|
|
27
ts/util/areWeAdmin.ts
Normal file
27
ts/util/areWeAdmin.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { isGroupV2 } from './whatTypeOfConversation';
|
||||
|
||||
export function areWeAdmin(
|
||||
attributes: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion' | 'membersV2'
|
||||
>
|
||||
): boolean {
|
||||
if (!isGroupV2(attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const memberEnum = Proto.Member.Role;
|
||||
const members = attributes.membersV2 || [];
|
||||
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
|
||||
const me = members.find(item => item.uuid === ourUuid);
|
||||
if (!me) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return me.role === memberEnum.ADMINISTRATOR;
|
||||
}
|
56
ts/util/avatarUtils.ts
Normal file
56
ts/util/avatarUtils.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import { isMe } from './whatTypeOfConversation';
|
||||
import { isSignalConversation } from './isSignalConversation';
|
||||
|
||||
export function getAvatarHash(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): undefined | string {
|
||||
const avatar = isMe(conversationAttrs)
|
||||
? conversationAttrs.profileAvatar || conversationAttrs.avatar
|
||||
: conversationAttrs.avatar || conversationAttrs.profileAvatar;
|
||||
return avatar?.hash || undefined;
|
||||
}
|
||||
|
||||
export function getAvatarPath(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): undefined | string {
|
||||
const shouldShowProfileAvatar =
|
||||
isMe(conversationAttrs) ||
|
||||
window.storage.get('preferContactAvatars') === false;
|
||||
const avatar = shouldShowProfileAvatar
|
||||
? conversationAttrs.profileAvatar || conversationAttrs.avatar
|
||||
: conversationAttrs.avatar || conversationAttrs.profileAvatar;
|
||||
return avatar?.path || undefined;
|
||||
}
|
||||
|
||||
export function getAbsoluteAvatarPath(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): string | undefined {
|
||||
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
|
||||
const avatarPath = getAvatarPath(conversationAttrs);
|
||||
if (isSignalConversation(conversationAttrs)) {
|
||||
return avatarPath;
|
||||
}
|
||||
return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined;
|
||||
}
|
||||
|
||||
export function getAbsoluteProfileAvatarPath(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): string | undefined {
|
||||
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
|
||||
const avatarPath = conversationAttrs.profileAvatar?.path;
|
||||
return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined;
|
||||
}
|
||||
|
||||
export function getAbsoluteUnblurredAvatarPath(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): string | undefined {
|
||||
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
|
||||
const { unblurredAvatarPath } = conversationAttrs;
|
||||
return unblurredAvatarPath
|
||||
? getAbsoluteAttachmentPath(unblurredAvatarPath)
|
||||
: undefined;
|
||||
}
|
25
ts/util/canAddNewMembers.ts
Normal file
25
ts/util/canAddNewMembers.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { isGroupV2 } from './whatTypeOfConversation';
|
||||
import { areWeAdmin } from './areWeAdmin';
|
||||
|
||||
export function canAddNewMembers(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conversationAttrs.left) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
areWeAdmin(conversationAttrs) ||
|
||||
conversationAttrs.accessControl?.members ===
|
||||
Proto.AccessControl.AccessRequired.MEMBER
|
||||
);
|
||||
}
|
|
@ -1,25 +1,23 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { UUID } from '../types/UUID';
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import { isAnnouncementGroupReady } from './isAnnouncementGroupReady';
|
||||
import { isGroupV2 } from './whatTypeOfConversation';
|
||||
|
||||
export function isMemberRequestingToJoin(
|
||||
export function canBeAnnouncementGroup(
|
||||
conversationAttrs: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion' | 'pendingAdminApprovalV2'
|
||||
>,
|
||||
uuid: UUID
|
||||
>
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
const { pendingAdminApprovalV2 } = conversationAttrs;
|
||||
|
||||
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
|
||||
if (!isAnnouncementGroupReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pendingAdminApprovalV2.some(item => item.uuid === uuid.toString());
|
||||
return true;
|
||||
}
|
39
ts/util/canChangeTimer.ts
Normal file
39
ts/util/canChangeTimer.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { areWeAdmin } from './areWeAdmin';
|
||||
import {
|
||||
isDirectConversation,
|
||||
isGroupV1,
|
||||
isGroupV2,
|
||||
} from './whatTypeOfConversation';
|
||||
|
||||
export function canChangeTimer(
|
||||
attributes: ConversationAttributesType
|
||||
): boolean {
|
||||
if (isDirectConversation(attributes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isGroupV1(attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isGroupV2(attributes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const accessControlEnum = Proto.AccessControl.AccessRequired;
|
||||
const { accessControl } = attributes;
|
||||
const canAnyoneChangeTimer =
|
||||
accessControl &&
|
||||
(accessControl.attributes === accessControlEnum.ANY ||
|
||||
accessControl.attributes === accessControlEnum.MEMBER);
|
||||
if (canAnyoneChangeTimer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return areWeAdmin(attributes);
|
||||
}
|
25
ts/util/canEditGroupInfo.ts
Normal file
25
ts/util/canEditGroupInfo.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { isGroupV2 } from './whatTypeOfConversation';
|
||||
import { areWeAdmin } from './areWeAdmin';
|
||||
|
||||
export function canEditGroupInfo(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conversationAttrs.left) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
areWeAdmin(conversationAttrs) ||
|
||||
conversationAttrs.accessControl?.attributes ===
|
||||
Proto.AccessControl.AccessRequired.MEMBER
|
||||
);
|
||||
}
|
|
@ -7,7 +7,7 @@ import type { UUID } from '../types/UUID';
|
|||
import * as log from '../logging/log';
|
||||
import { UUIDKind } from '../types/UUID';
|
||||
import { getConversationIdForLogging } from './idForLogging';
|
||||
import { isMemberRequestingToJoin } from './isMemberRequestingToJoin';
|
||||
import { isMemberRequestingToJoin } from './groupMembershipUtils';
|
||||
|
||||
export async function denyPendingApprovalRequest(
|
||||
conversationAttributes: ConversationAttributesType,
|
||||
|
|
24
ts/util/getAboutText.ts
Normal file
24
ts/util/getAboutText.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types';
|
||||
|
||||
export function getAboutText(
|
||||
attributes: ConversationAttributesType
|
||||
): string | undefined {
|
||||
if (!attributes.about) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emoji = attributes.aboutEmoji;
|
||||
const text = attributes.about;
|
||||
|
||||
if (!emoji) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return window.i18n('icu:message--getNotificationText--text-with-emoji', {
|
||||
text,
|
||||
emoji,
|
||||
});
|
||||
}
|
240
ts/util/getConversation.ts
Normal file
240
ts/util/getConversation.ts
Normal file
|
@ -0,0 +1,240 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import memoizee from 'memoizee';
|
||||
import { head, sortBy } from 'lodash';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { ConversationAttributesType } from '../model-types';
|
||||
import type { GroupNameCollisionsWithIdsByTitle } from './groupMemberNameCollisions';
|
||||
import { StorySendMode } from '../types/Stories';
|
||||
import { UUIDKind } from '../types/UUID';
|
||||
import { areWeAdmin } from './areWeAdmin';
|
||||
import { buildGroupLink } from '../groups';
|
||||
import { canAddNewMembers } from './canAddNewMembers';
|
||||
import { canBeAnnouncementGroup } from './canBeAnnouncementGroup';
|
||||
import { canChangeTimer } from './canChangeTimer';
|
||||
import { canEditGroupInfo } from './canEditGroupInfo';
|
||||
import { dropNull } from './dropNull';
|
||||
import { getAboutText } from './getAboutText';
|
||||
import {
|
||||
getAbsoluteAvatarPath,
|
||||
getAbsoluteUnblurredAvatarPath,
|
||||
getAbsoluteProfileAvatarPath,
|
||||
getAvatarHash,
|
||||
} from './avatarUtils';
|
||||
import { getAvatarData } from './getAvatarData';
|
||||
import { getConversationMembers } from './getConversationMembers';
|
||||
import { getCustomColorData, migrateColor } from './migrateColor';
|
||||
import { getDraftPreview } from './getDraftPreview';
|
||||
import { getLastMessage } from './getLastMessage';
|
||||
import {
|
||||
getNumber,
|
||||
getProfileName,
|
||||
getTitle,
|
||||
getTitleNoDefault,
|
||||
canHaveUsername,
|
||||
} from './getTitle';
|
||||
import { hasDraft } from './hasDraft';
|
||||
import { isBlocked } from './isBlocked';
|
||||
import { isConversationAccepted } from './isConversationAccepted';
|
||||
import {
|
||||
isDirectConversation,
|
||||
isGroupV1,
|
||||
isGroupV2,
|
||||
isMe,
|
||||
} from './whatTypeOfConversation';
|
||||
import {
|
||||
getBannedMemberships,
|
||||
getMembersCount,
|
||||
getMemberships,
|
||||
getPendingApprovalMemberships,
|
||||
getPendingMemberships,
|
||||
isMember,
|
||||
isMemberAwaitingApproval,
|
||||
isMemberPending,
|
||||
} from './groupMembershipUtils';
|
||||
import { isNotNil } from './isNotNil';
|
||||
|
||||
const EMPTY_ARRAY: Readonly<[]> = [];
|
||||
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
|
||||
|
||||
const getCollator = memoizee((): Intl.Collator => {
|
||||
return new Intl.Collator(undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
function sortConversationTitles(
|
||||
left: ConversationAttributesType,
|
||||
right: ConversationAttributesType
|
||||
) {
|
||||
return getCollator().compare(getTitle(left), getTitle(right));
|
||||
}
|
||||
|
||||
// Note: this should never be called directly. Use conversation.format() instead, which
|
||||
// maintains a cache, and protects against reentrant calls.
|
||||
// Note: When writing code inside this function, do not call .format() on a conversation
|
||||
// unless you are sure that it's not this very same conversation.
|
||||
// Note: If you start relying on an attribute that is in
|
||||
// `ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE`, remove it from that list.
|
||||
export function getConversation(model: ConversationModel): ConversationType {
|
||||
const { attributes } = model;
|
||||
const typingValues = Object.values(model.contactTypingTimers || {});
|
||||
const typingMostRecent = head(sortBy(typingValues, 'timestamp'));
|
||||
|
||||
const ourACI = window.textsecure.storage.user.getUuid(UUIDKind.ACI);
|
||||
const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI);
|
||||
|
||||
const color = migrateColor(attributes.color);
|
||||
|
||||
const { draftTimestamp, draftEditMessage, timestamp } = attributes;
|
||||
const draftPreview = getDraftPreview(attributes);
|
||||
const draftText = dropNull(attributes.draft);
|
||||
const shouldShowDraft = Boolean(
|
||||
hasDraft(attributes) && draftTimestamp && draftTimestamp >= (timestamp || 0)
|
||||
);
|
||||
const inboxPosition = attributes.inbox_position;
|
||||
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
||||
'desktop.messageRequests'
|
||||
);
|
||||
const ourConversationId =
|
||||
window.ConversationController.getOurConversationId();
|
||||
|
||||
let groupVersion: undefined | 1 | 2;
|
||||
if (isGroupV1(attributes)) {
|
||||
groupVersion = 1;
|
||||
} else if (isGroupV2(attributes)) {
|
||||
groupVersion = 2;
|
||||
}
|
||||
|
||||
const sortedGroupMembers = isGroupV2(attributes)
|
||||
? getConversationMembers(attributes)
|
||||
.sort((left, right) => sortConversationTitles(left, right))
|
||||
.map(member => window.ConversationController.get(member.id)?.format())
|
||||
.filter(isNotNil)
|
||||
: undefined;
|
||||
|
||||
const { customColor, customColorId } = getCustomColorData(attributes);
|
||||
|
||||
// TODO: DESKTOP-720
|
||||
return {
|
||||
id: attributes.id,
|
||||
uuid: attributes.uuid,
|
||||
pni: attributes.pni,
|
||||
e164: attributes.e164,
|
||||
|
||||
// We had previously stored `null` instead of `undefined` in some cases. We should
|
||||
// be able to remove this `dropNull` once usernames have gone to production.
|
||||
username: canHaveUsername(attributes, ourConversationId)
|
||||
? dropNull(attributes.username)
|
||||
: undefined,
|
||||
|
||||
about: getAboutText(attributes),
|
||||
aboutText: attributes.about,
|
||||
aboutEmoji: attributes.aboutEmoji,
|
||||
acceptedMessageRequest: isConversationAccepted(attributes),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
activeAt: attributes.active_at!,
|
||||
areWePending:
|
||||
ourACI &&
|
||||
(isMemberPending(attributes, ourACI) ||
|
||||
Boolean(
|
||||
ourPNI &&
|
||||
!isMember(attributes, ourACI) &&
|
||||
isMemberPending(attributes, ourPNI)
|
||||
)),
|
||||
areWePendingApproval: Boolean(
|
||||
ourConversationId &&
|
||||
ourACI &&
|
||||
isMemberAwaitingApproval(attributes, ourACI)
|
||||
),
|
||||
areWeAdmin: areWeAdmin(attributes),
|
||||
avatars: getAvatarData(attributes),
|
||||
badges: attributes.badges ?? EMPTY_ARRAY,
|
||||
canChangeTimer: canChangeTimer(attributes),
|
||||
canEditGroupInfo: canEditGroupInfo(attributes),
|
||||
canAddNewMembers: canAddNewMembers(attributes),
|
||||
avatarPath: getAbsoluteAvatarPath(attributes),
|
||||
avatarHash: getAvatarHash(attributes),
|
||||
unblurredAvatarPath: getAbsoluteUnblurredAvatarPath(attributes),
|
||||
profileAvatarPath: getAbsoluteProfileAvatarPath(attributes),
|
||||
color,
|
||||
conversationColor: attributes.conversationColor,
|
||||
customColor,
|
||||
customColorId,
|
||||
discoveredUnregisteredAt: attributes.discoveredUnregisteredAt,
|
||||
draftBodyRanges: attributes.draftBodyRanges,
|
||||
draftPreview,
|
||||
draftText,
|
||||
draftEditMessage,
|
||||
familyName: attributes.profileFamilyName,
|
||||
firstName: attributes.profileName,
|
||||
groupDescription: attributes.description,
|
||||
groupVersion,
|
||||
groupId: attributes.groupId,
|
||||
groupLink: buildGroupLink(attributes),
|
||||
hideStory: Boolean(attributes.hideStory),
|
||||
inboxPosition,
|
||||
isArchived: attributes.isArchived,
|
||||
isBlocked: isBlocked(attributes),
|
||||
removalStage: attributes.removalStage,
|
||||
isMe: isMe(attributes),
|
||||
isGroupV1AndDisabled: isGroupV1(attributes),
|
||||
isPinned: attributes.isPinned,
|
||||
isUntrusted: model.isUntrusted(),
|
||||
isVerified: model.isVerified(),
|
||||
isFetchingUUID: model.isFetchingUUID,
|
||||
lastMessage: getLastMessage(attributes),
|
||||
lastUpdated: dropNull(timestamp),
|
||||
left: Boolean(attributes.left),
|
||||
markedUnread: attributes.markedUnread,
|
||||
membersCount: getMembersCount(attributes),
|
||||
memberships: getMemberships(attributes),
|
||||
hasMessages: (attributes.messageCount ?? 0) > 0,
|
||||
pendingMemberships: getPendingMemberships(attributes),
|
||||
pendingApprovalMemberships: getPendingApprovalMemberships(attributes),
|
||||
bannedMemberships: getBannedMemberships(attributes),
|
||||
profileKey: attributes.profileKey,
|
||||
messageRequestsEnabled,
|
||||
accessControlAddFromInviteLink: attributes.accessControl?.addFromInviteLink,
|
||||
accessControlAttributes: attributes.accessControl?.attributes,
|
||||
accessControlMembers: attributes.accessControl?.members,
|
||||
announcementsOnly: Boolean(attributes.announcementsOnly),
|
||||
announcementsOnlyReady: canBeAnnouncementGroup(attributes),
|
||||
expireTimer: attributes.expireTimer,
|
||||
muteExpiresAt: attributes.muteExpiresAt,
|
||||
dontNotifyForMentionsIfMuted: attributes.dontNotifyForMentionsIfMuted,
|
||||
name: attributes.name,
|
||||
systemGivenName: attributes.systemGivenName,
|
||||
systemFamilyName: attributes.systemFamilyName,
|
||||
systemNickname: attributes.systemNickname,
|
||||
phoneNumber: getNumber(attributes),
|
||||
profileName: getProfileName(attributes),
|
||||
profileSharing: attributes.profileSharing,
|
||||
publicParams: attributes.publicParams,
|
||||
secretParams: attributes.secretParams,
|
||||
shouldShowDraft,
|
||||
sortedGroupMembers,
|
||||
timestamp: dropNull(timestamp),
|
||||
title: getTitle(attributes),
|
||||
titleNoDefault: getTitleNoDefault(attributes),
|
||||
typingContactId: typingMostRecent?.senderId,
|
||||
searchableTitle: isMe(attributes)
|
||||
? window.i18n('icu:noteToSelf')
|
||||
: getTitle(attributes),
|
||||
unreadCount: attributes.unreadCount || 0,
|
||||
...(isDirectConversation(attributes)
|
||||
? {
|
||||
type: 'direct' as const,
|
||||
sharedGroupNames: attributes.sharedGroupNames || EMPTY_ARRAY,
|
||||
}
|
||||
: {
|
||||
type: 'group' as const,
|
||||
acknowledgedGroupNameCollisions:
|
||||
attributes.acknowledgedGroupNameCollisions ||
|
||||
EMPTY_GROUP_COLLISIONS,
|
||||
sharedGroupNames: EMPTY_ARRAY,
|
||||
storySendMode: attributes.storySendMode ?? StorySendMode.IfActive,
|
||||
}),
|
||||
voiceNotePlaybackRate: attributes.voiceNotePlaybackRate,
|
||||
};
|
||||
}
|
49
ts/util/getDraftPreview.ts
Normal file
49
ts/util/getDraftPreview.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types';
|
||||
import type { DraftPreviewType } from '../state/ducks/conversations';
|
||||
import { findAndFormatContact } from './findAndFormatContact';
|
||||
import { hydrateRanges } from '../types/BodyRange';
|
||||
import { isVoiceMessage } from '../types/Attachment';
|
||||
import { stripNewlinesForLeftPane } from './stripNewlinesForLeftPane';
|
||||
|
||||
export function getDraftPreview(
|
||||
attributes: ConversationAttributesType
|
||||
): DraftPreviewType {
|
||||
const { draft } = attributes;
|
||||
|
||||
const rawBodyRanges = attributes.draftBodyRanges || [];
|
||||
const bodyRanges = hydrateRanges(rawBodyRanges, findAndFormatContact);
|
||||
|
||||
if (draft) {
|
||||
return {
|
||||
text: stripNewlinesForLeftPane(draft),
|
||||
bodyRanges,
|
||||
};
|
||||
}
|
||||
|
||||
const draftAttachments = attributes.draftAttachments || [];
|
||||
if (draftAttachments.length > 0) {
|
||||
if (isVoiceMessage(draftAttachments[0])) {
|
||||
return {
|
||||
text: window.i18n('icu:message--getNotificationText--voice-message'),
|
||||
prefix: '🎤',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: window.i18n('icu:Conversation--getDraftPreview--attachment'),
|
||||
};
|
||||
}
|
||||
|
||||
const { quotedMessageId } = attributes;
|
||||
if (quotedMessageId) {
|
||||
return {
|
||||
text: window.i18n('icu:Conversation--getDraftPreview--quote'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: window.i18n('icu:Conversation--getDraftPreview--draft'),
|
||||
};
|
||||
}
|
36
ts/util/getLastMessage.ts
Normal file
36
ts/util/getLastMessage.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import type { LastMessageType } from '../state/ducks/conversations';
|
||||
import { dropNull } from './dropNull';
|
||||
import { findAndFormatContact } from './findAndFormatContact';
|
||||
import { hydrateRanges } from '../types/BodyRange';
|
||||
import { stripNewlinesForLeftPane } from './stripNewlinesForLeftPane';
|
||||
|
||||
export function getLastMessage(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): LastMessageType | undefined {
|
||||
if (conversationAttrs.lastMessageDeletedForEveryone) {
|
||||
return { deletedForEveryone: true };
|
||||
}
|
||||
const lastMessageText = conversationAttrs.lastMessage;
|
||||
if (!lastMessageText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawBodyRanges = conversationAttrs.lastMessageBodyRanges || [];
|
||||
const bodyRanges = hydrateRanges(rawBodyRanges, findAndFormatContact);
|
||||
|
||||
const text = stripNewlinesForLeftPane(lastMessageText);
|
||||
const prefix = conversationAttrs.lastMessagePrefix;
|
||||
|
||||
return {
|
||||
author: dropNull(conversationAttrs.lastMessageAuthor),
|
||||
bodyRanges,
|
||||
deletedForEveryone: false,
|
||||
prefix,
|
||||
status: dropNull(conversationAttrs.lastMessageStatus),
|
||||
text,
|
||||
};
|
||||
}
|
186
ts/util/groupMembershipUtils.ts
Normal file
186
ts/util/groupMembershipUtils.ts
Normal file
|
@ -0,0 +1,186 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import isNumber from 'lodash/isNumber';
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import type { UUID, UUIDStringType } from '../types/UUID';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation';
|
||||
|
||||
export function isMemberPending(
|
||||
conversationAttrs: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion' | 'pendingMembersV2'
|
||||
>,
|
||||
uuid: UUID
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
const { pendingMembersV2 } = conversationAttrs;
|
||||
|
||||
if (!pendingMembersV2 || !pendingMembersV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pendingMembersV2.some(item => item.uuid === uuid.toString());
|
||||
}
|
||||
|
||||
export function isMemberBanned(
|
||||
conversationAttrs: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion' | 'bannedMembersV2'
|
||||
>,
|
||||
uuid: UUID
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
const { bannedMembersV2 } = conversationAttrs;
|
||||
|
||||
if (!bannedMembersV2 || !bannedMembersV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return bannedMembersV2.some(member => member.uuid === uuid.toString());
|
||||
}
|
||||
|
||||
export function isMemberAwaitingApproval(
|
||||
conversationAttrs: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion' | 'pendingAdminApprovalV2'
|
||||
>,
|
||||
uuid: UUID
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
const { pendingAdminApprovalV2 } = conversationAttrs;
|
||||
|
||||
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pendingAdminApprovalV2.some(member => member.uuid === uuid.toString());
|
||||
}
|
||||
|
||||
export function isMember(
|
||||
conversationAttrs: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion' | 'membersV2'
|
||||
>,
|
||||
uuid: UUID
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
const { membersV2 } = conversationAttrs;
|
||||
|
||||
if (!membersV2 || !membersV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return membersV2.some(item => item.uuid === uuid.toString());
|
||||
}
|
||||
|
||||
export function isMemberRequestingToJoin(
|
||||
conversationAttrs: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion' | 'pendingAdminApprovalV2'
|
||||
>,
|
||||
uuid: UUID
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
const { pendingAdminApprovalV2 } = conversationAttrs;
|
||||
|
||||
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pendingAdminApprovalV2.some(item => item.uuid === uuid.toString());
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: Readonly<[]> = [];
|
||||
|
||||
export function getBannedMemberships(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): ReadonlyArray<UUIDStringType> {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
const { bannedMembersV2 } = conversationAttrs;
|
||||
|
||||
return (bannedMembersV2 || []).map(member => member.uuid);
|
||||
}
|
||||
|
||||
export function getPendingMemberships(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): ReadonlyArray<{
|
||||
addedByUserId?: UUIDStringType;
|
||||
uuid: UUIDStringType;
|
||||
}> {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
const members = conversationAttrs.pendingMembersV2 || [];
|
||||
return members.map(member => ({
|
||||
addedByUserId: member.addedByUserId,
|
||||
uuid: member.uuid,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getPendingApprovalMemberships(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): ReadonlyArray<{ uuid: UUIDStringType }> {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
const members = conversationAttrs.pendingAdminApprovalV2 || [];
|
||||
return members.map(member => ({
|
||||
uuid: member.uuid,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getMembersCount(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): number | undefined {
|
||||
if (isDirectConversation(conversationAttrs)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const memberList = conversationAttrs.membersV2 || conversationAttrs.members;
|
||||
|
||||
// We'll fail over if the member list is empty
|
||||
if (memberList && memberList.length) {
|
||||
return memberList.length;
|
||||
}
|
||||
|
||||
const { temporaryMemberCount } = conversationAttrs;
|
||||
if (isNumber(temporaryMemberCount)) {
|
||||
return temporaryMemberCount;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getMemberships(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): ReadonlyArray<{
|
||||
uuid: UUIDStringType;
|
||||
isAdmin: boolean;
|
||||
}> {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return EMPTY_ARRAY;
|
||||
}
|
||||
|
||||
const members = conversationAttrs.membersV2 || [];
|
||||
return members.map(member => ({
|
||||
isAdmin: member.role === Proto.Member.Role.ADMINISTRATOR,
|
||||
uuid: member.uuid,
|
||||
}));
|
||||
}
|
12
ts/util/hasDraft.ts
Normal file
12
ts/util/hasDraft.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types';
|
||||
|
||||
export function hasDraft(attributes: ConversationAttributesType): boolean {
|
||||
const draftAttachments = attributes.draftAttachments || [];
|
||||
|
||||
return (attributes.draft ||
|
||||
attributes.quotedMessageId ||
|
||||
draftAttachments.length > 0) as boolean;
|
||||
}
|
21
ts/util/isBlocked.ts
Normal file
21
ts/util/isBlocked.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types';
|
||||
|
||||
export function isBlocked(attributes: ConversationAttributesType): boolean {
|
||||
const { e164, groupId, uuid } = attributes;
|
||||
if (uuid) {
|
||||
return window.storage.blocked.isUuidBlocked(uuid);
|
||||
}
|
||||
|
||||
if (e164) {
|
||||
return window.storage.blocked.isBlocked(e164);
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
return window.storage.blocked.isGroupBlocked(groupId);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { UUID } from '../types/UUID';
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import { isGroupV2 } from './whatTypeOfConversation';
|
||||
|
||||
export function isMemberPending(
|
||||
conversationAttrs: Pick<
|
||||
ConversationAttributesType,
|
||||
'groupId' | 'groupVersion' | 'pendingMembersV2'
|
||||
>,
|
||||
uuid: UUID
|
||||
): boolean {
|
||||
if (!isGroupV2(conversationAttrs)) {
|
||||
return false;
|
||||
}
|
||||
const { pendingMembersV2 } = conversationAttrs;
|
||||
|
||||
if (!pendingMembersV2 || !pendingMembersV2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pendingMembersV2.some(item => item.uuid === uuid.toString());
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
export function memoizeByThis<Owner extends Record<string, unknown>, Result>(
|
||||
fn: () => Result
|
||||
): () => Result {
|
||||
const lastValueMap = new WeakMap<Owner, Result>();
|
||||
return function memoizedFn(this: Owner): Result {
|
||||
const lastValue = lastValueMap.get(this);
|
||||
const newValue = fn();
|
||||
if (lastValue !== undefined && isEqual(lastValue, newValue)) {
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
lastValueMap.set(this, newValue);
|
||||
return newValue;
|
||||
};
|
||||
}
|
|
@ -2,8 +2,9 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { sample } from 'lodash';
|
||||
import type { AvatarColorType } from '../types/Colors';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import type { ConversationAttributesType } from '../model-types';
|
||||
import type { AvatarColorType, CustomColorType } from '../types/Colors';
|
||||
|
||||
const NEW_COLOR_NAMES = new Set(AvatarColors);
|
||||
|
||||
|
@ -14,3 +15,20 @@ export function migrateColor(color?: string): AvatarColorType {
|
|||
|
||||
return sample(AvatarColors) || AvatarColors[0];
|
||||
}
|
||||
|
||||
export function getCustomColorData(conversation: ConversationAttributesType): {
|
||||
customColor?: CustomColorType;
|
||||
customColorId?: string;
|
||||
} {
|
||||
if (conversation.conversationColor !== 'custom') {
|
||||
return {
|
||||
customColor: undefined,
|
||||
customColorId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
customColor: conversation.customColor,
|
||||
customColorId: conversation.customColorId,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import type { SignalService as Proto } from '../protobuf';
|
|||
import type { UUID } from '../types/UUID';
|
||||
import * as log from '../logging/log';
|
||||
import { getConversationIdForLogging } from './idForLogging';
|
||||
import { isMemberPending } from './isMemberPending';
|
||||
import { isMemberPending } from './groupMembershipUtils';
|
||||
import { isNotNil } from './isNotNil';
|
||||
|
||||
export async function removePendingMember(
|
||||
|
|
Loading…
Reference in a new issue