add zombies logic for member leaving and removing

pull/1592/head
Audric Ackermann 4 years ago
parent 7ec663df71
commit eb0ddd85f4
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -2015,6 +2015,10 @@
"noModeratorsToRemove": {
"message": "no moderators to remove"
},
"onlyAdminCanRemoveMembers": { "message": "You are not the creator" },
"onlyAdminCanRemoveMembersDesc": {
"message": "Only the creator of the group can remove users"
},
"createAccount": {
"message": "Create Account"
},

@ -17,8 +17,13 @@
d => !!d && !d.isBlocked() && d.isPrivate() && !d.isMe() && !!d.get('active_at')
);
if (!convo.isPublic()) {
// filter our zombies and current members from the list of contact we can add
const members = convo.get('members') || [];
this.contacts = this.contacts.filter(d => !members.includes(d.id));
const zombies = convo.get('zombies') || [];
this.contacts = this.contacts.filter(
d => !members.includes(d.id) && !zombies.includes(d.id)
);
}
this.chatName = convo.get('name');

@ -87,37 +87,29 @@
this.theme = groupConvo.theme;
if (this.isPublic) {
this.titleText = i18n('updateGroupDialogTitle', this.groupName);
// I'd much prefer to integrate mods with groupAdmins
// but lets discuss first...
this.isAdmin = groupConvo.isAdmin(window.storage.get('primaryDevicePubKey'));
// zero out contactList for now
this.contactsAndMembers = [];
this.existingMembers = [];
} else {
this.titleText = i18n('updateGroupDialogTitle', this.groupName);
// anybody can edit a closed group name or members
const ourPK = window.libsession.Utils.UserUtils.getOurPubKeyStrFromCache();
this.isAdmin = groupConvo.isMediumGroup()
? true
: groupConvo.get('groupAdmins').includes(ourPK);
this.admins = groupConvo.get('groupAdmins');
const convos = window
.getConversationController()
.getConversations()
.filter(d => !!d);
this.existingMembers = groupConvo.get('members') || [];
// Show a contact if they are our friend or if they are a member
this.contactsAndMembers = convos.filter(
d => this.existingMembers.includes(d.id) && d.isPrivate() && !d.isMe()
);
this.contactsAndMembers = _.uniq(this.contactsAndMembers, true, d => d.id);
throw new Error('UpdateGroupMembersDialog is only made for Closed/Medium groups');
}
this.titleText = i18n('updateGroupDialogTitle', this.groupName);
// anybody can edit a closed group name or members
const ourPK = window.libsession.Utils.UserUtils.getOurPubKeyStrFromCache();
this.isAdmin = groupConvo.get('groupAdmins').includes(ourPK);
this.admins = groupConvo.get('groupAdmins');
const convos = window
.getConversationController()
.getConversations()
.filter(d => !!d);
this.existingMembers = groupConvo.get('members') || [];
this.existingZombies = groupConvo.get('zombies') || [];
// Show a contact if they are our friend or if they are a member
this.contactsAndMembers = convos.filter(
d => this.existingMembers.includes(d.id) && d.isPrivate() && !d.isMe()
);
this.contactsAndMembers = _.uniq(this.contactsAndMembers, true, d => d.id);
// at least make sure it's an array
if (!Array.isArray(this.existingMembers)) {
this.existingMembers = [];
}
// at least make sure it's an array
if (!Array.isArray(this.existingMembers)) {
this.existingMembers = [];
}
this.$el.focus();
@ -133,6 +125,7 @@
cancelText: i18n('cancel'),
isPublic: this.isPublic,
existingMembers: this.existingMembers,
existingZombies: this.existingZombies,
contactList: this.contactsAndMembers,
isAdmin: this.isAdmin,
admins: this.admins,
@ -149,15 +142,32 @@
async onSubmit(newMembers) {
const _ = window.Lodash;
const ourPK = window.libsession.Utils.UserUtils.getOurPubKeyStrFromCache();
const allMembers = window.Lodash.concat(newMembers, [ourPK]);
const allMembersAfterUpdate = window.Lodash.concat(newMembers, [ourPK]);
if (!this.isAdmin) {
window.log.warn('Skipping update of members, we are not the admin');
return;
}
// new members won't include the zombies. We are the admin and we want to remove them not matter what
// We need to NOT trigger an group update if the list of member is the same.
const notPresentInOld = allMembers.filter(m => !this.existingMembers.includes(m));
// we need to merge all members, including zombies for this call.
const membersToRemove = this.existingMembers.filter(m => !allMembers.includes(m));
// we consider that the admin ALWAYS wants to remove zombies (actually they should be removed
// automatically by him when the LEFT message is received)
const allExistingMembersWithZombies = _.uniq(
this.existingMembers.concat(this.existingZombies)
);
// If any extra devices of removed exist in newMembers, ensure that you filter them
const filteredMemberes = allMembers.filter(member => !_.includes(membersToRemove, member));
const notPresentInOld = allMembersAfterUpdate.filter(
m => !allExistingMembersWithZombies.includes(m)
);
// be sure to include zombies in here
const membersToRemove = allExistingMembersWithZombies.filter(
m => !allMembersAfterUpdate.includes(m)
);
const xor = _.xor(membersToRemove, notPresentInOld);
if (xor.length === 0) {
@ -166,10 +176,16 @@
return;
}
// If any extra devices of removed exist in newMembers, ensure that you filter them
// Note: I think this is useless
const filteredMembers = allMembersAfterUpdate.filter(
member => !_.includes(membersToRemove, member)
);
window.libsession.ClosedGroup.initiateGroupUpdate(
this.groupId,
this.groupName,
filteredMemberes,
filteredMembers,
this.avatarPath
);
},

@ -1300,6 +1300,10 @@ input {
}
}
&.zombie {
opacity: 0.5;
}
&__checkmark {
opacity: 0;
transition: $session-transition-duration;

@ -10,6 +10,7 @@ import { initiateGroupUpdate } from '../../session/group';
import { ConversationModel, ConversationTypeEnum } from '../../models/conversation';
import { getCompleteUrlForV2ConvoId } from '../../interactions/conversation';
import _ from 'lodash';
import autoBind from 'auto-bind';
interface Props {
contactList: Array<any>;
chatName: string;
@ -26,12 +27,7 @@ class InviteContactsDialogInner extends React.Component<Props, State> {
constructor(props: any) {
super(props);
this.onMemberClicked = this.onMemberClicked.bind(this);
this.closeDialog = this.closeDialog.bind(this);
this.onClickOK = this.onClickOK.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.submitForOpenGroup = this.submitForOpenGroup.bind(this);
this.submitForClosedGroup = this.submitForClosedGroup.bind(this);
autoBind(this);
let contacts = this.props.contactList;
@ -137,11 +133,10 @@ class InviteContactsDialogInner extends React.Component<Props, State> {
private async submitForClosedGroup(pubkeys: Array<string>) {
const { convo } = this.props;
// FIXME audric is this dialog still used for closed groups? I think
// public group chats
// private group chats
// closed group chats
const ourPK = UserUtils.getOurPubKeyStrFromCache();
// we only care about real members. If a member is currently a zombie we have to be able to add him back
let existingMembers = convo.get('members') || [];
// at least make sure it's an array
if (!Array.isArray(existingMembers)) {

@ -6,6 +6,11 @@ import { SessionButton, SessionButtonColor } from '../session/SessionButton';
import { ContactType, SessionMemberListItem } from '../session/SessionMemberListItem';
import { DefaultTheme } from 'styled-components';
import { ToastUtils } from '../../session/utils';
import { LocalizerType } from '../../types/Util';
import autoBind from 'auto-bind';
import { ConversationController } from '../../session/conversations';
import _ from 'lodash';
interface Props {
titleText: string;
@ -15,10 +20,11 @@ interface Props {
// contacts not in the group
contactList: Array<any>;
isAdmin: boolean;
existingMembers: Array<String>;
admins: Array<String>; // used for closed group
existingMembers: Array<string>;
existingZombies: Array<string>;
admins: Array<string>; // used for closed group
i18n: any;
i18n: LocalizerType;
onSubmit: any;
onClose: any;
theme: DefaultTheme;
@ -26,23 +32,21 @@ interface Props {
interface State {
contactList: Array<ContactType>;
zombies: Array<ContactType>;
errorDisplayed: boolean;
errorMessage: string;
}
export class UpdateGroupMembersDialog extends React.Component<Props, State> {
constructor(props: any) {
constructor(props: Props) {
super(props);
this.onMemberClicked = this.onMemberClicked.bind(this);
this.onClickOK = this.onClickOK.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.closeDialog = this.closeDialog.bind(this);
autoBind(this);
let contacts = this.props.contactList;
contacts = contacts.map(d => {
const lokiProfile = d.getLokiProfile();
const name = lokiProfile ? lokiProfile.displayName : 'Anonymous';
const name = lokiProfile ? lokiProfile.displayName : window.i18n('anonymous');
const existingMember = this.props.existingMembers.includes(d.id);
@ -58,8 +62,33 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
};
});
const zombies = _.compact(
this.props.existingZombies.map(d => {
const convo = ConversationController.getInstance().get(d);
if (!convo) {
window.log.warn('Zombie convo not found');
return null;
}
const lokiProfile = convo.getLokiProfile();
const name = lokiProfile ? `${lokiProfile.displayName} (Zombie)` : window.i18n('anonymous');
const existingZombie = this.props.existingZombies.includes(convo.id);
return {
id: convo.id,
authorPhoneNumber: convo.id,
authorProfileName: name,
authorAvatarPath: convo?.getAvatarPath() as string,
selected: false,
authorName: name,
checkmarked: false,
existingMember: existingZombie,
};
})
);
this.state = {
contactList: contacts,
zombies,
errorDisplayed: false,
errorMessage: '',
};
@ -70,13 +99,14 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
public onClickOK() {
const members = this.getWouldBeMembers(this.state.contactList).map(d => d.id);
// do not include zombies here, they are removed by force
this.props.onSubmit(members);
this.closeDialog();
}
public render() {
const { okText, cancelText, contactList, titleText } = this.props;
const { okText, cancelText, isAdmin, contactList, titleText } = this.props;
const showNoMembersMessage = contactList.length === 0;
@ -99,17 +129,20 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
<div className="spacer-md" />
<div className="group-member-list__selection">{this.renderMemberList()}</div>
{this.renderZombiesList()}
{showNoMembersMessage && <p>{window.i18n('noMembersInThisGroup')}</p>}
<div className="spacer-lg" />
<div className="session-modal__button-group">
<SessionButton text={cancelText} onClick={this.closeDialog} />
<SessionButton
text={okText}
onClick={this.onClickOK}
buttonColor={SessionButtonColor.Green}
/>
{isAdmin && (
<SessionButton
text={okText}
onClick={this.onClickOK}
buttonColor={SessionButtonColor.Green}
/>
)}
</div>
</SessionModal>
);
@ -131,6 +164,21 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
));
}
private renderZombiesList() {
return this.state.zombies.map((member: ContactType, index: number) => (
<SessionMemberListItem
member={member}
index={index}
isSelected={!member.checkmarked}
onSelect={this.onZombieClicked}
onUnselect={this.onZombieClicked}
isZombie={true}
key={member.id}
theme={this.props.theme}
/>
));
}
private onKeyUp(event: any) {
switch (event.key) {
case 'Enter':
@ -158,10 +206,15 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
this.props.onClose();
}
private onMemberClicked(selected: any) {
private onMemberClicked(selected: ContactType) {
const { isAdmin, admins } = this.props;
const { contactList } = this.state;
if (!isAdmin) {
ToastUtils.pushOnlyAdminCanRemove();
return;
}
if (selected.existingMember && !isAdmin) {
window.log.warn('Only group admin can remove members!');
return;
@ -190,4 +243,41 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
};
});
}
private onZombieClicked(selected: ContactType) {
const { isAdmin, admins } = this.props;
const { zombies } = this.state;
if (!isAdmin) {
ToastUtils.pushOnlyAdminCanRemove();
return;
}
if (selected.existingMember && !isAdmin) {
window.log.warn('Only group admin can remove members!');
return;
}
if (selected.existingMember && admins.includes(selected.id)) {
window.log.warn(
`User ${selected.id} cannot be removed as they are the creator of the closed group.`
);
ToastUtils.pushCannotRemoveCreatorFromGroup();
return;
}
const updatedZombies = zombies.map(zombie => {
if (zombie.id === selected.id) {
return { ...zombie, checkmarked: !zombie.checkmarked };
} else {
return zombie;
}
});
this.setState(state => {
return {
...state,
zombies: updatedZombies,
};
});
}
}

@ -6,6 +6,7 @@ import { SessionIcon, SessionIconSize, SessionIconType } from './icon';
import { Constants } from '../../session';
import { DefaultTheme } from 'styled-components';
import { PubKey } from '../../session/types';
import autoBind from 'auto-bind';
export interface ContactType {
id: string;
@ -22,6 +23,8 @@ interface Props {
member: ContactType;
index: number; // index in the list
isSelected: boolean;
// this bool is used to make a zombie appear with less opacity than a normal member
isZombie?: boolean;
onSelect?: any;
onUnselect?: any;
theme: DefaultTheme;
@ -35,14 +38,11 @@ class SessionMemberListItemInner extends React.Component<Props> {
constructor(props: any) {
super(props);
this.handleSelectionAction = this.handleSelectionAction.bind(this);
this.selectMember = this.selectMember.bind(this);
this.unselectMember = this.unselectMember.bind(this);
this.renderAvatar = this.renderAvatar.bind(this);
autoBind(this);
}
public render() {
const { isSelected, member } = this.props;
const { isSelected, member, isZombie } = this.props;
const name = member.authorProfileName || PubKey.shorten(member.authorPhoneNumber);
@ -51,7 +51,8 @@ class SessionMemberListItemInner extends React.Component<Props> {
className={classNames(
`session-member-item-${this.props.index}`,
'session-member-item',
isSelected && 'selected'
isSelected && 'selected',
isZombie && 'zombie'
)}
onClick={this.handleSelectionAction}
role="button"

@ -252,7 +252,7 @@ class SessionRightPanel extends React.Component<Props, State> {
const showUpdateGroupNameButton = isAdmin && !commonNoShow;
const showAddRemoveModeratorsButton = isAdmin && !commonNoShow && isPublic;
const showUpdateGroupMembersButton = !isPublic && !commonNoShow && isAdmin;
const showUpdateGroupMembersButton = !isPublic && !commonNoShow;
return (
<div className="group-settings">

@ -52,7 +52,9 @@ export interface ConversationAttributes {
profileName?: string;
id: string;
name?: string;
// members are all members for this group. zombies excluded
members: Array<string>;
zombies: Array<string>; // only used for closed groups. Zombies are users which left but not yet removed by the admin
left: boolean;
expireTimer: number;
mentionedUs: boolean;
@ -90,6 +92,7 @@ export interface ConversationAttributesOptionals {
id: string;
name?: string;
members?: Array<string>;
zombies?: Array<string>;
left?: boolean;
expireTimer?: number;
mentionedUs?: boolean;
@ -130,6 +133,7 @@ export const fillConvoAttributesWithDefaults = (
): ConversationAttributes => {
return _.defaults(optAttributes, {
members: [],
zombies: [],
left: false,
unreadCount: 0,
lastMessageStatus: null,

@ -451,6 +451,10 @@ export const downloadFileOpenGroupV2 = async (
fileId: number,
roomInfos: OpenGroupRequestCommonType
): Promise<Uint8Array | null> => {
if (!fileId) {
window.log.warn('downloadFileOpenGroupV2: FileId cannot be unset. returning null');
return null;
}
const request: OpenGroupV2Request = {
method: 'GET',
room: roomInfos.roomId,

@ -584,6 +584,9 @@ async function handleClosedGroupMembersAdded(
await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming', _.toNumber(envelope.timestamp));
convo.set({ members });
// make sure those members are not on our zombie list
addedMembers.forEach(added => removeMemberFromZombies(envelope, PubKey.cast(added), convo));
convo.updateLastMessage();
await convo.commit();
await removeFromCache(envelope);
@ -633,16 +636,9 @@ async function handleClosedGroupMembersRemoved(
const ourPubKey = UserUtils.getOurPubKeyFromCache();
const wasCurrentUserRemoved = !membersAfterUpdate.includes(ourPubKey.key);
if (wasCurrentUserRemoved) {
await markGroupAsLeftOrKicked(groupPublicKey, convo, true);
}
// Generate and distribute a new encryption key pair if needed
if (await areWeAdmin(convo)) {
try {
await ClosedGroup.generateAndSendNewEncryptionKeyPair(groupPubKey, membersAfterUpdate);
} catch (e) {
window.log.warn('Could not distribute new encryption keypair.');
}
await markGroupAsLeftOrKicked(groupPubKey, convo, true);
}
// Note: we don't want to send a new encryption keypair when we get a member removed.
// Only add update message if we have something to show
if (membersAfterUpdate.length !== currentMembers.length) {
@ -660,6 +656,8 @@ async function handleClosedGroupMembersRemoved(
// Update the group
convo.set({ members: membersAfterUpdate });
const zombies = convo.get('zombies').filter(z => membersAfterUpdate.includes(z));
convo.set({ zombies });
await convo.commit();
await removeFromCache(envelope);
@ -762,6 +760,7 @@ async function handleClosedGroupMemberLeft(envelope: EnvelopePlus, convo: Conver
// otherwise, we remove the sender from the list of current members in this group
const oldMembers = convo.get('members') || [];
const newMembers = oldMembers.filter(s => s !== sender);
window.log.info(`Got a group update for group ${envelope.source}, type: MEMBER_LEFT`);
// Show log if we sent this message ourself (from another device or not)
if (UserUtils.isUsFromCache(sender)) {
@ -784,12 +783,6 @@ async function handleClosedGroupMemberLeft(envelope: EnvelopePlus, convo: Conver
return;
}
// if we are the admin, and there are still some members after the member left, we send a new keypair
// to the remaining members
if (isCurrentUserAdmin && !!newMembers.length) {
await ClosedGroup.generateAndSendNewEncryptionKeyPair(groupPublicKey, newMembers);
}
// Another member left, not us, not the admin, just another member.
// But this member was in the list of members (as performIfValid checks for that)
const groupDiff: ClosedGroup.GroupDiff = {
@ -797,8 +790,19 @@ async function handleClosedGroupMemberLeft(envelope: EnvelopePlus, convo: Conver
};
await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming', _.toNumber(envelope.timestamp));
convo.updateLastMessage();
await addMemberToZombies(envelope, PubKey.cast(sender), convo);
// if a user just left and we are the admin, we remove him right away for everyone by sending a members removed
if (!isCurrentUserAdmin && oldMembers.includes(sender)) {
addMemberToZombies(envelope, PubKey.cast(sender), convo);
}
convo.set('members', newMembers);
// if we are the admin, and there are still some members after the member left, we send a new keypair
// to the remaining members.
// also if we are the admin, we can tell to everyone that this user is effectively removed
if (isCurrentUserAdmin && !!newMembers.length) {
await ClosedGroup.sendRemovedMembers(convo, [sender], newMembers);
}
await convo.commit();
await removeFromCache(envelope);

@ -40,7 +40,8 @@ import { updateOpenGroupV2 } from '../../opengroup/opengroupV2/OpenGroupUpdate';
export interface GroupInfo {
id: string;
name: string;
members: Array<string>; // Primary keys
members: Array<string>;
zombies?: Array<string>;
active?: boolean;
expireTimer?: number | null;
avatar?: any;
@ -107,6 +108,8 @@ export async function initiateGroupUpdate(
if (!isMediumGroup) {
throw new Error('Legacy group are not supported anymore.');
}
const oldZombies = convo.get('zombies');
console.warn('initiategroupUpdate old zombies:', oldZombies);
// do not give an admins field here. We don't want to be able to update admins and
// updateOrCreateClosedGroup() will update them if given the choice.
@ -114,10 +117,13 @@ export async function initiateGroupUpdate(
id: groupId,
name: groupName,
members,
// remove from the zombies list the zombies not which are not in the group anymore
zombies: convo.get('zombies').filter(z => members.includes(z)),
active: true,
expireTimer: convo.get('expireTimer'),
avatar,
};
console.warn('initiategroupUpdate new zombies:', groupDetails.zombies);
const diff = buildGroupDiff(convo, groupDetails);
@ -150,8 +156,9 @@ export async function initiateGroupUpdate(
const dbMessageLeaving = await addUpdateMessage(convo, leavingOnlyDiff, 'outgoing', Date.now());
MessageController.getInstance().register(dbMessageLeaving.id, dbMessageLeaving);
const stillMembers = members;
await sendRemovedMembers(convo, diff.leavingMembers, dbMessageLeaving.id, stillMembers);
await sendRemovedMembers(convo, diff.leavingMembers, stillMembers, dbMessageLeaving.id);
}
await convo.commit();
}
export async function addUpdateMessage(
@ -253,6 +260,10 @@ export async function updateOrCreateClosedGroup(details: GroupInfo) {
updates.left = true;
}
if (details.zombies) {
updates.zombies = details.zombies;
}
conversation.set(updates);
// Update the conversation avatar only if new avatar exists and hash differs
@ -285,10 +296,14 @@ export async function updateOrCreateClosedGroup(details: GroupInfo) {
if (expireTimer === undefined || typeof expireTimer !== 'number') {
return;
}
const source = UserUtils.getOurPubKeyStrFromCache();
await conversation.updateExpirationTimer(expireTimer, source, Date.now(), {
fromSync: true,
});
await conversation.updateExpirationTimer(
expireTimer,
UserUtils.getOurPubKeyStrFromCache(),
Date.now(),
{
fromSync: true,
}
);
}
export async function leaveClosedGroup(groupId: string) {
@ -420,11 +435,11 @@ async function sendAddedMembers(
await Promise.all(promises);
}
async function sendRemovedMembers(
export async function sendRemovedMembers(
convo: ConversationModel,
removedMembers: Array<string>,
messageId: string,
stillMembers: Array<string>
stillMembers: Array<string>,
messageId?: string
) {
if (!removedMembers?.length) {
window.log.warn('No removedMembers given for group update. Skipping');

@ -183,6 +183,14 @@ export function pushCannotRemoveCreatorFromGroup() {
);
}
export function pushOnlyAdminCanRemove() {
pushToastInfo(
'onlyAdminCanRemoveMembers',
window.i18n('onlyAdminCanRemoveMembers'),
window.i18n('onlyAdminCanRemoveMembersDesc')
);
}
export function pushUserNeedsToHaveJoined() {
pushToastWarning(
'userNeedsToHaveJoined',

@ -81,6 +81,7 @@ export class MockConversation {
lastJoinedTimestamp: Date.now(),
lastMessageStatus: null,
lastMessage: null,
zombies: [],
};
}

Loading…
Cancel
Save