Init group terminate

This commit is contained in:
ayumi-signal
2026-03-25 17:00:48 -07:00
committed by GitHub
parent 61095cc0a1
commit e7544a5565
63 changed files with 1090 additions and 70 deletions
+76
View File
@@ -3152,6 +3152,10 @@
"messageformat": "Support Center",
"description": "Label for the support center link in conversation details"
},
"icu:ConversationDetails__GroupTerminatedBanner": {
"messageformat": "This group was ended",
"description": "Conversation Header banner in conversation details for terminated groups"
},
"icu:SafetyNumberNotification__viewSafetyNumber": {
"messageformat": "View Safety Number",
"description": "In conversation, safety number change notification, label for button to view safety number, opens safety number modal"
@@ -5260,6 +5264,10 @@
"messageformat": "Your request to join has been sent to the group admin. Youll be notified when they take action.",
"description": "Shown in composition area when you've requested to join a group"
},
"icu:GroupV2--join--group-terminated": {
"messageformat": "Join by link failed, group has ended.",
"description": "Shown if you click a group link for a group which was terminated"
},
"icu:GroupV2--join--general-join-failure--title": {
"messageformat": "Link Error",
"description": "Shown if something went wrong when you try to join via a group link"
@@ -5268,6 +5276,18 @@
"messageformat": "Couldn't join group. Try again later.",
"description": "Shown if something went wrong when you try to join via a group link"
},
"icu:GroupV2--terminate-group-in-progress": {
"messageformat": "Ending group...",
"description": "Shown while waiting for a group terminate request to finish"
},
"icu:TerminateGroupFailedModal__description": {
"messageformat": "Ending the group failed. Check your connection and try again.",
"description": "Shown when a group terminate request failed"
},
"icu:TerminateGroupFailedModal__try-again": {
"messageformat": "Try again",
"description": "Shown when a group terminate request failed"
},
"icu:GroupV2--admin": {
"messageformat": "Admin",
"description": "Label for a group administrator"
@@ -5856,6 +5876,18 @@
"messageformat": "This group's members or settings have changed.",
"description": "When rejoining a group, any detected changes are collapsed down into this summary"
},
"icu:GroupV2--terminated--you": {
"messageformat": "You ended the group",
"description": "Shown in timeline or conversation preview when group is terminated by you"
},
"icu:GroupV2--terminated--other": {
"messageformat": "{memberName} ended the group",
"description": "Shown in timeline or conversation preview when group is terminated by someone else"
},
"icu:GroupV2--terminated--unknown": {
"messageformat": "This group was ended",
"description": "Shown in timeline or conversation preview when group is terminated"
},
"icu:GroupV1--Migration--disabled--link": {
"messageformat": "Upgrade this group to activate new features like @mentions and admins. Members who have not shared their name or photo in this group will be invited to join. <learnMoreLink>Learn more.</learnMoreLink>",
"description": "Shown instead of composition area when user is forced to migrate a legacy group (GV1)."
@@ -6224,6 +6256,10 @@
"messageformat": "Replace",
"description": "Composition Area > GIF Picker > After selecting a GIF > When you already have an attachment > Confirm Dialog > Replace Button"
},
"icu:CompositionArea--group-terminated": {
"messageformat": "You cant send messages because the group was ended.",
"description": "Shown instead of message composer when group is terminated"
},
"icu:CompositionArea--viewOnceToggle": {
"messageformat": "View once",
"description": "Aria label for the view once toggle button in the composition input"
@@ -6460,6 +6496,10 @@
"messageformat": "Leave group",
"description": "This is a button to leave a group"
},
"icu:ConversationDetailsActions--terminate-group": {
"messageformat": "End group",
"description": "This is a button to terminate a group permanently"
},
"icu:ConversationDetailsActions--block-group": {
"messageformat": "Block group",
"description": "This is a button to block a group"
@@ -6468,6 +6508,18 @@
"messageformat": "Unblock group",
"description": "This is a button to unblock a group"
},
"icu:ConversationDetailsActions--archive": {
"messageformat": "Archive chat",
"description": "This is a button to archive a chat"
},
"icu:ConversationDetailsActions--unarchive": {
"messageformat": "Unarchive chat",
"description": "This is a button to unarchive a chat"
},
"icu:ConversationDetailsActions--delete": {
"messageformat": "Delete chat",
"description": "This is a button to delete a chat"
},
"icu:ConversationDetailsActions--leave-group-must-choose-new-admin": {
"messageformat": "Before you leave, you must choose at least one new admin for this group.",
"description": "Shown if, before leaving a group, you need to choose an admin"
@@ -6484,6 +6536,22 @@
"messageformat": "Leave",
"description": "This is the modal button to confirm leaving a group"
},
"icu:ConversationDetailsActions--prompt-terminate-group-modal-title": {
"messageformat": "End \"{groupName}\"?",
"description": "This is the modal title for confirming terminating a group"
},
"icu:ConversationDetailsActions--prompt-terminate-group-modal-content": {
"messageformat": "Members will no longer be able to send messages or start calls in the group. They will be notified that you ended the group, and will still have access to message history.",
"description": "This is the modal content for terminating a group. After this modal, a second confirmation modal will show."
},
"icu:ConversationDetailsActions--terminate-group-modal-confirm": {
"messageformat": "End group",
"description": "This is the modal button to confirm terminating a group"
},
"icu:ConversationDetailsActions--confirm-terminate-group-confirm-modal-content": {
"messageformat": "This will end the group permanently. Are you sure you want to proceed?",
"description": "This is the modal content for confirming terminating a group"
},
"icu:ConversationDetailsActions--unblock-group-modal-title": {
"messageformat": "Unblock the \"{groupName}\" Group?",
"description": "This is the modal title for confirming unblock of a group"
@@ -6520,6 +6588,10 @@
"messageformat": "{number, plural, one {# member} other {# members}}",
"description": "The title of the membership list panel"
},
"icu:ConversationDetailsMembershipList--terminated-title": {
"messageformat": "{number, plural, one {# former member} other {# former members}}",
"description": "The title of the membership list panel for a terminated group"
},
"icu:ConversationDetailsMembershipList--add-members": {
"messageformat": "Add members",
"description": "The button that you can click to add new members"
@@ -8924,6 +8996,10 @@
"messageformat": "Delete this story? It will also be deleted for everyone who received it.",
"description": "Confirmation dialog description text for deleting a story"
},
"icu:MyStories__delete-group-story-for-me": {
"messageformat": "Delete this story? It will only be deleted for you because the group has ended.",
"description": "Confirmation dialog description text for deleting a story"
},
"icu:payment-event-notification-message-you-label": {
"messageformat": "You started a payment to {receiver}",
"description": "Payment event notification from you message bubble label"
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

+8 -2
View File
@@ -312,6 +312,7 @@ message Group {
bytes inviteLinkPassword = 10;
bool announcements_only = 12;
repeated MemberBanned members_banned = 13;
bool terminated = 14;
}
message GroupAttributeBlob {
@@ -1053,7 +1054,6 @@ message GroupChangeChatUpdate {
GroupAvatarUpdate groupAvatarUpdate = 4;
GroupDescriptionUpdate groupDescriptionUpdate = 5;
GroupMembershipAccessLevelChangeUpdate groupMembershipAccessLevelChangeUpdate = 6;
GroupMemberLabelAccessLevelChangeUpdate groupMemberLabelAccessLevelChangeUpdate = 35;
GroupAttributesAccessLevelChangeUpdate groupAttributesAccessLevelChangeUpdate = 7;
GroupAnnouncementOnlyChangeUpdate groupAnnouncementOnlyChangeUpdate = 8;
GroupAdminStatusUpdate groupAdminStatusUpdate = 9;
@@ -1082,7 +1082,9 @@ message GroupChangeChatUpdate {
GroupV2MigrationDroppedMembersUpdate groupV2MigrationDroppedMembersUpdate = 32;
GroupSequenceOfRequestsAndCancelsUpdate groupSequenceOfRequestsAndCancelsUpdate = 33;
GroupExpirationTimerUpdate groupExpirationTimerUpdate = 34;
// next: 36
GroupMemberLabelAccessLevelChangeUpdate groupMemberLabelAccessLevelChangeUpdate = 35;
GroupTerminateChangeUpdate groupTerminateChangeUpdate = 36;
// next: 37
}
}
@@ -1297,6 +1299,10 @@ message GroupExpirationTimerUpdate {
optional bytes updaterAci = 2;
}
message GroupTerminateChangeUpdate {
optional bytes updaterAci = 1;
}
message PollTerminateUpdate {
uint64 targetSentTimestamp = 1;
string question = 2; // Between 1-100 characters
+7 -3
View File
@@ -86,7 +86,8 @@ message Group {
bytes inviteLinkPassword = 10;
bool announcements_only = 12;
repeated MemberBanned members_banned = 13;
// next: 14
bool terminated = 14;
// next: 15
}
message GroupAttributeBlob {
@@ -237,6 +238,8 @@ message GroupChange {
bool announcements_only = 1;
}
message TerminateGroupAction {}
bytes sourceUserId = 1;
// clients should not provide this value; the server will provide it in the response buffer to ensure the signature is binding to a particular group
// if clients set it during a request the server will respond with 400.
@@ -246,7 +249,7 @@ message GroupChange {
repeated AddMemberAction addMembers = 3;
repeated DeleteMemberAction deleteMembers = 4;
repeated ModifyMemberRoleAction modifyMemberRoles = 5;
repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6;
repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6
repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6;
repeated AddMemberPendingProfileKeyAction addMembersPendingProfileKey = 7;
repeated DeleteMemberPendingProfileKeyAction deleteMembersPendingProfileKey = 8;
@@ -267,7 +270,8 @@ message GroupChange {
repeated AddMemberBannedAction add_members_banned = 22; // change epoch = 4
repeated DeleteMemberBannedAction delete_members_banned = 23; // change epoch = 4
repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5
// next: 28
TerminateGroupAction terminate_group = 28; // change epoch = 7
// next: 29
}
bytes actions = 1;
@@ -88,9 +88,12 @@
}
}
&__block-group {
&__block-group,
&__terminate-group,
&__delete {
color: variables.$color-accent-red;
}
&__unblock-group {
color: variables.$color-accent-blue;
}
@@ -192,6 +195,12 @@
}
}
&--archive {
&::after {
@include details-icon('../images/icons/v3/archive/archive.svg');
}
}
&--approve {
&::after {
@include details-icon('../images/icons/v3/check/check.svg');
@@ -236,6 +245,16 @@
}
}
&--delete {
&::after {
@include details-icon(
'../images/icons/v3/trash/trash.svg',
variables.$color-accent-red,
variables.$color-accent-red
);
}
}
&--invites {
&::after {
@include details-icon('../images/icons/v3/group/group.svg');
@@ -338,6 +357,16 @@
opacity: 0.5;
}
}
&--terminate {
&::after {
@include details-icon(
'../images/icons/v3/x/x-circle.svg',
variables.$color-accent-red,
variables.$color-accent-red
);
}
}
}
}
@@ -143,6 +143,15 @@
@include system-message-icon('../images/icons/v3/info/info-compact.svg');
}
&--icon-group-terminate::before {
@include system-message-icon(
'../images/icons/v3/group/group-x-inline.svg'
);
& {
width: 24px;
}
}
&--icon-info::before {
@include system-message-icon('../images/icons/v3/info/info-compact.svg');
}
+2
View File
@@ -38,6 +38,8 @@ const SemverKeys = [
'desktop.binaryServiceId.prod',
'desktop.groupMemberLabels.edit.beta',
'desktop.groupMemberLabels.edit.prod',
'desktop.groupTerminate.send.beta',
'desktop.groupTerminate.send.prod',
'desktop.keyTransparency.beta',
'desktop.keyTransparency.prod',
'desktop.localBackups.beta',
+7
View File
@@ -2242,6 +2242,13 @@ export async function startApp(): Promise<void> {
}
}
if (conversation?.get('terminated')) {
log.info(
`onTyping: conversation ${conversation.idForLogging()} is terminated group, dropping typing message`
);
return;
}
if (conversation?.isBlocked()) {
log.info(
`onTyping: conversation ${conversation.idForLogging()} is blocked, dropping typing message`
@@ -72,6 +72,7 @@ export default {
},
announcementsOnly: { control: { type: 'boolean' } },
areWePendingApproval: { control: { type: 'boolean' } },
terminated: { control: { type: 'boolean' } },
},
args: {
acceptedMessageRequest: true,
@@ -145,6 +146,7 @@ export default {
announcementsOnly: false,
areWeAdmin: false,
areWePendingApproval: false,
terminated: false,
groupAdmins,
memberColors,
cancelJoinRequest: action('cancelJoinRequest'),
@@ -259,6 +261,13 @@ export function AnnouncementsOnlyGroup(args: Props): React.JSX.Element {
);
}
export function TerminatedGroup(args: Props): React.JSX.Element {
const theme = useContext(StorybookThemeContext);
return (
<CompositionArea {...args} theme={theme} terminated areWeAdmin={false} />
);
}
export function Quote(args: Props): React.JSX.Element {
const theme = useContext(StorybookThemeContext);
return (
+15
View File
@@ -214,6 +214,7 @@ export type OwnProps = Readonly<{
shouldSendHighQualityAttachments: boolean;
showConversation: ShowConversationType;
startRecording: (id: string) => unknown;
terminated: boolean | null;
theme: ThemeType;
renderSmartCompositionRecording: () => React.JSX.Element;
renderSmartCompositionRecordingDraft: (
@@ -348,6 +349,7 @@ export const CompositionArea = memo(function CompositionArea({
areWeAdmin,
groupAdmins,
memberColors,
terminated,
cancelJoinRequest,
showConversation,
// SMS-only contacts
@@ -984,6 +986,19 @@ export const CompositionArea = memo(function CompositionArea({
);
}
if (terminated) {
return (
<div
className={tw(
'border-t-[1px] border-border-primary py-[16px]',
'text-center type-body-small text-label-secondary select-none'
)}
>
{i18n('icu:CompositionArea--group-terminated')}
</div>
);
}
if (
isBlocked ||
areWePending ||
@@ -13,12 +13,13 @@ import { tw } from '../axo/tw.dom.js';
export type Props = {
i18n: LocalizerType;
disabled?: boolean;
value?: DurationInSeconds;
onChange(value: DurationInSeconds): void;
};
export function DisappearingTimerSelect(props: Props): React.JSX.Element {
const { i18n, value = DurationInSeconds.ZERO, onChange } = props;
const { i18n, disabled, value = DurationInSeconds.ZERO, onChange } = props;
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -99,6 +100,7 @@ export function DisappearingTimerSelect(props: Props): React.JSX.Element {
return (
<div className={tw('relative')}>
<AxoSelect.Root
disabled={disabled}
value={String(isCustomTimeSelected ? -1 : value)}
onValueChange={onSelectChange}
>
@@ -190,6 +190,9 @@ export type PropsType = {
// LocalBackupExportWorkflow
shouldShowLocalBackupExportWorkflow: boolean;
renderLocalBackupExportWorkflow: () => React.JSX.Element;
// TerminateGroupFailedModal
terminateGroupFailedModal: { conversationId: string } | null;
renderTerminateGroupFailedModal: () => React.JSX.Element | null;
};
export function GlobalModalContainer({
@@ -314,6 +317,9 @@ export function GlobalModalContainer({
// LocalBackupExportWorkflow
shouldShowLocalBackupExportWorkflow,
renderLocalBackupExportWorkflow,
// TerminateGroupFailedModal
terminateGroupFailedModal,
renderTerminateGroupFailedModal,
}: PropsType): React.JSX.Element | null {
// We want the following dialogs to show in this order:
// 0. Stateful multi-modal workflows
@@ -565,5 +571,9 @@ export function GlobalModalContainer({
);
}
if (terminateGroupFailedModal) {
return renderTerminateGroupFailedModal();
}
return null;
}
+5 -1
View File
@@ -7,10 +7,12 @@ import { Spinner } from './Spinner.dom.js';
export type PropsType = {
readonly i18n: LocalizerType;
readonly description?: string;
};
// TODO: This should use <Modal>. See DESKTOP-1038.
export const ProgressDialog = React.memo(function ProgressDialogInner({
description,
i18n,
}: PropsType) {
return (
@@ -18,7 +20,9 @@ export const ProgressDialog = React.memo(function ProgressDialogInner({
<div className="module-progress-dialog__spinner">
<Spinner svgSize="normal" size="39px" direction="on-progress-dialog" />
</div>
<div className="module-progress-dialog__text">{i18n('icu:updating')}</div>
<div className="module-progress-dialog__text">
{description ?? i18n('icu:updating')}
</div>
</div>
);
});
+3 -1
View File
@@ -8,9 +8,11 @@ import type { LocalizerType } from '../types/Util.std.js';
export type PropsType = {
readonly i18n: LocalizerType;
readonly description?: string;
};
export const ProgressModal = React.memo(function ProgressModalInner({
description,
i18n,
}: PropsType) {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
@@ -32,7 +34,7 @@ export const ProgressModal = React.memo(function ProgressModalInner({
return root
? createPortal(
<div role="presentation" className="module-progress-dialog__overlay">
<ProgressDialog i18n={i18n} />
<ProgressDialog description={description} i18n={i18n} />
</div>,
root
)
+12 -1
View File
@@ -49,6 +49,7 @@ import {
} from '../types/VisualAttachment.dom.js';
import { UserText } from './UserText.dom.js';
import { Theme } from '../util/theme.std.js';
import { strictAssert } from '../util/assert.std.js';
const { noop, sortBy } = lodash;
@@ -183,6 +184,11 @@ export function SendStoryModal({
[distributionLists, groupStories, selectedGroupIds, selectedListIds, i18n]
);
const nonTerminatedGroupStories = useMemo(
() => groupStories.filter(group => !group.terminated),
[groupStories]
);
const [searchTerm, setSearchTerm] = useState('');
const [filteredConversations, setFilteredConversations] = useState(
@@ -629,7 +635,7 @@ export function SendStoryModal({
// my stories always first, the rest sorted by recency
const fullList = sortBy(
[...groupStories, ...distributionLists],
[...nonTerminatedGroupStories, ...distributionLists],
listOrGroup => {
if (listOrGroup.id === MY_STORY_ID) {
return Number.NEGATIVE_INFINITY;
@@ -790,6 +796,11 @@ export function SendStoryModal({
return;
}
strictAssert(
!group.terminated,
"Can't send story to terminated group"
);
setSelectedGroupIds(groupIds => {
if (value) {
groupIds.add(group.id);
+5 -1
View File
@@ -79,6 +79,7 @@ export type PropsType = {
| 'sortedGroupMembers'
| 'title'
| 'left'
| 'terminated'
>;
hasActiveCall?: boolean;
hasAllStoriesUnmuted: boolean;
@@ -483,6 +484,7 @@ export function StoryViewer({
| undefined;
if (isSent) {
// TODO: DESKTOP-9943
contextMenuOptions = [
{
icon: 'StoryListItem__icon--info',
@@ -1001,7 +1003,9 @@ export function StoryViewer({
i18n={i18n}
onClose={() => setConfirmDeleteStory(undefined)}
>
{i18n('icu:MyStories__delete')}
{group?.terminated
? i18n('icu:MyStories__delete-group-story-for-me')
: i18n('icu:MyStories__delete')}
</ConfirmationDialog>
)}
</div>
@@ -0,0 +1,30 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, StoryFn } from '@storybook/react';
import React, { type ComponentProps } from 'react';
import { action } from '@storybook/addon-actions';
import { TerminateGroupFailedModal } from './TerminateGroupFailedModal.dom.js';
const { i18n } = window.SignalContext;
type PropsType = ComponentProps<typeof TerminateGroupFailedModal>;
export default {
title: 'Components/TerminateGroupFailedModal',
component: TerminateGroupFailedModal,
args: {
i18n,
onClose: action('close'),
onRetry: action('retry'),
},
} satisfies Meta<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = args => (
<TerminateGroupFailedModal {...args} />
);
export const Modal = Template.bind({});
Modal.args = {};
@@ -0,0 +1,42 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import type { LocalizerType } from '../types/Util.std.js';
import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js';
export type PropsType = Readonly<{
i18n: LocalizerType;
onClose: () => void;
onRetry: () => void;
}>;
export function TerminateGroupFailedModal(props: PropsType): React.JSX.Element {
const { i18n, onClose, onRetry } = props;
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
onClose();
}
},
[onClose]
);
return (
<AxoAlertDialog.Root open onOpenChange={handleOpenChange}>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Description>
{i18n('icu:TerminateGroupFailedModal__description')}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>{i18n('icu:cancel')}</AxoAlertDialog.Cancel>
<AxoAlertDialog.Action variant="secondary" onClick={onRetry}>
{i18n('icu:TerminateGroupFailedModal__try-again')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}
@@ -257,6 +257,8 @@ export const ConversationHeader = memo(function ConversationHeader({
MessageRequestState.default
);
const isTerminated = Boolean(conversation.terminated);
if (hasPanelShowing) {
return null;
}
@@ -343,17 +345,19 @@ export const ConversationHeader = memo(function ConversationHeader({
onViewConversationDetails={onViewConversationDetails}
isSignalConversation={isSignalConversation ?? false}
/>
{!isSmsOnlyOrUnregistered && !isSignalConversation && (
<OutgoingCallButtons
conversation={conversation}
hasActiveCall={hasActiveCall}
i18n={i18n}
isNarrow={isNarrow}
onOutgoingAudioCall={onOutgoingAudioCall}
onOutgoingVideoCall={onOutgoingVideoCall}
outgoingCallButtonStyle={outgoingCallButtonStyle}
/>
)}
{!isSmsOnlyOrUnregistered &&
!isSignalConversation &&
!isTerminated && (
<OutgoingCallButtons
conversation={conversation}
hasActiveCall={hasActiveCall}
i18n={i18n}
isNarrow={isNarrow}
onOutgoingAudioCall={onOutgoingAudioCall}
onOutgoingVideoCall={onOutgoingVideoCall}
outgoingCallButtonStyle={outgoingCallButtonStyle}
/>
)}
<button
type="button"
onClick={onSearchInConversation}
@@ -378,6 +382,7 @@ export const ConversationHeader = memo(function ConversationHeader({
<HeaderDropdownMenuContent
i18n={i18n}
conversation={conversation}
isTerminated={isTerminated}
isMissingMandatoryProfileSharing={
isMissingMandatoryProfileSharing ?? false
}
@@ -588,6 +593,7 @@ function HeaderDropdownMenuContent({
isMissingMandatoryProfileSharing,
isSelectMode,
isSignalConversation,
isTerminated,
onChangeDisappearingMessages,
onChangeMuteExpiration,
onConversationAccept,
@@ -613,6 +619,7 @@ function HeaderDropdownMenuContent({
isMissingMandatoryProfileSharing: boolean;
isSelectMode: boolean;
isSignalConversation: boolean;
isTerminated: boolean;
onChangeDisappearingMessages: (seconds: DurationInSeconds) => void;
onChangeMuteExpiration: (seconds: number) => void;
onConversationAccept: () => void;
@@ -912,12 +919,12 @@ function HeaderDropdownMenuContent({
{i18n('icu:archiveConversation')}
</AxoDropdownMenu.Item>
)}
{!conversation.isBlocked && (
{!conversation.isBlocked && !isTerminated && (
<AxoDropdownMenu.Item symbol="block" onSelect={onConversationBlock}>
{i18n('icu:ConversationHeader__MenuItem--Block')}
</AxoDropdownMenu.Item>
)}
{conversation.isBlocked && (
{conversation.isBlocked && !isTerminated && (
<AxoDropdownMenu.Item
symbol="message-thread"
onSelect={onConversationUnblock}
@@ -931,7 +938,7 @@ function HeaderDropdownMenuContent({
>
{i18n('icu:deleteConversation')}
</AxoDropdownMenu.Item>
{isGroup && (
{isGroup && !isTerminated && (
<AxoDropdownMenu.Item
symbol="leave"
onSelect={onConversationLeaveGroup}
@@ -966,8 +973,8 @@ function OutgoingCallButtons({
>): React.JSX.Element | null {
const disabled =
conversation.type === 'group' &&
conversation.announcementsOnly &&
!conversation.areWeAdmin;
((conversation.announcementsOnly && !conversation.areWeAdmin) ||
conversation.terminated);
const inAnotherCall = !disabled && hasActiveCall;
const videoButton = (
@@ -1796,6 +1796,36 @@ export function AnnouncementGroupChange(): React.JSX.Element {
);
}
export function GroupTerminated(): React.JSX.Element {
return (
<>
{renderChange({
from: OUR_ACI,
details: [
{
type: 'terminated',
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'terminated',
},
],
})}
{renderChange({
details: [
{
type: 'terminated',
},
],
})}
</>
);
}
export function Summary(): React.JSX.Element {
return (
<>
@@ -89,7 +89,9 @@ type GroupIconType =
| 'group-edit'
| 'group-summary'
| 'group-leave'
| 'group-remove';
| 'group-remove'
// TODO: DESKTOP-9894
| 'group-terminate';
const changeToIconMap: Record<GroupV2ChangeDetailType['type'], GroupIconType> =
{
@@ -119,6 +121,8 @@ const changeToIconMap: Record<GroupV2ChangeDetailType['type'], GroupIconType> =
'pending-remove-one': 'group-decline',
summary: 'group-summary',
title: 'group-edit',
// TODO: DESKTOP-9894
terminated: 'group-terminate',
};
function getIcon(
detail: GroupV2ChangeDetailType,
@@ -39,6 +39,7 @@ export type PropsType = SystemMessageBaseProps &
| 'group-leave'
| 'group-remove'
| 'group-summary'
| 'group-terminate'
| 'info'
| 'phone'
| 'profile'
@@ -454,6 +454,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
haveOldest: overrideProps.haveOldest ?? false,
isBlocked: false,
isConversationSelected: true,
isGroupTerminated: false,
isIncomingMessageRequest: overrideProps.isIncomingMessageRequest ?? false,
isInFullScreenCall: false,
items:
+4 -2
View File
@@ -75,6 +75,7 @@ type PropsHousekeepingType = {
isBlocked: boolean;
isConversationSelected: boolean;
isGroupV1AndDisabled?: boolean;
isGroupTerminated: boolean;
isInFullScreenCall: boolean;
isIncomingMessageRequest: boolean;
isSomeoneTyping: boolean;
@@ -660,7 +661,8 @@ export class Timeline extends React.Component<
public override componentDidMount(): void {
const containerEl = this.#containerRef.current;
const messagesEl = this.#messagesRef.current;
const { conversationType, isConversationSelected } = this.props;
const { conversationType, isConversationSelected, isGroupTerminated } =
this.props;
strictAssert(
// We don't render anything unless the conversation is selected
(containerEl && messagesEl) || !isConversationSelected,
@@ -684,7 +686,7 @@ export class Timeline extends React.Component<
this.#markNewestBottomVisibleMessageReadAfterDelay
);
if (conversationType === 'group') {
if (conversationType === 'group' && !isGroupTerminated) {
this.#setupGroupCallPeekTimeouts();
}
}
@@ -21,6 +21,7 @@ import { getFakeCallHistoryGroup } from '../../../test-helpers/getFakeCallHistor
import type { ContactNameColorType } from '../../../types/Colors.std.js';
import { ContactNameColors } from '../../../types/Colors.std.js';
import { isNotNil } from '../../../util/isNotNil.std.js';
import { strictAssert } from '../../../util/assert.std.js';
const { times } = lodash;
@@ -97,6 +98,7 @@ const createProps = (
isEditMemberLabelEnabled: true,
isGroup: true,
isSignalConversation: false,
isTerminateGroupEnabled: true,
leaveGroup: action('leaveGroup'),
hasMedia: true,
memberships,
@@ -125,9 +127,13 @@ const createProps = (
setMuteExpiration: action('setMuteExpiration'),
showToast: action('showToast'),
userAvatarData: [],
terminateGroup: action('terminateGroup'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
toggleAboutContactModal: action('toggleAboutContactModal'),
toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'),
onConversationArchive: action('onConversationArchive'),
onConversationDeleteMessages: action('onConversationDeleteMessages'),
onConversationUnarchive: action('onConversationUnarchive'),
onDeleteNicknameAndNote: action('onDeleteNicknameAndNote'),
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
onOutgoingAudioCallInConversation: action(
@@ -310,3 +316,50 @@ export function SignalConversation(): React.JSX.Element {
<ConversationDetails {...props} isSignalConversation isGroup={false} />
);
}
export function TerminatedGroup(): React.JSX.Element {
const props = createProps();
strictAssert(props.conversation, 'conversation must exist');
return (
<ConversationDetails
{...props}
canAddLabel={false}
canAddNewMembers={false}
canEditGroupInfo={false}
conversation={{
...props.conversation,
terminated: true,
}}
/>
);
}
export function TerminatedGroupAsAdmin(): React.JSX.Element {
const props = createProps();
strictAssert(props.conversation, 'conversation must exist');
return (
<ConversationDetails
{...props}
conversation={{
...props.conversation,
terminated: true,
}}
canAddLabel={false}
canAddNewMembers={false}
canEditGroupInfo={false}
isAdmin
memberships={[
{
isAdmin: true,
labelEmoji: undefined,
labelString: undefined,
member: getDefaultConversation({
isMe: true,
}),
},
]}
/>
);
}
@@ -98,6 +98,7 @@ export type StateProps = {
isEditMemberLabelEnabled: boolean;
isGroup: boolean;
isSignalConversation: boolean;
isTerminateGroupEnabled: boolean;
groupsInCommon: ReadonlyArray<ConversationType>;
maxGroupSize: number;
maxRecommendedGroupSize: number;
@@ -133,6 +134,9 @@ type ActionProps = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
getProfilesForConversation: (id: string) => unknown;
leaveGroup: (conversationId: string) => void;
onConversationArchive: () => void;
onConversationDeleteMessages: () => void;
onConversationUnarchive: () => void;
onDeleteNicknameAndNote: () => void;
onOpenEditNicknameAndNoteModal: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => unknown;
@@ -145,6 +149,7 @@ type ActionProps = {
setMuteExpiration: (id: string, muteExpiresAt: undefined | number) => unknown;
showContactModal: (payload: ContactModalStateType) => void;
showConversation: ShowConversationType;
terminateGroup: (conversationId: string) => void;
toggleAboutContactModal: (options: ContactModalStateType) => void;
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
toggleSafetyNumberModal: (conversationId: string) => unknown;
@@ -190,11 +195,15 @@ export function ConversationDetails({
isEditMemberLabelEnabled,
isGroup,
isSignalConversation,
isTerminateGroupEnabled,
leaveGroup,
memberships,
memberColors,
maxGroupSize,
maxRecommendedGroupSize,
onConversationArchive,
onConversationDeleteMessages,
onConversationUnarchive,
onDeleteNicknameAndNote,
onOpenEditNicknameAndNoteModal,
onOutgoingAudioCallInConversation,
@@ -215,6 +224,7 @@ export function ConversationDetails({
showConversation,
showToast,
startAvatarDownload,
terminateGroup,
theme,
toggleAboutContactModal,
toggleSafetyNumberModal,
@@ -244,6 +254,10 @@ export function ConversationDetails({
const cannotLeaveBecauseYouAreLastAdmin =
getCannotLeaveBecauseYouAreLastAdmin(memberships, isAdmin);
const isGroupTerminated = Boolean(conversation.terminated);
const canTerminateGroup =
isTerminateGroupEnabled && !isGroupTerminated && isAdmin;
const onCloseModal = useCallback(() => {
setModalState(ModalState.NothingOpen);
setEditGroupAttributesRequestState(RequestState.Inactive);
@@ -445,12 +459,16 @@ export function ConversationDetails({
)}
{!conversation.isMe && !isSignalConversation && (
<>
<ConversationDetailsCallButton
hasActiveCall={hasActiveCall}
i18n={i18n}
onClick={() => onOutgoingVideoCallInConversation(conversation.id)}
type="video"
/>
{!conversation.terminated && (
<ConversationDetailsCallButton
hasActiveCall={hasActiveCall}
i18n={i18n}
onClick={() =>
onOutgoingVideoCallInConversation(conversation.id)
}
type="video"
/>
)}
{!isGroup && (
<ConversationDetailsCallButton
hasActiveCall={hasActiveCall}
@@ -588,6 +606,7 @@ export function ConversationDetails({
<DisappearingTimerSelect
i18n={i18n}
value={conversation.expireTimer || DurationInSeconds.ZERO}
disabled={conversation.terminated}
onChange={value =>
setDisappearingMessages(conversation.id, value)
}
@@ -735,6 +754,7 @@ export function ConversationDetails({
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isEditMemberLabelEnabled={isEditMemberLabelEnabled}
isTerminated={isGroupTerminated}
memberships={memberships}
memberColors={memberColors}
showContactModal={showContactModal}
@@ -750,7 +770,7 @@ export function ConversationDetails({
/>
)}
{isGroup && (
{isGroup && !isGroupTerminated && (
<PanelSection>
{isAdmin || hasGroupLink ? (
<PanelRow
@@ -852,13 +872,20 @@ export function ConversationDetails({
acceptConversation={acceptConversation}
blockConversation={blockConversation}
cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin}
canTerminateGroup={canTerminateGroup}
conversationId={conversation.id}
conversationTitle={conversation.title}
i18n={i18n}
isArchived={Boolean(conversation.isArchived)}
isBlocked={Boolean(conversation.isBlocked)}
isGroup={isGroup}
isGroupTerminated={isGroupTerminated}
left={Boolean(conversation.left)}
onArchive={onConversationArchive}
onDelete={onConversationDeleteMessages}
onUnarchive={onConversationUnarchive}
onLeave={() => leaveGroup(conversation.id)}
onTerminateGroup={() => terminateGroup(conversation.id)}
/>
)}
@@ -20,6 +20,9 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
acceptConversation: action('acceptConversation'),
blockConversation: action('blockConversation'),
canTerminateGroup: isBoolean(overrideProps.canTerminateGroup)
? overrideProps.canTerminateGroup
: true,
cannotLeaveBecauseYouAreLastAdmin: isBoolean(
overrideProps.cannotLeaveBecauseYouAreLastAdmin
)
@@ -28,10 +31,20 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationId: '123',
conversationTitle: overrideProps.conversationTitle || '',
i18n,
isArchived: isBoolean(overrideProps.isArchived)
? overrideProps.isArchived
: false,
isBlocked: isBoolean(overrideProps.isBlocked),
isGroup: true,
isGroupTerminated: isBoolean(overrideProps.isGroupTerminated)
? overrideProps.isGroupTerminated
: false,
left: isBoolean(overrideProps.left) ? overrideProps.left : false,
onArchive: action('onArchive'),
onDelete: action('onDelet'),
onLeave: action('onLeave'),
onTerminateGroup: action('onTerminateGroup'),
onUnarchive: action('onUnarchive'),
});
export function Basic(): React.JSX.Element {
@@ -56,6 +69,36 @@ export function BlockedAndLeftTheGroup(): React.JSX.Element {
return <ConversationDetailsActions {...props} />;
}
export function CanTerminateGroup(): React.JSX.Element {
const props = createProps({
canTerminateGroup: true,
conversationTitle: '😸 Cat Snaps',
});
return <ConversationDetailsActions {...props} />;
}
export function GroupTerminated(): React.JSX.Element {
const props = createProps({
canTerminateGroup: false,
isGroupTerminated: true,
conversationTitle: '😸 Cat Snaps',
});
return <ConversationDetailsActions {...props} />;
}
export function GroupTerminatedArchived(): React.JSX.Element {
const props = createProps({
canTerminateGroup: false,
isArchived: true,
isGroupTerminated: true,
conversationTitle: '😸 Cat Snaps',
});
return <ConversationDetailsActions {...props} />;
}
export function CannotLeaveBecauseYouAreTheLastAdmin(): React.JSX.Element {
const props = createProps({ cannotLeaveBecauseYouAreLastAdmin: true });
@@ -15,40 +15,60 @@ import {
ConversationDetailsIcon,
IconType,
} from './ConversationDetailsIcon.dom.js';
import { DeleteMessagesConfirmationDialog } from '../../DeleteMessagesConfirmationDialog.dom.js';
export type Props = {
acceptConversation: (id: string) => void;
blockConversation: (id: string) => void;
canTerminateGroup: boolean;
cannotLeaveBecauseYouAreLastAdmin: boolean;
conversationId: string;
conversationTitle: string;
i18n: LocalizerType;
isArchived: boolean;
isBlocked: boolean;
isGroup: boolean;
isGroupTerminated: boolean;
left: boolean;
onArchive: () => void;
onDelete: () => void;
onUnarchive: () => void;
onLeave: () => void;
onTerminateGroup: () => void;
};
export function ConversationDetailsActions({
acceptConversation,
blockConversation,
cannotLeaveBecauseYouAreLastAdmin,
canTerminateGroup,
conversationId,
conversationTitle,
i18n,
isArchived,
isBlocked,
isGroup,
isGroupTerminated,
left,
onArchive,
onDelete,
onUnarchive,
onLeave,
onTerminateGroup,
}: Props): React.JSX.Element {
const [confirmLeave, gLeave] = useState<boolean>(false);
const [confirmGroupBlock, gGroupBlock] = useState<boolean>(false);
const [confirmGroupUnblock, gGroupUnblock] = useState<boolean>(false);
const [confirmDirectBlock, gDirectBlock] = useState<boolean>(false);
const [confirmDirectUnblock, gDirectUnblock] = useState<boolean>(false);
const [promptTerminateGroup, gPromptTerminateGroup] =
useState<boolean>(false);
const [confirmTerminateGroup, gConfirmTerminateGroup] =
useState<boolean>(false);
const [confirmGroupDelete, gGroupDelete] = useState<boolean>(false);
let leaveGroupNode: ReactNode;
if (isGroup && !left) {
if (isGroup && !left && !isGroupTerminated) {
leaveGroupNode = (
<PanelRow
disabled={cannotLeaveBecauseYouAreLastAdmin}
@@ -88,7 +108,7 @@ export function ConversationDetailsActions({
}
let blockNode: ReactNode;
if (isGroup && !isBlocked) {
if (isGroup && !isBlocked && !isGroupTerminated) {
blockNode = (
<PanelRow
disabled={cannotLeaveBecauseYouAreLastAdmin}
@@ -106,7 +126,7 @@ export function ConversationDetailsActions({
}
/>
);
} else if (isGroup && isBlocked) {
} else if (isGroup && isBlocked && !isGroupTerminated) {
blockNode = (
<PanelRow
onClick={() => gGroupUnblock(true)}
@@ -123,7 +143,7 @@ export function ConversationDetailsActions({
}
/>
);
} else {
} else if (!isGroup) {
const label = isBlocked
? i18n('icu:MessageRequests--unblock')
: i18n('icu:MessageRequests--block');
@@ -164,12 +184,91 @@ export function ConversationDetailsActions({
);
}
let terminateGroupNode: ReactNode;
if (canTerminateGroup) {
terminateGroupNode = (
<PanelRow
onClick={() => gPromptTerminateGroup(true)}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:ConversationDetailsActions--terminate-group')}
icon={IconType.terminate}
/>
}
label={
<div className={classNames('ConversationDetails__terminate-group')}>
{i18n('icu:ConversationDetailsActions--terminate-group')}
</div>
}
/>
);
}
let archiveNode: ReactNode;
if (isGroupTerminated) {
if (isArchived) {
archiveNode = (
<PanelRow
onClick={onUnarchive}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:ConversationDetailsActions--unarchive')}
icon={IconType.archive}
/>
}
label={
<div className={classNames('ConversationDetails__unarchive')}>
{i18n('icu:ConversationDetailsActions--unarchive')}
</div>
}
/>
);
} else {
archiveNode = (
<PanelRow
onClick={onArchive}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:ConversationDetailsActions--archive')}
icon={IconType.archive}
/>
}
label={
<div className={classNames('ConversationDetails__archive')}>
{i18n('icu:ConversationDetailsActions--archive')}
</div>
}
/>
);
}
}
const deleteNode = isGroupTerminated ? (
<PanelRow
onClick={() => gGroupDelete(true)}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:ConversationDetailsActions--delete')}
icon={IconType.delete}
/>
}
label={
<div className={classNames('ConversationDetails__delete')}>
{i18n('icu:ConversationDetailsActions--delete')}
</div>
}
/>
) : null;
return (
<>
<PanelSection>
{leaveGroupNode}
{blockNode}
{archiveNode}
{deleteNode}
</PanelSection>
{terminateGroupNode && <PanelSection>{terminateGroupNode}</PanelSection>}
{confirmLeave && (
<ConfirmationDialog
dialogName="ConversationDetailsAction.confirmLeave"
@@ -179,7 +278,7 @@ export function ConversationDetailsActions({
'icu:ConversationDetailsActions--leave-group-modal-confirm'
),
action: onLeave,
style: 'affirmative',
style: 'negative',
},
]}
i18n={i18n}
@@ -201,7 +300,7 @@ export function ConversationDetailsActions({
'icu:ConversationDetailsActions--block-group-modal-confirm'
),
action: () => 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')}
</ConfirmationDialog>
)}
{promptTerminateGroup && (
<ConfirmationDialog
dialogName="ConversationDetailsAction.promptTerminateGroup"
actions={[
{
text: i18n(
'icu:ConversationDetailsActions--terminate-group-modal-confirm'
),
action: () => 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'
)}
</ConfirmationDialog>
)}
{confirmTerminateGroup && (
<ConfirmationDialog
dialogName="ConversationDetailsAction.confirmTerminateGroup"
actions={[
{
text: i18n(
'icu:ConversationDetailsActions--terminate-group-modal-confirm'
),
action: onTerminateGroup,
style: 'negative',
},
]}
i18n={i18n}
onClose={() => gConfirmTerminateGroup(false)}
>
{i18n(
'icu:ConversationDetailsActions--confirm-terminate-group-confirm-modal-content'
)}
</ConfirmationDialog>
)}
{confirmGroupDelete && (
<DeleteMessagesConfirmationDialog
i18n={i18n}
onDestroyMessages={() => {
gGroupDelete(false);
onDelete();
}}
onClose={() => {
gGroupDelete(false);
}}
/>
)}
</>
);
}
@@ -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 && (
<div
className={tw(
'mb-3 px-2 py-[5px]',
'curved-xl bg-fill-secondary text-label-primary',
'text-start type-body-small'
)}
>
<AxoSymbol.InlineGlyph symbol="group-x" label={null} />
<span className={tw('ms-1')}>
{i18n('icu:ConversationDetails__GroupTerminatedBanner')}
</span>
</div>
)}
<div className="ConversationDetailsHeader__subtitle">{subtitle}</div>
</div>
);
@@ -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',
@@ -54,6 +54,7 @@ export default {
conversationId: '123',
getPreferredBadge: () => undefined,
i18n,
isTerminated: false,
memberships: defaultMemberships,
memberColors: getMemberColors(defaultMemberships),
showContactModal: action('showContactModal'),
@@ -35,6 +35,7 @@ export type Props = {
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
isEditMemberLabelEnabled: boolean;
isTerminated: boolean;
maxShownMemberCount?: number;
memberships: ReadonlyArray<GroupV2Membership>;
memberColors: Map<string, ContactNameColorType>;
@@ -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 (
<PanelSection
title={i18n('icu:ConversationDetailsMembershipList--title', {
number: sortedMemberships.length,
})}
>
{canAddNewMembers && (
<PanelSection title={title}>
{canAddNewMembers && !isTerminated && (
<PanelRow
icon={
<div className="ConversationDetails-membership-list__add-members-icon" />
+11
View File
@@ -912,6 +912,17 @@ function renderChangeDetail<T extends string | React.JSX.Element>(
}
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');
}
+56 -3
View File
@@ -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<string, never>;
};
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<GroupV2BannedMemberType>;
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) {
+4
View File
@@ -114,6 +114,10 @@ export async function joinViaLink(value: string): Promise<void> {
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'),
+4
View File
@@ -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,
]);
+9
View File
@@ -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;
+1
View File
@@ -505,6 +505,7 @@ export type ConversationAttributesType = {
memberLabel: AccessRequiredEnum | undefined;
};
announcementsOnly?: boolean;
terminated?: boolean;
avatar?: ContactAvatarType | null;
avatars?: ReadonlyArray<Readonly<AvatarDataType>>;
description?: string;
+17
View File
@@ -253,6 +253,7 @@ import {
buildModifyMemberRoleChange,
buildNewGroupLinkChange,
buildPromoteMemberChange,
buildTerminateChange,
generateGroupInviteLinkPassword,
hasV1GroupBeenMigrated,
joinGroupV2ViaLinkAndMigrate,
@@ -5005,6 +5006,18 @@ export class ConversationModel {
);
}
async terminateGroup(): Promise<void> {
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 || {};
+9
View File
@@ -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);
}
+11
View File
@@ -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) {
+13 -1
View File
@@ -1306,7 +1306,9 @@ export class CallingClass {
await DataWriter.markCallHistoryMissed(staleCallIds);
}
public async peekGroupCall(conversationId: string): Promise<PeekInfo> {
public async peekGroupCall(
conversationId: string
): Promise<PeekInfo | undefined> {
// 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)
+11
View File
@@ -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()) {
+67 -4
View File
@@ -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',
+52
View File
@@ -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,
+2
View File
@@ -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)) &&
+18 -5
View File
@@ -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)
);
}
+1
View File
@@ -281,6 +281,7 @@ export function getConversationStory(
'sortedGroupMembers',
'title',
'left',
'terminated',
]);
const storyView = getStoryView(
+8 -2
View File
@@ -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}
@@ -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}
+1
View File
@@ -56,6 +56,7 @@ export const SmartGV1Members = memo(function SmartGV1Members({
conversationId={conversationId}
i18n={i18n}
isEditMemberLabelEnabled={false}
isTerminated={false}
getPreferredBadge={getPreferredBadge}
maxShownMemberCount={32}
memberColors={memberColors}
@@ -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 <SmartCallLinkAddNameModal />;
@@ -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 ? (
<SmartTerminateGroupFailedModal
conversationId={terminateGroupFailedModal.conversationId}
/>
) : null,
[terminateGroupFailedModal]
);
return (
<GlobalModalContainer
addUserToAnotherGroupModalContactId={
@@ -300,6 +312,7 @@ export const SmartGlobalModalContainer = memo(
hideCriticalIdlePrimaryDeviceModal={hideCriticalIdlePrimaryDeviceModal}
hideLowDiskSpaceBackupImportModal={hideLowDiskSpaceBackupImportModal}
lowDiskSpaceBackupImportModal={lowDiskSpaceBackupImportModal}
terminateGroupFailedModal={terminateGroupFailedModal}
messageRequestActionsConfirmationProps={
messageRequestActionsConfirmationProps
}
@@ -359,6 +372,7 @@ export const SmartGlobalModalContainer = memo(
renderShortcutGuideModal={renderShortcutGuideModal}
renderStickerPreviewModal={renderStickerPreviewModal}
renderStoriesSettings={renderStoriesSettings}
renderTerminateGroupFailedModal={renderTerminateGroupFailedModal}
safetyNumberChangedBlockingData={safetyNumberChangedBlockingData}
safetyNumberModalContactId={safetyNumberModalContactId}
shouldShowPlaintextExportWorkflow={shouldShowPlaintextExportWorkflow}
@@ -0,0 +1,30 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { TerminateGroupFailedModal } from '../../components/TerminateGroupFailedModal.dom.js';
import { getIntl } from '../selectors/user.std.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
export type SmartTerminateGroupFailedModalProps = {
conversationId: string;
};
export const SmartTerminateGroupFailedModal = memo(
function SmartTerminateGroupFailedModal({
conversationId,
}: SmartTerminateGroupFailedModalProps) {
const i18n = useSelector(getIntl);
const { terminateGroup } = useConversationsActions();
const { hideTerminateGroupFailedModal } = useGlobalModalActions();
return (
<TerminateGroupFailedModal
i18n={i18n}
onClose={hideTerminateGroupFailedModal}
onRetry={() => terminateGroup(conversationId)}
/>
);
}
);
+2
View File
@@ -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}
@@ -2007,6 +2007,36 @@ describe('backup/groupv2/notifications', () => {
await symmetricRoundtripHarness(messages);
});
it('Terminated items', async () => {
const messages: Array<MessageAttributesType> = [
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', () => {
@@ -99,6 +99,7 @@ export function getDefaultGroup(
markedUnread: Boolean(overrideProps.markedUnread),
membersCount: memberships.length,
memberships,
terminated: false,
title: casual.title,
serviceId: generateAci(),
acknowledgedGroupNameCollisions: {},
+1
View File
@@ -29,6 +29,7 @@ export function getCanAddLabel(
return Boolean(
membership &&
conversation.type === 'group' &&
!conversation.terminated &&
(membership.isAdmin ||
conversation.accessControlMemberLabel ===
Proto.AccessControl.AccessRequired.MEMBER)
+1
View File
@@ -56,6 +56,7 @@ export type ConversationStoryType = {
| 'sortedGroupMembers'
| 'title'
| 'left'
| 'terminated'
>;
isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types
+4
View File
@@ -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 = {
+3 -2
View File
@@ -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),
+3 -1
View File
@@ -20,11 +20,13 @@ const log = createLogger('longRunningTaskWrapper');
export async function longRunningTaskWrapper<T>({
name,
idForLogging,
spinnerText,
task,
suppressErrorDialog,
}: {
name: string;
idForLogging: string;
spinnerText?: string;
task: () => Promise<T>;
suppressErrorDialog?: boolean;
}): Promise<T> {
@@ -44,7 +46,7 @@ export async function longRunningTaskWrapper<T>({
<StrictMode>
<AxoProvider dir={i18n.getLocaleDirection()}>
<FunDefaultEnglishEmojiLocalizationProvider>
<ProgressModal i18n={i18n} />
<ProgressModal i18n={i18n} description={spinnerText} />
</FunDefaultEnglishEmojiLocalizationProvider>
</AxoProvider>
</StrictMode>
+2 -1
View File
@@ -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');
+8
View File
@@ -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',