diff --git a/ts/groups.ts b/ts/groups.ts index 20905882cad..66fb49f4899 100644 --- a/ts/groups.ts +++ b/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: { diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 5e56cafb67d..5b79ef8fcd3 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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 | 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 => { - if (!isGroupV2(this.attributes)) { - return EMPTY_ARRAY; - } - - return (this.get('bannedMembersV2') || []).map(member => member.uuid); - } - ); - getMembers( options: { includePendingMembers?: boolean } = {} ): Array { @@ -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()); -}; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index d7b1ccc119c..76f9a3f06ee 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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'; diff --git a/ts/util/areWeAdmin.ts b/ts/util/areWeAdmin.ts new file mode 100644 index 00000000000..4e3b4594505 --- /dev/null +++ b/ts/util/areWeAdmin.ts @@ -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; +} diff --git a/ts/util/avatarUtils.ts b/ts/util/avatarUtils.ts new file mode 100644 index 00000000000..f832f03a068 --- /dev/null +++ b/ts/util/avatarUtils.ts @@ -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; +} diff --git a/ts/util/canAddNewMembers.ts b/ts/util/canAddNewMembers.ts new file mode 100644 index 00000000000..775c4786597 --- /dev/null +++ b/ts/util/canAddNewMembers.ts @@ -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 + ); +} diff --git a/ts/util/isMemberRequestingToJoin.ts b/ts/util/canBeAnnouncementGroup.ts similarity index 52% rename from ts/util/isMemberRequestingToJoin.ts rename to ts/util/canBeAnnouncementGroup.ts index 80a40709752..ea1c4196731 100644 --- a/ts/util/isMemberRequestingToJoin.ts +++ b/ts/util/canBeAnnouncementGroup.ts @@ -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; } diff --git a/ts/util/canChangeTimer.ts b/ts/util/canChangeTimer.ts new file mode 100644 index 00000000000..b108564cc57 --- /dev/null +++ b/ts/util/canChangeTimer.ts @@ -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); +} diff --git a/ts/util/canEditGroupInfo.ts b/ts/util/canEditGroupInfo.ts new file mode 100644 index 00000000000..f209140633d --- /dev/null +++ b/ts/util/canEditGroupInfo.ts @@ -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 + ); +} diff --git a/ts/util/denyPendingApprovalRequest.ts b/ts/util/denyPendingApprovalRequest.ts index 625b82193a5..385e9f9ec73 100644 --- a/ts/util/denyPendingApprovalRequest.ts +++ b/ts/util/denyPendingApprovalRequest.ts @@ -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, diff --git a/ts/util/getAboutText.ts b/ts/util/getAboutText.ts new file mode 100644 index 00000000000..01f5e6597d4 --- /dev/null +++ b/ts/util/getAboutText.ts @@ -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, + }); +} diff --git a/ts/util/getConversation.ts b/ts/util/getConversation.ts new file mode 100644 index 00000000000..247c504e05a --- /dev/null +++ b/ts/util/getConversation.ts @@ -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, + }; +} diff --git a/ts/util/getDraftPreview.ts b/ts/util/getDraftPreview.ts new file mode 100644 index 00000000000..985e976b841 --- /dev/null +++ b/ts/util/getDraftPreview.ts @@ -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'), + }; +} diff --git a/ts/util/getLastMessage.ts b/ts/util/getLastMessage.ts new file mode 100644 index 00000000000..54b5273a49e --- /dev/null +++ b/ts/util/getLastMessage.ts @@ -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, + }; +} diff --git a/ts/util/groupMembershipUtils.ts b/ts/util/groupMembershipUtils.ts new file mode 100644 index 00000000000..bbd41b46969 --- /dev/null +++ b/ts/util/groupMembershipUtils.ts @@ -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 { + 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, + })); +} diff --git a/ts/util/hasDraft.ts b/ts/util/hasDraft.ts new file mode 100644 index 00000000000..9c3aff6e278 --- /dev/null +++ b/ts/util/hasDraft.ts @@ -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; +} diff --git a/ts/util/isBlocked.ts b/ts/util/isBlocked.ts new file mode 100644 index 00000000000..103f6724069 --- /dev/null +++ b/ts/util/isBlocked.ts @@ -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; +} diff --git a/ts/util/isMemberPending.ts b/ts/util/isMemberPending.ts deleted file mode 100644 index ffb0c68cdfb..00000000000 --- a/ts/util/isMemberPending.ts +++ /dev/null @@ -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()); -} diff --git a/ts/util/memoizeByThis.ts b/ts/util/memoizeByThis.ts deleted file mode 100644 index 66903a2abb3..00000000000 --- a/ts/util/memoizeByThis.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { isEqual } from 'lodash'; - -export function memoizeByThis, Result>( - fn: () => Result -): () => Result { - const lastValueMap = new WeakMap(); - 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; - }; -} diff --git a/ts/util/migrateColor.ts b/ts/util/migrateColor.ts index e435079d475..791ed11b37a 100644 --- a/ts/util/migrateColor.ts +++ b/ts/util/migrateColor.ts @@ -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, + }; +} diff --git a/ts/util/removePendingMember.ts b/ts/util/removePendingMember.ts index 7cb9345ca75..4633fdd6760 100644 --- a/ts/util/removePendingMember.ts +++ b/ts/util/removePendingMember.ts @@ -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(