gPromptTerminateGroup(true)}
+ icon={
+
+ }
+ label={
+
+ {i18n('icu:ConversationDetailsActions--terminate-group')}
+
+ }
+ />
+ );
+ }
+
+ let archiveNode: ReactNode;
+ if (isGroupTerminated) {
+ if (isArchived) {
+ archiveNode = (
+
+ }
+ label={
+
+ {i18n('icu:ConversationDetailsActions--unarchive')}
+
+ }
+ />
+ );
+ } else {
+ archiveNode = (
+
+ }
+ label={
+
+ {i18n('icu:ConversationDetailsActions--archive')}
+
+ }
+ />
+ );
+ }
+ }
+
+ const deleteNode = isGroupTerminated ? (
+ gGroupDelete(true)}
+ icon={
+
+ }
+ label={
+
+ {i18n('icu:ConversationDetailsActions--delete')}
+
+ }
+ />
+ ) : null;
+
return (
<>
{leaveGroupNode}
{blockNode}
+ {archiveNode}
+ {deleteNode}
+ {terminateGroupNode && {terminateGroupNode}}
{confirmLeave && (
blockConversation(conversationId),
- style: 'affirmative',
+ style: 'negative',
},
]}
i18n={i18n}
@@ -225,7 +324,7 @@ export function ConversationDetailsActions({
'icu:ConversationDetailsActions--unblock-group-modal-confirm'
),
action: () => acceptConversation(conversationId),
- style: 'affirmative',
+ style: 'negative',
},
]}
i18n={i18n}
@@ -248,7 +347,7 @@ export function ConversationDetailsActions({
{
text: i18n('icu:MessageRequests--block'),
action: () => blockConversation(conversationId),
- style: 'affirmative',
+ style: 'negative',
},
]}
i18n={i18n}
@@ -279,6 +378,67 @@ export function ConversationDetailsActions({
{i18n('icu:MessageRequests--unblock-direct-confirm-body')}
)}
+
+ {promptTerminateGroup && (
+ gConfirmTerminateGroup(true),
+ style: 'negative',
+ },
+ ]}
+ i18n={i18n}
+ onClose={() => gPromptTerminateGroup(false)}
+ title={i18n(
+ 'icu:ConversationDetailsActions--prompt-terminate-group-modal-title',
+ {
+ groupName: conversationTitle,
+ }
+ )}
+ >
+ {i18n(
+ 'icu:ConversationDetailsActions--prompt-terminate-group-modal-content'
+ )}
+
+ )}
+
+ {confirmTerminateGroup && (
+ gConfirmTerminateGroup(false)}
+ >
+ {i18n(
+ 'icu:ConversationDetailsActions--confirm-terminate-group-confirm-modal-content'
+ )}
+
+ )}
+
+ {confirmGroupDelete && (
+ {
+ gGroupDelete(false);
+ onDelete();
+ }}
+ onClose={() => {
+ gGroupDelete(false);
+ }}
+ />
+ )}
>
);
}
diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.dom.tsx
index f9bab8b3e5..d18d4c46fe 100644
--- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.dom.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.dom.tsx
@@ -17,6 +17,8 @@ import { UserText } from '../../UserText.dom.js';
import { isInSystemContacts } from '../../../util/isInSystemContacts.std.js';
import { InContactsIcon } from '../../InContactsIcon.dom.js';
import type { ContactModalStateType } from '../../../types/globalModals.std.js';
+import { tw } from '../../../axo/tw.dom.js';
+import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
export type Props = {
areWeASubscriber: boolean;
@@ -267,6 +269,20 @@ export function ConversationDetailsHeader({
{modal}
{avatar}
{title}
+ {conversation.terminated && (
+
+
+
+ {i18n('icu:ConversationDetails__GroupTerminatedBanner')}
+
+
+ )}
{subtitle}
);
diff --git a/ts/components/conversation/conversation-details/ConversationDetailsIcon.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetailsIcon.dom.tsx
index 57c28119cb..4673546731 100644
--- a/ts/components/conversation/conversation-details/ConversationDetailsIcon.dom.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetailsIcon.dom.tsx
@@ -8,12 +8,14 @@ import { Spinner } from '../../Spinner.dom.js';
import { bemGenerator } from './util.std.js';
export enum IconType {
+ 'archive' = 'archive',
'approveAllMembers' = 'approveAllMembers',
'bell' = 'bell',
'block' = 'block',
'edit' = 'edit',
'unblock' = 'unblock',
'color' = 'color',
+ 'delete' = 'delete',
'down' = 'down',
'forward' = 'forward',
'heart' = 'heart',
@@ -32,6 +34,7 @@ export enum IconType {
'share' = 'share',
'spinner' = 'spinner',
'tag' = 'tag',
+ 'terminate' = 'terminate',
'timer' = 'timer',
'trash' = 'trash',
'verify' = 'verify',
diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.stories.tsx
index ae34ba22d8..746beb6570 100644
--- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.stories.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.stories.tsx
@@ -54,6 +54,7 @@ export default {
conversationId: '123',
getPreferredBadge: () => undefined,
i18n,
+ isTerminated: false,
memberships: defaultMemberships,
memberColors: getMemberColors(defaultMemberships),
showContactModal: action('showContactModal'),
diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.tsx
index ec550ef00d..f1d675976d 100644
--- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.tsx
@@ -35,6 +35,7 @@ export type Props = {
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
isEditMemberLabelEnabled: boolean;
+ isTerminated: boolean;
maxShownMemberCount?: number;
memberships: ReadonlyArray;
memberColors: Map;
@@ -90,6 +91,7 @@ export function ConversationDetailsMembershipList({
getPreferredBadge,
i18n,
isEditMemberLabelEnabled,
+ isTerminated,
maxShownMemberCount = 5,
memberColors,
memberships,
@@ -107,14 +109,17 @@ export function ConversationDetailsMembershipList({
shouldHideRestMembers && !showAllMembers
? maxShownMemberCount
: sortedMemberships.length;
+ const title = isTerminated
+ ? i18n('icu:ConversationDetailsMembershipList--terminated-title', {
+ number: sortedMemberships.length,
+ })
+ : i18n('icu:ConversationDetailsMembershipList--title', {
+ number: sortedMemberships.length,
+ });
return (
-
- {canAddNewMembers && (
+
+ {canAddNewMembers && !isTerminated && (
diff --git a/ts/groupChange.std.ts b/ts/groupChange.std.ts
index 48c65efb76..934f16d4e8 100644
--- a/ts/groupChange.std.ts
+++ b/ts/groupChange.std.ts
@@ -912,6 +912,17 @@ function renderChangeDetail(
}
return i18n('icu:GroupV2--announcements--member--unknown');
}
+ if (detail.type === 'terminated') {
+ if (fromYou) {
+ return i18n('icu:GroupV2--terminated--you');
+ }
+ if (from) {
+ return i18n('icu:GroupV2--terminated--other', {
+ memberName: renderContact(from),
+ });
+ }
+ return i18n('icu:GroupV2--terminated--unknown');
+ }
if (detail.type === 'summary') {
return i18n('icu:GroupV2--summary');
}
diff --git a/ts/groups.preload.ts b/ts/groups.preload.ts
index d79aa7bf00..84faafbb64 100644
--- a/ts/groups.preload.ts
+++ b/ts/groups.preload.ts
@@ -196,6 +196,7 @@ function toGroupActionsParams(
addMembersBanned: null,
deleteMembersBanned: null,
promoteMembersPendingPniAciProfileKey: null,
+ terminateGroup: null,
...input,
};
}
@@ -254,7 +255,7 @@ const GROUP_DESC_MAX_ENCRYPTED_BYTES = 8192;
const TEMPORAL_AUTH_REJECTED_CODE = 401;
const GROUP_ACCESS_DENIED_CODE = 403;
const GROUP_NONEXISTENT_CODE = 404;
-const SUPPORTED_CHANGE_EPOCH = 6; // support for ModifyMemberLabelAction
+const SUPPORTED_CHANGE_EPOCH = 7; // support for GroupTerminateChangeUpdate
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
@@ -594,6 +595,7 @@ function buildGroupProto(
membersBanned: null,
inviteLinkPassword: null,
announcementsOnly: null,
+ terminated: null,
};
}
@@ -1464,6 +1466,15 @@ export function buildPromoteMemberChange({
});
}
+export function buildTerminateChange(
+ group: ConversationAttributesType
+): Actions.Params {
+ return toGroupActionsParams({
+ version: (group.revision || 0) + 1,
+ terminateGroup: {},
+ });
+}
+
async function uploadGroupChange({
actions,
groupId,
@@ -4974,6 +4985,18 @@ function extractDiffs({
});
}
+ // terminated
+
+ if (Boolean(old.terminated) !== Boolean(current.terminated)) {
+ strictAssert(
+ current.terminated,
+ 'extractDiffs/terminated: terminated can only be set from false to true'
+ );
+ details.push({
+ type: 'terminated',
+ });
+ }
+
// Note: currently no diff generated for bannedMembersV2 changes
// final processing
@@ -5103,6 +5126,18 @@ function extractDiffs({
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
};
} else if (details.length > 0) {
+ let readStatus: ReadStatus;
+ let seenStatus: SeenStatus;
+ if (!isFromUs && details.find(detail => detail.type === 'terminated')) {
+ // When a group is terminated, we want the unread count for this conversation
+ // to go up, and we want the conversation list badged
+ readStatus = ReadStatus.Unread;
+ seenStatus = SeenStatus.Unseen;
+ } else {
+ readStatus = ReadStatus.Read;
+ seenStatus = isFromUs ? SeenStatus.Seen : SeenStatus.Unseen;
+ }
+
message = {
type: 'group-v2-change',
sourceServiceId,
@@ -5110,8 +5145,8 @@ function extractDiffs({
from,
details,
},
- readStatus: ReadStatus.Read,
- seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
+ readStatus,
+ seenStatus,
};
}
@@ -5731,6 +5766,10 @@ async function applyGroupChange({
result.announcementsOnly = announcementsOnly;
}
+ if (actions.terminateGroup) {
+ result.terminated = true;
+ }
+
if (actions.addMembersBanned && actions.addMembersBanned.length > 0) {
actions.addMembersBanned.forEach(member => {
if (bannedMembers.has(member.serviceId)) {
@@ -6172,6 +6211,9 @@ async function applyGroupState({
// announcementsOnly
result.announcementsOnly = groupState.announcementsOnly;
+ // terminated
+ result.terminated = groupState.terminated;
+
// membersBanned
result.bannedMembersV2 = groupState.membersBanned?.map(member => {
const previousMember = bannedMembers.get(member.serviceId);
@@ -6350,6 +6392,7 @@ type DecryptedGroupChangeActions = {
modifyAvatar?: {
avatar: string;
};
+ terminateGroup?: Record;
};
function decryptGroupChange(
@@ -6962,6 +7005,11 @@ function decryptGroupChange(
};
}
+ // modifyTerminated
+ if (actions.terminateGroup) {
+ result.terminateGroup = {};
+ }
+
// addMembersBanned
if (actions.addMembersBanned && actions.addMembersBanned.length > 0) {
result.addMembersBanned = actions.addMembersBanned
@@ -7111,6 +7159,7 @@ type DecryptedGroupState = {
avatarUrl?: string;
announcementsOnly?: boolean;
membersBanned?: Array;
+ terminated?: boolean;
};
function decryptGroupState(
@@ -7258,6 +7307,10 @@ function decryptGroupState(
const { announcementsOnly } = groupState;
result.announcementsOnly = Boolean(announcementsOnly);
+ // terminated
+ const { terminated } = groupState;
+ result.terminated = Boolean(terminated);
+
// membersBanned
const { membersBanned } = groupState;
if (membersBanned && membersBanned.length > 0) {
diff --git a/ts/groups/joinViaLink.preload.ts b/ts/groups/joinViaLink.preload.ts
index ebd027ed7f..e43d0139fb 100644
--- a/ts/groups/joinViaLink.preload.ts
+++ b/ts/groups/joinViaLink.preload.ts
@@ -114,6 +114,10 @@ export async function joinViaLink(value: string): Promise {
description: i18n('icu:GroupV2--join--link-revoked'),
title: i18n('icu:GroupV2--join--link-revoked--title'),
});
+ } else if (error instanceof HTTPError && error.code === 423) {
+ window.reduxActions.globalModals.showErrorModal({
+ description: i18n('icu:GroupV2--join--group-terminated'),
+ });
} else {
window.reduxActions.globalModals.showErrorModal({
description: i18n('icu:GroupV2--join--general-join-failure'),
diff --git a/ts/hooks/useMinimalConversation.std.ts b/ts/hooks/useMinimalConversation.std.ts
index 80150dc880..27e1b5a9bc 100644
--- a/ts/hooks/useMinimalConversation.std.ts
+++ b/ts/hooks/useMinimalConversation.std.ts
@@ -42,6 +42,7 @@ export type MinimalConversation = Satisfies<
| 'name'
| 'phoneNumber'
| 'profileName'
+ | 'terminated'
| 'title'
| 'type'
> & {
@@ -76,6 +77,7 @@ export function useMinimalConversation(
name,
phoneNumber,
profileName,
+ terminated,
title,
type,
} = conversation;
@@ -107,6 +109,7 @@ export function useMinimalConversation(
name,
phoneNumber,
profileName,
+ terminated,
title,
type,
};
@@ -135,6 +138,7 @@ export function useMinimalConversation(
name,
phoneNumber,
profileName,
+ terminated,
title,
type,
]);
diff --git a/ts/messages/handleDataMessage.preload.ts b/ts/messages/handleDataMessage.preload.ts
index 74e10d3063..445e12545d 100644
--- a/ts/messages/handleDataMessage.preload.ts
+++ b/ts/messages/handleDataMessage.preload.ts
@@ -365,6 +365,15 @@ export async function handleDataMessage(
}
}
+ // Drop incoming messages to terminated groups
+ if (conversation.get('terminated')) {
+ log.warn(
+ `Received message for terminated group ${conversation.idForLogging()}. Dropping.`
+ );
+ confirm();
+ return;
+ }
+
const messageId =
message.get('id') || generateMessageId(message.get('received_at')).id;
diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts
index 568a4bf74f..572171f6bb 100644
--- a/ts/model-types.d.ts
+++ b/ts/model-types.d.ts
@@ -505,6 +505,7 @@ export type ConversationAttributesType = {
memberLabel: AccessRequiredEnum | undefined;
};
announcementsOnly?: boolean;
+ terminated?: boolean;
avatar?: ContactAvatarType | null;
avatars?: ReadonlyArray>;
description?: string;
diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts
index 1ad7347cab..fdbc41dfdf 100644
--- a/ts/models/conversations.preload.ts
+++ b/ts/models/conversations.preload.ts
@@ -253,6 +253,7 @@ import {
buildModifyMemberRoleChange,
buildNewGroupLinkChange,
buildPromoteMemberChange,
+ buildTerminateChange,
generateGroupInviteLinkPassword,
hasV1GroupBeenMigrated,
joinGroupV2ViaLinkAndMigrate,
@@ -5005,6 +5006,18 @@ export class ConversationModel {
);
}
+ async terminateGroup(): Promise {
+ if (!isGroupV2(this.attributes)) {
+ return;
+ }
+
+ await this.modifyGroupV2({
+ name: 'terminate',
+ usingCredentialsFrom: [],
+ createGroupChange: async () => buildTerminateChange(this.attributes),
+ });
+ }
+
isSealedSenderDisabled(): boolean {
const members = this.getMembers();
if (
@@ -5821,6 +5834,10 @@ export class ConversationModel {
return;
}
+ if (this.get('terminated')) {
+ return;
+ }
+
const typingToken = `${sender.id}.${senderDevice}`;
this.contactTypingTimers = this.contactTypingTimers || {};
diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts
index 97f23c9665..4bc7a97fb6 100644
--- a/ts/services/backups/export.preload.ts
+++ b/ts/services/backups/export.preload.ts
@@ -1480,6 +1480,7 @@ export class BackupExportStream extends Readable {
? Bytes.fromBase64(convo.groupInviteLinkPassword)
: null,
announcementsOnly: convo.announcementsOnly === true,
+ terminated: convo.terminated === true,
},
},
},
@@ -3118,6 +3119,14 @@ export class BackupExportStream extends Readable {
},
},
});
+ } else if (type === 'terminated') {
+ updates.push({
+ update: {
+ groupTerminateChangeUpdate: {
+ updaterAci: from ? this.#aciToBytesOrNull(from) : null,
+ },
+ },
+ });
} else {
throw missingCaseError(type);
}
diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts
index 713da3ed47..b96a6771fd 100644
--- a/ts/services/backups/import.preload.ts
+++ b/ts/services/backups/import.preload.ts
@@ -1217,6 +1217,7 @@ export class BackupImportStream extends Writable {
membersBanned,
inviteLinkPassword,
announcementsOnly,
+ terminated,
} = snapshot;
const expirationTimerS =
@@ -1353,6 +1354,7 @@ export class BackupImportStream extends Writable {
? Bytes.toBase64(inviteLinkPassword)
: undefined,
announcementsOnly: dropNull(announcementsOnly),
+ terminated: dropNull(terminated),
};
if (group.blocked) {
@@ -3300,6 +3302,15 @@ export class BackupImportStream extends Writable {
aci: fromAciObject(Aci.fromUuidBytes(removedAci)),
});
}
+ if (update.groupTerminateChangeUpdate) {
+ const { updaterAci } = update.groupTerminateChangeUpdate;
+ if (updaterAci) {
+ from = fromAciObject(Aci.fromUuidBytes(updaterAci));
+ }
+ details.push({
+ type: 'terminated',
+ });
+ }
if (update.selfInvitedToGroupUpdate) {
const { inviterAci } = update.selfInvitedToGroupUpdate;
if (inviterAci) {
diff --git a/ts/services/calling.preload.ts b/ts/services/calling.preload.ts
index d97e5dc2b3..d77c4012f1 100644
--- a/ts/services/calling.preload.ts
+++ b/ts/services/calling.preload.ts
@@ -1306,7 +1306,9 @@ export class CallingClass {
await DataWriter.markCallHistoryMissed(staleCallIds);
}
- public async peekGroupCall(conversationId: string): Promise {
+ public async peekGroupCall(
+ conversationId: string
+ ): Promise {
// This can be undefined in two cases:
//
// 1. There is no group call instance. This is "stateless peeking", and is expected
@@ -1329,6 +1331,11 @@ export class CallingClass {
if (!conversation) {
throw new Error('Missing conversation; not peeking group call');
}
+
+ if (conversation.get('terminated')) {
+ return undefined;
+ }
+
const publicParams = conversation.get('publicParams');
const secretParams = conversation.get('secretParams');
if (!publicParams || !secretParams) {
@@ -3402,6 +3409,11 @@ export class CallingClass {
return;
}
+ if (conversation.get('terminated')) {
+ log.warn(`${logId}: update to terminated group`);
+ return;
+ }
+
if (
conversation.get('announcementsOnly') &&
!conversation.isAdmin(ringerAci)
diff --git a/ts/state/ducks/calling.preload.ts b/ts/state/ducks/calling.preload.ts
index ea724240b7..de2c266ff8 100644
--- a/ts/state/ducks/calling.preload.ts
+++ b/ts/state/ducks/calling.preload.ts
@@ -554,6 +554,7 @@ const doGroupCallPeek = ({
);
if (
!conversation ||
+ conversation.terminated ||
getConversationCallMode(conversation) !== CallMode.Group
) {
return;
@@ -2231,6 +2232,16 @@ function onOutgoingVideoCallInConversation(
call.peekInfo &&
isAnybodyElseInGroupCall(call.peekInfo, ourAci);
+ if (conversation.get('terminated')) {
+ dispatch({
+ type: SHOW_TOAST,
+ payload: {
+ toastType: ToastType.CannotStartGroupCall,
+ },
+ });
+ return;
+ }
+
// If it's a group call on an announcementsOnly group, only allow join if the call
// has already been started (presumably by the admin)
if (conversation.get('announcementsOnly') && !conversation.areWeAdmin()) {
diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts
index ca1d068361..ebfa5dc72a 100644
--- a/ts/state/ducks/conversations.preload.ts
+++ b/ts/state/ducks/conversations.preload.ts
@@ -33,13 +33,15 @@ import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload.std.js
import { isFileDangerous } from '../../util/isFileDangerous.std.js';
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl.std.js';
import { instance as libphonenumberInstance } from '../../util/libphonenumberInstance.std.js';
-import type {
- ShowSendAnywayDialogActionType,
- ShowErrorModalActionType,
- ToggleDiscardDraftDialogActionType,
+import {
+ type ShowSendAnywayDialogActionType,
+ type ShowErrorModalActionType,
+ type ShowTerminateGroupFailedModalActionType,
+ type ToggleDiscardDraftDialogActionType,
} from './globalModals.preload.js';
import {
SHOW_SEND_ANYWAY_DIALOG,
+ SHOW_TERMINATE_GROUP_FAILED_MODAL,
SHOW_ERROR_MODAL,
TOGGLE_DISCARD_DRAFT_DIALOG,
} from './globalModals.preload.js';
@@ -250,6 +252,7 @@ import { pinnedMessagesCleanupService } from '../../services/expiring/pinnedMess
import { getPinnedMessageTarget } from '../../util/getPinMessageTarget.preload.js';
import {
getActivePanel,
+ getPanels,
getSelectedConversationId,
} from '../selectors/nav.std.js';
@@ -439,6 +442,7 @@ export type ConversationType = ReadonlyDeep<
groupVersion?: 1 | 2;
groupId?: string;
groupLink?: string;
+ terminated?: boolean;
acceptedMessageRequest: boolean;
secretParams?: string;
publicParams?: string;
@@ -1289,6 +1293,7 @@ export const actions = {
showMediaNoLongerAvailableToast,
startComposing,
startSettingGroupMetadata,
+ terminateGroup,
toggleAdmin,
toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
@@ -3152,6 +3157,64 @@ function createGroup(
};
}
+function terminateGroup(
+ conversationId: string
+): ThunkAction<
+ void,
+ RootStateType,
+ unknown,
+ | ShowTerminateGroupFailedModalActionType
+ | TargetedConversationChangedActionType
+ | NoopActionType
+> {
+ return async (dispatch, getState) => {
+ const conversation = window.ConversationController.get(conversationId);
+ if (!conversation) {
+ throw new Error('terminateGroup: No conversation found');
+ }
+
+ const i18n = getIntl(getState());
+
+ try {
+ await longRunningTaskWrapper({
+ name: 'terminateGroup',
+ idForLogging: conversation.idForLogging(),
+ spinnerText: i18n('icu:GroupV2--terminate-group-in-progress'),
+ suppressErrorDialog: true,
+ task: async () => conversation.terminateGroup(),
+ });
+
+ // After success, reset panel state to show conversation timeline
+ const state = getState();
+ const selectedConversationId = getSelectedConversationId(state);
+ const panels = getPanels(state);
+ if (selectedConversationId === conversationId) {
+ if (panels && panels.stack.length === 1) {
+ dispatch(popPanelForConversation());
+ } else {
+ dispatch(
+ showConversation({
+ conversationId,
+ })
+ );
+ }
+ } else {
+ dispatch({
+ type: 'NOOP',
+ payload: null,
+ });
+ }
+ } catch {
+ dispatch({
+ type: SHOW_TERMINATE_GROUP_FAILED_MODAL,
+ payload: {
+ conversationId,
+ },
+ });
+ }
+ };
+}
+
function removeAllConversations(): RemoveAllConversationsActionType {
return {
type: 'CONVERSATIONS_REMOVE_ALL',
diff --git a/ts/state/ducks/globalModals.preload.ts b/ts/state/ducks/globalModals.preload.ts
index c27e7d4afe..fa3828706b 100644
--- a/ts/state/ducks/globalModals.preload.ts
+++ b/ts/state/ducks/globalModals.preload.ts
@@ -174,6 +174,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
safetyNumberModalContactId?: string;
stickerPackPreviewId?: string;
tapToViewNotAvailableModalProps?: TapToViewNotAvailablePropsType;
+ terminateGroupFailedModal: { conversationId: string } | null;
userNotFoundModalState?: UserNotFoundModalStateType;
}>;
@@ -264,6 +265,10 @@ const SHOW_LOW_DISK_SPACE_BACKUP_IMPORT_MODAL =
const HIDE_LOW_DISK_SPACE_BACKUP_IMPORT_MODAL =
'globalModals/HIDE_LOW_DISK_SPACE_BACKUP_IMPORT_MODAL';
const TOGGLE_PIN_MESSAGE_DIALOG = 'globalModals/TOGGLE_PIN_MESSAGE_DIALOG';
+export const SHOW_TERMINATE_GROUP_FAILED_MODAL =
+ 'globalModals/SHOW_TERMINATE_GROUP_FAILED_MODAL';
+const HIDE_TERMINATE_GROUP_FAILED_MODAL =
+ 'globalModals/HIDE_TERMINATE_GROUP_FAILED_MODAL';
export type UserNotFoundModalStateType = ReadonlyDeep<
| {
@@ -557,6 +562,17 @@ type TogglePinMessageDialogActionType = ReadonlyDeep<{
payload: PinMessageDialogData | null;
}>;
+export type ShowTerminateGroupFailedModalActionType = ReadonlyDeep<{
+ type: typeof SHOW_TERMINATE_GROUP_FAILED_MODAL;
+ payload: {
+ conversationId: string;
+ };
+}>;
+
+type HideTerminateGroupFailedModalActionType = ReadonlyDeep<{
+ type: typeof HIDE_TERMINATE_GROUP_FAILED_MODAL;
+}>;
+
export type GlobalModalsActionType = ReadonlyDeep<
| CloseEditHistoryModalActionType
| CloseDebugLogErrorModalActionType
@@ -575,6 +591,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| HideSendAnywayDialogActiontype
| HideStoriesSettingsActionType
| HideTapToViewNotAvailableModalActionType
+ | HideTerminateGroupFailedModalActionType
| HideUserNotFoundModalActionType
| HideWhatsNewModalActionType
| MessageChangedActionType
@@ -595,6 +612,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ShowStickerPackPreviewActionType
| ShowStoriesSettingsActionType
| ShowTapToViewNotAvailableModalActionType
+ | ShowTerminateGroupFailedModalActionType
| ShowUserNotFoundModalActionType
| ShowWhatsNewModalActionType
| StartMigrationToGV2ActionType
@@ -642,6 +660,7 @@ export const actions = {
hideLowDiskSpaceBackupImportModal,
hideStoriesSettings,
hideTapToViewNotAvailableModal,
+ hideTerminateGroupFailedModal,
hideUserNotFoundModal,
hideWhatsNewModal,
showBackfillFailureModal,
@@ -661,6 +680,7 @@ export const actions = {
showStickerPackPreview,
showStoriesSettings,
showTapToViewNotAvailableModal,
+ showTerminateGroupFailedModal,
showUserNotFoundModal,
showWhatsNewModal,
toggleAboutContactModal,
@@ -1381,6 +1401,23 @@ function hideLowDiskSpaceBackupImportModal(): HideLowDiskSpaceBackupImportModalA
};
}
+function showTerminateGroupFailedModal(
+ conversationId: string
+): ShowTerminateGroupFailedModalActionType {
+ return {
+ type: SHOW_TERMINATE_GROUP_FAILED_MODAL,
+ payload: {
+ conversationId,
+ },
+ };
+}
+
+function hideTerminateGroupFailedModal(): HideTerminateGroupFailedModalActionType {
+ return {
+ type: HIDE_TERMINATE_GROUP_FAILED_MODAL,
+ };
+}
+
function toggleEditNicknameAndNoteModal(
payload: EditNicknameAndNoteModalPropsType | null
): ToggleEditNicknameAndNoteModalActionType {
@@ -1549,6 +1586,7 @@ export function getEmptyState(): GlobalModalsStateType {
tapToViewNotAvailableModalProps: undefined,
notePreviewModalProps: null,
pinMessageDialogData: null,
+ terminateGroupFailedModal: null,
};
}
@@ -2032,6 +2070,20 @@ export function reducer(
};
}
+ if (action.type === SHOW_TERMINATE_GROUP_FAILED_MODAL) {
+ return {
+ ...state,
+ terminateGroupFailedModal: action.payload,
+ };
+ }
+
+ if (action.type === HIDE_TERMINATE_GROUP_FAILED_MODAL) {
+ return {
+ ...state,
+ terminateGroupFailedModal: null,
+ };
+ }
+
if (action.type === TOGGLE_PIN_MESSAGE_DIALOG) {
return {
...state,
diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts
index 238b131e02..a96ab7ea26 100644
--- a/ts/state/selectors/conversations.dom.ts
+++ b/ts/state/selectors/conversations.dom.ts
@@ -708,6 +708,7 @@ function canComposeConversation(
!isSignalConversation(conversation) &&
!conversation.isBlocked &&
!conversation.removalStage &&
+ !conversation.terminated &&
((isGroupV2(conversation) && !conversation.left) ||
!isConversationUnregistered(conversation)) &&
hasDisplayInfo(conversation) &&
@@ -723,6 +724,7 @@ export const getAllComposableConversations = createSelector(
!isSignalConversation(conversation) &&
!conversation.isBlocked &&
!conversation.removalStage &&
+ !conversation.terminated &&
!conversation.isGroupV1AndDisabled &&
((isGroupV2(conversation) && !conversation.left) ||
!isConversationUnregistered(conversation)) &&
diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts
index 4fc2baa230..6a3f89c5b7 100644
--- a/ts/state/selectors/message.preload.ts
+++ b/ts/state/selectors/message.preload.ts
@@ -901,6 +901,7 @@ export const getPropsForMessage = (
const conversation = getConversation(message, conversationSelector);
const isGroup = conversation.type === 'group';
+ const isGroupTerminated = isGroup && conversation.terminated;
const { sticker } = message;
const isMessageTapToView = isTapToView(message);
@@ -956,13 +957,13 @@ export const getPropsForMessage = (
: getPropsForAttachment(textAttachment, 'long-message', message),
payment,
canCopy: canCopy(message),
- canEditMessage: canEditMessage(message),
+ canEditMessage: canEditMessage(message) && !isGroupTerminated,
canDeleteForEveryone: canDeleteForEveryoneInSelector(message, {
conversation,
ourAci,
}),
canDownload: canDownload(message, conversationSelector),
- canEndPoll: canEndPoll(message),
+ canEndPoll: canEndPoll(message) && !isGroupTerminated,
canForward: canForward(message),
canPinMessage: canPinMessage(conversation, message),
canReact: canReact(message, ourConversationId, conversationSelector),
@@ -2277,6 +2278,10 @@ function canReplyOrReact(
return false;
}
+ if (conversation.terminated) {
+ return false;
+ }
+
if (deletedForEveryone) {
return false;
}
@@ -2332,6 +2337,7 @@ export function canReply(
const conversation = getConversation(message, conversationSelector);
if (
!conversation ||
+ conversation.terminated ||
(conversation.announcementsOnly && !conversation.areWeAdmin)
) {
return false;
@@ -2363,7 +2369,13 @@ export function canCopy(
type CanDeleteForEveryoneConversation = Pick<
ConversationType,
- 'id' | 'e164' | 'serviceId' | 'groupId' | 'groupVersion' | 'areWeAdmin'
+ | 'id'
+ | 'e164'
+ | 'serviceId'
+ | 'groupId'
+ | 'groupVersion'
+ | 'areWeAdmin'
+ | 'terminated'
>;
type MessageCanDeleteForEveryoneResult = Readonly<{
@@ -2391,7 +2403,7 @@ function getMessageCanDeleteForEveryone(
const { conversation, ourAci } = options;
const isDeletingOwnMessage = isOutgoing(message);
- if (!ourAci) {
+ if (!ourAci || conversation.terminated) {
return {
canDeleteForEveryone: false,
needsAdminDelete: false,
@@ -2618,7 +2630,8 @@ export function canForward(message: ReadonlyMessageAttributesType): boolean {
export function canPinMessages(conversation: ConversationType): boolean {
return (
- conversation.type === 'direct' || conversation.canEditGroupInfo === true
+ conversation.type === 'direct' ||
+ (conversation.canEditGroupInfo === true && !conversation.terminated)
);
}
diff --git a/ts/state/selectors/stories.preload.ts b/ts/state/selectors/stories.preload.ts
index 0f801b3d18..1a1c2b5a63 100644
--- a/ts/state/selectors/stories.preload.ts
+++ b/ts/state/selectors/stories.preload.ts
@@ -281,6 +281,7 @@ export function getConversationStory(
'sortedGroupMembers',
'title',
'left',
+ 'terminated',
]);
const storyView = getStoryView(
diff --git a/ts/state/smart/CompositionArea.preload.tsx b/ts/state/smart/CompositionArea.preload.tsx
index e052891ddf..de4415b255 100644
--- a/ts/state/smart/CompositionArea.preload.tsx
+++ b/ts/state/smart/CompositionArea.preload.tsx
@@ -106,8 +106,13 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({
getComposerStateForConversationIdSelector
);
const composerState = composerStateForConversationIdSelector(id);
- const { announcementsOnly, areWeAdmin, draftEditMessage, draftBodyRanges } =
- conversation;
+ const {
+ announcementsOnly,
+ areWeAdmin,
+ draftEditMessage,
+ draftBodyRanges,
+ terminated,
+ } = conversation;
const {
attachments: draftAttachments,
focusCounter,
@@ -354,6 +359,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({
showGV2MigrationDialog={showGV2MigrationDialog}
cancelJoinRequest={cancelJoinRequest}
sortedGroupMembers={conversation.sortedGroupMembers ?? null}
+ terminated={terminated ?? null}
// Select Mode
selectedMessageIds={selectedMessageIds}
areSelectedMessagesForwardable={areSelectedMessagesForwardable}
diff --git a/ts/state/smart/ConversationDetails.preload.tsx b/ts/state/smart/ConversationDetails.preload.tsx
index a59f566a47..a976f17cb3 100644
--- a/ts/state/smart/ConversationDetails.preload.tsx
+++ b/ts/state/smart/ConversationDetails.preload.tsx
@@ -127,14 +127,18 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
addMembersToGroup,
blockConversation,
deleteAvatarFromDisk,
+ destroyMessages,
getProfilesForConversation,
leaveGroup,
+ onArchive,
+ onMoveToInbox,
replaceAvatar,
saveAvatarToDisk,
setDisappearingMessages,
setMuteExpiration,
showConversation,
startAvatarDownload,
+ terminateGroup,
updateGroupAttributes,
updateNicknameAndNote,
} = useConversationsActions();
@@ -179,6 +183,12 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
remoteConfig: items.remoteConfig,
prodKey: 'desktop.groupMemberLabels.edit.prod',
});
+ const isTerminateGroupEnabled = isFeaturedEnabledSelector({
+ betaKey: 'desktop.groupTerminate.send.beta',
+ currentVersion: version,
+ remoteConfig: items.remoteConfig,
+ prodKey: 'desktop.groupTerminate.send.prod',
+ });
const groupsInCommon = getGroupsInCommonSorted(
conversation,
@@ -209,6 +219,18 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
toggleEditNicknameAndNoteModal({ conversationId });
}, [conversationId, toggleEditNicknameAndNoteModal]);
+ const onConversationArchive = useCallback(() => {
+ onArchive(conversationId);
+ }, [onArchive, conversationId]);
+
+ const onConversationUnarchive = useCallback(() => {
+ onMoveToInbox(conversationId);
+ }, [onMoveToInbox, conversationId]);
+
+ const onConversationDeleteMessages = useCallback(() => {
+ destroyMessages(conversationId);
+ }, [destroyMessages, conversationId]);
+
const [hasMedia, setHasMedia] = useState(false);
useEffect(() => {
@@ -252,12 +274,16 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
isEditMemberLabelEnabled={isEditMemberLabelEnabled}
isGroup={isGroup}
isSignalConversation={isSignalConversation(conversation)}
+ isTerminateGroupEnabled={isTerminateGroupEnabled}
leaveGroup={leaveGroup}
hasMedia={hasMedia}
maxGroupSize={maxGroupSize}
maxRecommendedGroupSize={maxRecommendedGroupSize}
memberColors={memberColors}
memberships={memberships}
+ onConversationArchive={onConversationArchive}
+ onConversationDeleteMessages={onConversationDeleteMessages}
+ onConversationUnarchive={onConversationUnarchive}
onDeleteNicknameAndNote={handleDeleteNicknameAndNote}
onOpenEditNicknameAndNoteModal={handleOpenEditNicknameAndNoteModal}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
@@ -278,6 +304,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
showConversation={showConversation}
showToast={showToast}
startAvatarDownload={() => startAvatarDownload(conversationId)}
+ terminateGroup={terminateGroup}
theme={theme}
toggleAboutContactModal={toggleAboutContactModal}
toggleAddUserToAnotherGroupModal={toggleAddUserToAnotherGroupModal}
diff --git a/ts/state/smart/GV1Members.preload.tsx b/ts/state/smart/GV1Members.preload.tsx
index ae4b72c952..02211a1eb4 100644
--- a/ts/state/smart/GV1Members.preload.tsx
+++ b/ts/state/smart/GV1Members.preload.tsx
@@ -56,6 +56,7 @@ export const SmartGV1Members = memo(function SmartGV1Members({
conversationId={conversationId}
i18n={i18n}
isEditMemberLabelEnabled={false}
+ isTerminated={false}
getPreferredBadge={getPreferredBadge}
maxShownMemberCount={32}
memberColors={memberColors}
diff --git a/ts/state/smart/GlobalModalContainer.preload.tsx b/ts/state/smart/GlobalModalContainer.preload.tsx
index 001548aaf9..514dd85f71 100644
--- a/ts/state/smart/GlobalModalContainer.preload.tsx
+++ b/ts/state/smart/GlobalModalContainer.preload.tsx
@@ -43,6 +43,7 @@ import {
} from '../selectors/backups.std.js';
import { SmartPinMessageDialog } from './PinMessageDialog.preload.js';
import { SmartGroupMemberLabelInfoModal } from './GroupMemberLabelInfoModal.preload.js';
+import { SmartTerminateGroupFailedModal } from './TerminateGroupFailedModal.preload.js';
function renderCallLinkAddNameModal(): React.JSX.Element {
return ;
@@ -196,6 +197,7 @@ export const SmartGlobalModalContainer = memo(
safetyNumberModalContactId,
stickerPackPreviewId,
tapToViewNotAvailableModalProps,
+ terminateGroupFailedModal,
userNotFoundModalState,
} = useSelector(getGlobalModalsState);
@@ -272,6 +274,16 @@ export const SmartGlobalModalContainer = memo(
[closeDebugLogErrorModal, i18n]
);
+ const renderTerminateGroupFailedModal = useCallback(
+ () =>
+ terminateGroupFailedModal ? (
+
+ ) : null,
+ [terminateGroupFailedModal]
+ );
+
return (
terminateGroup(conversationId)}
+ />
+ );
+ }
+);
diff --git a/ts/state/smart/Timeline.preload.tsx b/ts/state/smart/Timeline.preload.tsx
index 4912c69b98..8849a0524f 100644
--- a/ts/state/smart/Timeline.preload.tsx
+++ b/ts/state/smart/Timeline.preload.tsx
@@ -112,6 +112,7 @@ export const SmartTimeline = memo(function SmartTimeline({
acceptedMessageRequest,
isBlocked = false,
isGroupV1AndDisabled,
+ terminated: isGroupTerminated = false,
removalStage,
typingContactIdTimestamps = {},
unreadCount,
@@ -207,6 +208,7 @@ export const SmartTimeline = memo(function SmartTimeline({
isBlocked={isBlocked}
isConversationSelected={isConversationSelected}
isGroupV1AndDisabled={isGroupV1AndDisabled}
+ isGroupTerminated={isGroupTerminated}
isInFullScreenCall={isInFullScreenCall}
isIncomingMessageRequest={isIncomingMessageRequest}
isNearBottom={isNearBottom}
diff --git a/ts/test-electron/backup/backup_groupv2_notifications_test.preload.ts b/ts/test-electron/backup/backup_groupv2_notifications_test.preload.ts
index e0ca2f3111..df24722c27 100644
--- a/ts/test-electron/backup/backup_groupv2_notifications_test.preload.ts
+++ b/ts/test-electron/backup/backup_groupv2_notifications_test.preload.ts
@@ -2007,6 +2007,36 @@ describe('backup/groupv2/notifications', () => {
await symmetricRoundtripHarness(messages);
});
+
+ it('Terminated items', async () => {
+ const messages: Array = [
+ createMessage({
+ from: OUR_ACI,
+ details: [
+ {
+ type: 'terminated',
+ },
+ ],
+ }),
+ createMessage({
+ from: ADMIN_A,
+ details: [
+ {
+ type: 'terminated',
+ },
+ ],
+ }),
+ createMessage({
+ details: [
+ {
+ type: 'terminated',
+ },
+ ],
+ }),
+ ];
+
+ await symmetricRoundtripHarness(messages);
+ });
});
describe('roundtrips given a timer change notification', () => {
diff --git a/ts/test-helpers/getDefaultConversation.std.ts b/ts/test-helpers/getDefaultConversation.std.ts
index 479501adf0..ec142d5fa2 100644
--- a/ts/test-helpers/getDefaultConversation.std.ts
+++ b/ts/test-helpers/getDefaultConversation.std.ts
@@ -99,6 +99,7 @@ export function getDefaultGroup(
markedUnread: Boolean(overrideProps.markedUnread),
membersCount: memberships.length,
memberships,
+ terminated: false,
title: casual.title,
serviceId: generateAci(),
acknowledgedGroupNameCollisions: {},
diff --git a/ts/types/GroupMemberLabels.std.ts b/ts/types/GroupMemberLabels.std.ts
index 88ae2f5adf..bdee660acf 100644
--- a/ts/types/GroupMemberLabels.std.ts
+++ b/ts/types/GroupMemberLabels.std.ts
@@ -29,6 +29,7 @@ export function getCanAddLabel(
return Boolean(
membership &&
conversation.type === 'group' &&
+ !conversation.terminated &&
(membership.isAdmin ||
conversation.accessControlMemberLabel ===
Proto.AccessControl.AccessRequired.MEMBER)
diff --git a/ts/types/Stories.std.ts b/ts/types/Stories.std.ts
index 7e67a26341..e23a8f60e1 100644
--- a/ts/types/Stories.std.ts
+++ b/ts/types/Stories.std.ts
@@ -56,6 +56,7 @@ export type ConversationStoryType = {
| 'sortedGroupMembers'
| 'title'
| 'left'
+ | 'terminated'
>;
isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types
diff --git a/ts/types/groups.std.ts b/ts/types/groups.std.ts
index 8ecb1f56df..217b75c1a8 100644
--- a/ts/types/groups.std.ts
+++ b/ts/types/groups.std.ts
@@ -126,6 +126,9 @@ export type GroupV2DescriptionChangeType = {
export type GroupV2SummaryType = {
type: 'summary';
};
+type GroupV2TerminatedChangeType = {
+ type: 'terminated';
+};
export type GroupV2ChangeDetailType =
| GroupV2AccessAttributesChangeType
@@ -153,6 +156,7 @@ export type GroupV2ChangeDetailType =
| GroupV2PendingRemoveManyChangeType
| GroupV2PendingRemoveOneChangeType
| GroupV2SummaryType
+ | GroupV2TerminatedChangeType
| GroupV2TitleChangeType;
export type GroupV2ChangeType = {
diff --git a/ts/util/getConversation.preload.ts b/ts/util/getConversation.preload.ts
index a17aeca48a..5af679c7f5 100644
--- a/ts/util/getConversation.preload.ts
+++ b/ts/util/getConversation.preload.ts
@@ -171,8 +171,8 @@ export function getConversation(model: ConversationModel): ConversationType {
avatars: getAvatarData(attributes),
badges: attributes.badges ?? EMPTY_ARRAY,
canChangeTimer: canChangeTimer(attributes),
- canEditGroupInfo: canEditGroupInfo(attributes),
- canAddNewMembers: canAddNewMembers(attributes),
+ canEditGroupInfo: canEditGroupInfo(attributes) && !attributes.terminated,
+ canAddNewMembers: canAddNewMembers(attributes) && !attributes.terminated,
avatarUrl: getLocalAvatarUrl(attributes),
rawAvatarPath: getRawAvatarPath(attributes),
avatarHash: getAvatarHash(attributes),
@@ -250,6 +250,7 @@ export function getConversation(model: ConversationModel): ConversationType {
secretParams: attributes.secretParams,
shouldShowDraft,
sortedGroupMembers,
+ terminated: Boolean(attributes.terminated),
timestamp: dropNull(timestamp),
title: getTitle(attributes),
titleNoDefault: getTitleNoDefault(attributes),
diff --git a/ts/util/longRunningTaskWrapper.dom.tsx b/ts/util/longRunningTaskWrapper.dom.tsx
index 7a61d0a42f..65d904090b 100644
--- a/ts/util/longRunningTaskWrapper.dom.tsx
+++ b/ts/util/longRunningTaskWrapper.dom.tsx
@@ -20,11 +20,13 @@ const log = createLogger('longRunningTaskWrapper');
export async function longRunningTaskWrapper({
name,
idForLogging,
+ spinnerText,
task,
suppressErrorDialog,
}: {
name: string;
idForLogging: string;
+ spinnerText?: string;
task: () => Promise;
suppressErrorDialog?: boolean;
}): Promise {
@@ -44,7 +46,7 @@ export async function longRunningTaskWrapper({
-
+
diff --git a/ts/util/maybeForwardMessages.preload.ts b/ts/util/maybeForwardMessages.preload.ts
index e5e2193db6..6e3eb7cdf2 100644
--- a/ts/util/maybeForwardMessages.preload.ts
+++ b/ts/util/maybeForwardMessages.preload.ts
@@ -58,7 +58,8 @@ export async function maybeForwardMessages(
const cannotSend = conversations.some(
conversation =>
- conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
+ conversation?.get('terminated') ||
+ (conversation?.get('announcementsOnly') && !conversation.areWeAdmin())
);
if (cannotSend) {
throw new Error('Cannot send to group');
diff --git a/ts/util/sendStoryMessage.preload.ts b/ts/util/sendStoryMessage.preload.ts
index 4e23396257..89e0ce278c 100644
--- a/ts/util/sendStoryMessage.preload.ts
+++ b/ts/util/sendStoryMessage.preload.ts
@@ -216,6 +216,14 @@ export async function sendStoryMessage(
return;
}
+ if (group.get('terminated')) {
+ log.warn(
+ 'stories.sendStoryMessage: cannot send to a terminated group',
+ conversationId
+ );
+ return;
+ }
+
if (group.get('announcementsOnly') && !group.areWeAdmin()) {
log.warn(
'stories.sendStoryMessage: cannot send to an announcement only group as a non-admin',