Support for people banned from joining groups via link
This commit is contained in:
parent
1b7496399b
commit
f217730b84
17 changed files with 455 additions and 108 deletions
|
@ -22,17 +22,23 @@ const story = storiesOf('Components/Conversation/ContactModal', module);
|
|||
|
||||
const defaultContact: ConversationType = getDefaultConversation({
|
||||
id: 'abcdef',
|
||||
areWeAdmin: false,
|
||||
title: 'Pauline Oliveros',
|
||||
phoneNumber: '(333) 444-5515',
|
||||
about: '👍 Free to chat',
|
||||
});
|
||||
const defaultGroup: ConversationType = getDefaultConversation({
|
||||
id: 'abcdef',
|
||||
areWeAdmin: true,
|
||||
title: "It's a group",
|
||||
groupLink: 'something',
|
||||
});
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
areWeASubscriber: false,
|
||||
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),
|
||||
badges: overrideProps.badges || [],
|
||||
contact: overrideProps.contact || defaultContact,
|
||||
conversation: overrideProps.conversation || defaultGroup,
|
||||
hideContactModal: action('hideContactModal'),
|
||||
i18n,
|
||||
isAdmin: boolean('isAdmin', overrideProps.isAdmin || false),
|
||||
|
@ -62,6 +68,17 @@ story.add('As admin', () => {
|
|||
return <ContactModal {...props} />;
|
||||
});
|
||||
|
||||
story.add('As admin with no group link', () => {
|
||||
const props = createProps({
|
||||
areWeAdmin: true,
|
||||
conversation: {
|
||||
...defaultGroup,
|
||||
groupLink: undefined,
|
||||
},
|
||||
});
|
||||
return <ContactModal {...props} />;
|
||||
});
|
||||
|
||||
story.add('As admin, viewing non-member of group', () => {
|
||||
const props = createProps({
|
||||
isMember: false,
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import * as log from '../../logging/log';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { About } from './About';
|
||||
import { Avatar } from '../Avatar';
|
||||
|
@ -14,13 +16,14 @@ import { BadgeDialog } from '../BadgeDialog';
|
|||
import type { BadgeType } from '../../badges/types';
|
||||
import { SharedGroupNames } from '../SharedGroupNames';
|
||||
import { ConfirmationDialog } from '../ConfirmationDialog';
|
||||
import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog';
|
||||
|
||||
export type PropsDataType = {
|
||||
areWeASubscriber: boolean;
|
||||
areWeAdmin: boolean;
|
||||
badges: ReadonlyArray<BadgeType>;
|
||||
contact?: ConversationType;
|
||||
conversationId?: string;
|
||||
conversation?: ConversationType;
|
||||
readonly i18n: LocalizerType;
|
||||
isAdmin: boolean;
|
||||
isMember: boolean;
|
||||
|
@ -50,12 +53,18 @@ enum ContactModalView {
|
|||
ShowingBadges,
|
||||
}
|
||||
|
||||
enum SubModalState {
|
||||
None = 'None',
|
||||
ToggleAdmin = 'ToggleAdmin',
|
||||
MemberRemove = 'MemberRemove',
|
||||
}
|
||||
|
||||
export const ContactModal = ({
|
||||
areWeASubscriber,
|
||||
areWeAdmin,
|
||||
badges,
|
||||
contact,
|
||||
conversationId,
|
||||
conversation,
|
||||
hideContactModal,
|
||||
i18n,
|
||||
isAdmin,
|
||||
|
@ -72,14 +81,78 @@ export const ContactModal = ({
|
|||
}
|
||||
|
||||
const [view, setView] = useState(ContactModalView.Default);
|
||||
const [confirmToggleAdmin, setConfirmToggleAdmin] = useState(false);
|
||||
const [subModalState, setSubModalState] = useState<SubModalState>(
|
||||
SubModalState.None
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationId) {
|
||||
if (conversation?.id) {
|
||||
// Kick off the expensive hydration of the current sharedGroupNames
|
||||
updateConversationModelSharedGroups(conversationId);
|
||||
updateConversationModelSharedGroups(conversation.id);
|
||||
}
|
||||
}, [conversationId, updateConversationModelSharedGroups]);
|
||||
}, [conversation?.id, updateConversationModelSharedGroups]);
|
||||
|
||||
let modalNode: ReactNode;
|
||||
switch (subModalState) {
|
||||
case SubModalState.None:
|
||||
modalNode = undefined;
|
||||
break;
|
||||
case SubModalState.ToggleAdmin:
|
||||
if (!conversation?.id) {
|
||||
log.warn('ContactModal: ToggleAdmin state - missing conversationId');
|
||||
modalNode = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
modalNode = (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: () => toggleAdmin(conversation.id, contact.id),
|
||||
text: isAdmin
|
||||
? i18n('ContactModal--rm-admin')
|
||||
: i18n('ContactModal--make-admin'),
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={() => setSubModalState(SubModalState.None)}
|
||||
>
|
||||
{isAdmin
|
||||
? i18n('ContactModal--rm-admin-info', [contact.title])
|
||||
: i18n('ContactModal--make-admin-info', [contact.title])}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
break;
|
||||
case SubModalState.MemberRemove:
|
||||
if (!contact || !conversation?.id) {
|
||||
log.warn(
|
||||
'ContactModal: MemberRemove state - missing contact or conversationId'
|
||||
);
|
||||
modalNode = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
modalNode = (
|
||||
<RemoveGroupMemberConfirmationDialog
|
||||
conversation={contact}
|
||||
group={conversation}
|
||||
i18n={i18n}
|
||||
onClose={() => {
|
||||
setSubModalState(SubModalState.None);
|
||||
}}
|
||||
onRemove={() => {
|
||||
removeMemberFromGroup(conversation?.id, contact.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
const state: never = subModalState;
|
||||
log.warn(`ContactModal: unexpected ${state}!`);
|
||||
modalNode = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (view) {
|
||||
case ContactModalView.Default: {
|
||||
|
@ -155,12 +228,12 @@ export const ContactModal = ({
|
|||
<span>{i18n('showSafetyNumber')}</span>
|
||||
</button>
|
||||
)}
|
||||
{!contact.isMe && areWeAdmin && isMember && conversationId && (
|
||||
{!contact.isMe && areWeAdmin && isMember && conversation?.id && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__make-admin"
|
||||
onClick={() => setConfirmToggleAdmin(true)}
|
||||
onClick={() => setSubModalState(SubModalState.ToggleAdmin)}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__make-admin__bubble-icon" />
|
||||
|
@ -174,9 +247,7 @@ export const ContactModal = ({
|
|||
<button
|
||||
type="button"
|
||||
className="ContactModal__button ContactModal__remove-from-group"
|
||||
onClick={() =>
|
||||
removeMemberFromGroup(conversationId, contact.id)
|
||||
}
|
||||
onClick={() => setSubModalState(SubModalState.MemberRemove)}
|
||||
>
|
||||
<div className="ContactModal__bubble-icon">
|
||||
<div className="ContactModal__remove-from-group__bubble-icon" />
|
||||
|
@ -186,24 +257,7 @@ export const ContactModal = ({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{confirmToggleAdmin && conversationId && (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: () => toggleAdmin(conversationId, contact.id),
|
||||
text: isAdmin
|
||||
? i18n('ContactModal--rm-admin')
|
||||
: i18n('ContactModal--make-admin'),
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={() => setConfirmToggleAdmin(false)}
|
||||
>
|
||||
{isAdmin
|
||||
? i18n('ContactModal--rm-admin-info', [contact.title])
|
||||
: i18n('ContactModal--make-admin-info', [contact.title])}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
{modalNode}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -23,6 +23,7 @@ const story = storiesOf(
|
|||
const getCommonProps = () => ({
|
||||
getPreferredBadge: () => undefined,
|
||||
i18n,
|
||||
group: getDefaultConversation(),
|
||||
onBlock: action('onBlock'),
|
||||
onBlockAndReportSpam: action('onBlockAndReportSpam'),
|
||||
onClose: action('onClose'),
|
||||
|
@ -51,7 +52,10 @@ story.add('Direct conversations with same title', () => (
|
|||
<ContactSpoofingReviewDialog
|
||||
{...getCommonProps()}
|
||||
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
|
||||
areWeAdmin={areWeAdmin}
|
||||
group={{
|
||||
...getDefaultConversation(),
|
||||
areWeAdmin,
|
||||
}}
|
||||
collisionInfoByTitle={{
|
||||
Alice: times(2, () => ({
|
||||
oldName: 'Alicia',
|
||||
|
|
|
@ -43,7 +43,7 @@ type PropsType = {
|
|||
}
|
||||
| {
|
||||
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
|
||||
areWeAdmin: boolean;
|
||||
group: ConversationType;
|
||||
collisionInfoByTitle: Record<
|
||||
string,
|
||||
Array<{
|
||||
|
@ -78,13 +78,20 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
|
|||
const [confirmationState, setConfirmationState] = useState<
|
||||
| undefined
|
||||
| {
|
||||
type: ConfirmationStateType;
|
||||
type: ConfirmationStateType.ConfirmingGroupRemoval;
|
||||
affectedConversation: ConversationType;
|
||||
group: ConversationType;
|
||||
}
|
||||
| {
|
||||
type:
|
||||
| ConfirmationStateType.ConfirmingDelete
|
||||
| ConfirmationStateType.ConfirmingBlock;
|
||||
affectedConversation: ConversationType;
|
||||
}
|
||||
>();
|
||||
|
||||
if (confirmationState) {
|
||||
const { affectedConversation, type } = confirmationState;
|
||||
const { type, affectedConversation } = confirmationState;
|
||||
switch (type) {
|
||||
case ConfirmationStateType.ConfirmingDelete:
|
||||
case ConfirmationStateType.ConfirmingBlock:
|
||||
|
@ -140,10 +147,12 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
|
|||
}}
|
||||
/>
|
||||
);
|
||||
case ConfirmationStateType.ConfirmingGroupRemoval:
|
||||
case ConfirmationStateType.ConfirmingGroupRemoval: {
|
||||
const { group } = confirmationState;
|
||||
return (
|
||||
<RemoveGroupMemberConfirmationDialog
|
||||
conversation={affectedConversation}
|
||||
group={group}
|
||||
i18n={i18n}
|
||||
onClose={() => {
|
||||
setConfirmationState(undefined);
|
||||
|
@ -153,6 +162,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
|
|||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
@ -227,7 +237,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
|
|||
break;
|
||||
}
|
||||
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
|
||||
const { areWeAdmin, collisionInfoByTitle } = props;
|
||||
const { group, collisionInfoByTitle } = props;
|
||||
|
||||
const unsortedConversationInfos = concat(
|
||||
// This empty array exists to appease Lodash's type definitions.
|
||||
|
@ -254,7 +264,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
|
|||
</h2>
|
||||
{conversationInfos.map((conversationInfo, index) => {
|
||||
let button: ReactNode;
|
||||
if (areWeAdmin) {
|
||||
if (group.areWeAdmin) {
|
||||
button = (
|
||||
<Button
|
||||
variant={ButtonVariant.SecondaryAffirmative}
|
||||
|
@ -262,6 +272,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
|
|||
setConfirmationState({
|
||||
type: ConfirmationStateType.ConfirmingGroupRemoval,
|
||||
affectedConversation: conversationInfo.conversation,
|
||||
group,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -6,12 +6,14 @@ import React from 'react';
|
|||
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { isAccessControlEnabled } from '../../groups/util';
|
||||
|
||||
import { ConfirmationDialog } from '../ConfirmationDialog';
|
||||
import { Intl } from '../Intl';
|
||||
import { ContactName } from './ContactName';
|
||||
|
||||
type PropsType = {
|
||||
group: ConversationType;
|
||||
conversation: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
onClose: () => void;
|
||||
|
@ -19,25 +21,33 @@ type PropsType = {
|
|||
};
|
||||
|
||||
export const RemoveGroupMemberConfirmationDialog: FunctionComponent<PropsType> =
|
||||
({ conversation, i18n, onClose, onRemove }) => (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: onRemove,
|
||||
text: i18n('RemoveGroupMemberConfirmation__remove-button'),
|
||||
style: 'negative',
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="RemoveGroupMemberConfirmation__description"
|
||||
components={{
|
||||
name: <ContactName title={conversation.title} />,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
({ conversation, group, i18n, onClose, onRemove }) => {
|
||||
const descriptionKey = isAccessControlEnabled(
|
||||
group.accessControlAddFromInviteLink
|
||||
)
|
||||
? 'RemoveGroupMemberConfirmation__description__with-link'
|
||||
: 'RemoveGroupMemberConfirmation__description';
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: onRemove,
|
||||
text: i18n('RemoveGroupMemberConfirmation__remove-button'),
|
||||
style: 'negative',
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id={descriptionKey}
|
||||
components={{
|
||||
name: <ContactName title={conversation.title} />,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -492,6 +492,7 @@ const renderTypingBubble = () => (
|
|||
);
|
||||
|
||||
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
conversation: overrideProps.conversation || getDefaultConversation(),
|
||||
discardMessages: action('discardMessages'),
|
||||
getPreferredBadge: () => undefined,
|
||||
i18n,
|
||||
|
|
|
@ -94,7 +94,7 @@ export type PropsDataType = {
|
|||
|
||||
type PropsHousekeepingType = {
|
||||
id: string;
|
||||
areWeAdmin?: boolean;
|
||||
conversation: ConversationType;
|
||||
isConversationSelected: boolean;
|
||||
isGroupV1AndDisabled?: boolean;
|
||||
isIncomingMessageRequest: boolean;
|
||||
|
@ -734,10 +734,10 @@ export class Timeline extends React.Component<
|
|||
public override render(): JSX.Element | null {
|
||||
const {
|
||||
acknowledgeGroupMemberNameCollisions,
|
||||
areWeAdmin,
|
||||
clearInvitedUuidsForNewlyCreatedGroup,
|
||||
closeContactSpoofingReview,
|
||||
contactSpoofingReview,
|
||||
conversation,
|
||||
getPreferredBadge,
|
||||
getTimestampForMessage,
|
||||
haveNewest,
|
||||
|
@ -1018,7 +1018,7 @@ export class Timeline extends React.Component<
|
|||
<ContactSpoofingReviewDialog
|
||||
{...commonProps}
|
||||
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
|
||||
areWeAdmin={Boolean(areWeAdmin)}
|
||||
group={conversation}
|
||||
collisionInfoByTitle={contactSpoofingReview.collisionInfoByTitle}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ import { ConfirmationDialog } from '../../ConfirmationDialog';
|
|||
import { PanelSection } from './PanelSection';
|
||||
import { PanelRow } from './PanelRow';
|
||||
import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon';
|
||||
import { isAccessControlEnabled } from '../../../groups/util';
|
||||
|
||||
export type PropsType = {
|
||||
readonly conversation?: ConversationType;
|
||||
|
@ -147,6 +148,7 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
|
|||
{stagedMemberships && stagedMemberships.length && (
|
||||
<MembershipActionConfirmation
|
||||
approvePendingMembership={approvePendingMembership}
|
||||
conversation={conversation}
|
||||
i18n={i18n}
|
||||
members={conversation.sortedGroupMembers || []}
|
||||
onClose={() => setStagedMemberships(null)}
|
||||
|
@ -161,6 +163,7 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
|
|||
|
||||
function MembershipActionConfirmation({
|
||||
approvePendingMembership,
|
||||
conversation,
|
||||
i18n,
|
||||
members,
|
||||
onClose,
|
||||
|
@ -169,6 +172,7 @@ function MembershipActionConfirmation({
|
|||
stagedMemberships,
|
||||
}: {
|
||||
approvePendingMembership: (conversationId: string) => void;
|
||||
conversation: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
members: Array<ConversationType>;
|
||||
onClose: () => void;
|
||||
|
@ -222,6 +226,7 @@ function MembershipActionConfirmation({
|
|||
onClose={onClose}
|
||||
>
|
||||
{getConfirmationMessage({
|
||||
conversation,
|
||||
i18n,
|
||||
members,
|
||||
ourUuid,
|
||||
|
@ -232,11 +237,13 @@ function MembershipActionConfirmation({
|
|||
}
|
||||
|
||||
function getConfirmationMessage({
|
||||
conversation,
|
||||
i18n,
|
||||
members,
|
||||
ourUuid,
|
||||
stagedMemberships,
|
||||
}: Readonly<{
|
||||
conversation: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
members: ReadonlyArray<ConversationType>;
|
||||
ourUuid: string;
|
||||
|
@ -251,7 +258,12 @@ function getConfirmationMessage({
|
|||
|
||||
// Requesting a membership since they weren't added by anyone
|
||||
if (membershipType === StageType.DENY_REQUEST) {
|
||||
return i18n('PendingRequests--deny-for', {
|
||||
const key = isAccessControlEnabled(
|
||||
conversation.accessControlAddFromInviteLink
|
||||
)
|
||||
? 'PendingRequests--deny-for--with-link'
|
||||
: 'PendingRequests--deny-for';
|
||||
return i18n(key, {
|
||||
name: firstMembership.member.title,
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue