mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-07-10 15:18:28 +01:00
Init group terminate
This commit is contained in:
@@ -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. You’ll 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 can’t 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 |
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+43
@@ -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 });
|
||||
|
||||
|
||||
+168
-8
@@ -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',
|
||||
|
||||
+1
@@ -54,6 +54,7 @@ export default {
|
||||
conversationId: '123',
|
||||
getPreferredBadge: () => undefined,
|
||||
i18n,
|
||||
isTerminated: false,
|
||||
memberships: defaultMemberships,
|
||||
memberColors: getMemberColors(defaultMemberships),
|
||||
showContactModal: action('showContactModal'),
|
||||
|
||||
+11
-6
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Vendored
+1
@@ -505,6 +505,7 @@ export type ConversationAttributesType = {
|
||||
memberLabel: AccessRequiredEnum | undefined;
|
||||
};
|
||||
announcementsOnly?: boolean;
|
||||
terminated?: boolean;
|
||||
avatar?: ContactAvatarType | null;
|
||||
avatars?: ReadonlyArray<Readonly<AvatarDataType>>;
|
||||
description?: string;
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) &&
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@ export function getConversationStory(
|
||||
'sortedGroupMembers',
|
||||
'title',
|
||||
'left',
|
||||
'terminated',
|
||||
]);
|
||||
|
||||
const storyView = getStoryView(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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: {},
|
||||
|
||||
@@ -29,6 +29,7 @@ export function getCanAddLabel(
|
||||
return Boolean(
|
||||
membership &&
|
||||
conversation.type === 'group' &&
|
||||
!conversation.terminated &&
|
||||
(membership.isAdmin ||
|
||||
conversation.accessControlMemberLabel ===
|
||||
Proto.AccessControl.AccessRequired.MEMBER)
|
||||
|
||||
@@ -56,6 +56,7 @@ export type ConversationStoryType = {
|
||||
| 'sortedGroupMembers'
|
||||
| 'title'
|
||||
| 'left'
|
||||
| 'terminated'
|
||||
>;
|
||||
isHidden?: boolean;
|
||||
searchNames?: string; // This is just here to satisfy Fuse's types
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user