Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add stopping to federate (WPB-203) #15583

Merged
merged 26 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b3d52c8
feat: Add stopping to federate (WPB-203)
thisisamir98 Aug 11, 2023
b215a8a
disable 1:1 conversations
thisisamir98 Aug 14, 2023
0d37101
display domains
thisisamir98 Aug 14, 2023
54a36f0
remove yalc
thisisamir98 Aug 14, 2023
82467e1
handle federation.connectionRemoved
thisisamir98 Aug 14, 2023
cb87abd
avoid too many system messages
thisisamir98 Aug 14, 2023
0425f35
check if conversation has members from both backends
thisisamir98 Aug 14, 2023
19e80c2
remove fake event
thisisamir98 Aug 14, 2023
eda23d1
Merge branch dev of github.com:wireapp/wire-webapp into feat/WPB-203
thisisamir98 Aug 14, 2023
14e5ee9
bump core
thisisamir98 Aug 14, 2023
2814782
fix type error
thisisamir98 Aug 14, 2023
c0754bc
fix more type errors
thisisamir98 Aug 14, 2023
6427193
remove extra comment
thisisamir98 Aug 14, 2023
1d1458a
remove event save type error
thisisamir98 Aug 14, 2023
1e1df3e
persist system message
thisisamir98 Aug 14, 2023
51e7e52
offline delete
thisisamir98 Aug 14, 2023
53d9ff3
fix type error
thisisamir98 Aug 14, 2023
371458e
fix lint error
thisisamir98 Aug 14, 2023
9038815
add jsdoc
thisisamir98 Aug 15, 2023
76d1df0
refactor: use switch statement
thisisamir98 Aug 15, 2023
a692a88
fix bugs
thisisamir98 Aug 15, 2023
d5a3f6e
add learn more link and bold
thisisamir98 Aug 15, 2023
e140026
remove debug util
thisisamir98 Aug 15, 2023
fc35485
refactor
thisisamir98 Aug 15, 2023
ae67d93
Merge branch 'dev' of github.com:wireapp/wire-webapp into feat/WPB-203
thisisamir98 Aug 16, 2023
1be8626
feat: debounce function
thisisamir98 Aug 16, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
"@datadog/browser-rum": "^4.46.0",
"@emotion/react": "11.11.1",
"@wireapp/avs": "9.3.7",
"@wireapp/core": "40.9.0",
"@wireapp/core": "40.9.1",
"@wireapp/lru-cache": "3.8.1",
"@wireapp/react-ui-kit": "9.8.0",
"@wireapp/store-engine-dexie": "2.1.3",
"@wireapp/store-engine-sqleet": "1.8.9",
"@wireapp/webapp-events": "0.17.0",
"@wireapp/webapp-events": "0.18.0",
"amplify": "https://github.com/wireapp/amplify#head=master",
"beautiful-react-hooks": "^4.3.0",
"classnames": "2.3.2",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,8 @@
"featureConfigChangeModalSelfDeletingMessagesDescriptionItemEnabled": "Self-deleting messages are enabled. You can set a timer before writing a message.",
"featureConfigChangeModalSelfDeletingMessagesDescriptionItemEnforced": "Self-deleting messages are now mandatory. New messages will self-delete after {{timeout}}.",
"featureConfigChangeModalSelfDeletingMessagesHeadline": "There has been a change in {{brandName}}",
"federationDelete": "Your backend stopped federating with {{backendUrl}}.",
"federationConnectionRemove": "The backends {{backendUrlOne}} and {{backendUrlTwo}} stopped federating.",
"fileTypeRestrictedIncoming": "File from [bold]{{name}}[/bold] can’t be opened",
"fileTypeRestrictedOutgoing": "Sharing files with the {{fileExt}} extension is not permitted by your organization",
"folderViewTooltip": "Folders",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Wire
* Copyright (C) 2023 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import React from 'react';

import {Icon} from 'Components/Icon';
import {useKoSubscribableChildren} from 'Util/ComponentUtil';
import {t} from 'Util/LocalizerUtil';

import {MessageTime} from './MessageTime';

import {FederationStopMessage as FederationStopMessageEntity} from '../../../entity/message/FederationStopMessage';

export interface FederationStopMessageProps {
message: FederationStopMessageEntity;
}

const FederationStopMessage: React.FC<FederationStopMessageProps> = ({message}) => {
const {timestamp} = useKoSubscribableChildren(message, ['timestamp']);
const {id, domains} = message;

return (
<div className="message-header">
<div className="message-header-icon message-header-icon--svg">
<div>
<Icon.Info />
</div>
</div>
<div
className="message-header-label"
data-uie-name="element-message-failed-to-add-users"
data-uie-value={`domains-${domains.join('_')}`}
>
{domains.length === 1
? t('federationDelete', {backendUrl: domains[0]})
: t('federationConnectionRemove', {backendUrlOne: domains[0], backendUrlTwo: domains[1]})}
</div>
<p className="message-body-actions">
<MessageTime
timestamp={timestamp}
data-uie-uid={id}
data-uie-name="item-message-failed-to-add-users-timestamp"
/>
</p>
</div>
);
};

export {FederationStopMessage};
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {ContentMessageComponent} from './ContentMessage';
import {DecryptErrorMessage} from './DecryptErrorMessage';
import {DeleteMessage} from './DeleteMessage';
import {FailedToAddUsersMessage} from './FailedToAddUsersMessage';
import {FederationStopMessage} from './FederationStopMessage';
import {FileTypeRestrictedMessage} from './FileTypeRestrictedMessage';
import {LegalHoldMessage} from './LegalHoldMessage';
import {MemberMessage} from './MemberMessage';
Expand Down Expand Up @@ -225,6 +226,9 @@ export const MessageWrapper: React.FC<MessageParams & {hasMarker: boolean; isMes
if (message.isLegalHold()) {
return <LegalHoldMessage message={message} />;
}
if (message.isFederationStop()) {
return <FederationStopMessage message={message} />;
}
if (message.isVerification()) {
return <VerificationMessage message={message} />;
}
Expand Down
4 changes: 2 additions & 2 deletions src/script/conversation/AbstractConversationEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*
*/

import {ConversationEvent} from '@wireapp/api-client/lib/event';
import {ConversationEvent, FederationEvent} from '@wireapp/api-client/lib/event';

import {ClientConversationEvent} from './EventBuilder';

Expand Down Expand Up @@ -52,7 +52,7 @@ export class AbstractConversationEventHandler {
*/
handleConversationEvent(
conversationEntity: Conversation,
eventJson: ConversationEvent | ClientConversationEvent,
eventJson: ConversationEvent | ClientConversationEvent | FederationEvent,
): Promise<void> {
const handler = this.eventHandlingConfig[eventJson.type] || (() => Promise.resolve());
return handler.bind(this)(conversationEntity, eventJson);
Expand Down
171 changes: 160 additions & 11 deletions src/script/conversation/ConversationRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
ConversationRenameEvent,
ConversationTypingEvent,
CONVERSATION_EVENT,
FederationEvent,
FEDERATION_EVENT,
ConversationMLSWelcomeEvent,
} from '@wireapp/api-client/lib/event';
import {BackendErrorLabel} from '@wireapp/api-client/lib/http/';
Expand Down Expand Up @@ -310,13 +312,154 @@ export class ConversationRepository {
amplify.subscribe(WebAppEvents.TEAM.MEMBER_LEAVE, this.teamMemberLeave);
amplify.subscribe(WebAppEvents.USER.UNBLOCKED, this.onUnblockUser);
amplify.subscribe(WebAppEvents.CONVERSATION.INJECT_LEGAL_HOLD_MESSAGE, this.injectLegalHoldMessage);
amplify.subscribe(WebAppEvents.FEDERATION.EVENT_FROM_BACKEND, this.onFederationEvent);

this.eventService.addEventUpdatedListener(this.updateLocalMessageEntity);
this.eventService.addEventDeletedListener(this.deleteLocalMessageEntity);

window.addEventListener<any>(WebAppEvents.CONVERSATION.JOIN, this.onConversationJoin);
}

private readonly onFederationEvent = async (event: FederationEvent) => {
const {type, data} = event;

switch (type) {
case FEDERATION_EVENT.FEDERATION_DELETE:
const {domain: deletedDomain} = data;
await this.onFederationDelete(deletedDomain);

break;
case FEDERATION_EVENT.FEDERATION_CONNECTION_REMOVED:
const {domains: deletedDomains} = data;
await this.onFederationConnectionRemove(deletedDomains);

break;
}
};

/**
* For the `federation.delete` event: (Backend A has stopped federating with us)
- receive the event from backend
- leave the conversations locally that are owned by the backend A which was deleted.
- remove the deleted backend A users locally from our own conversations.
- insert system message to the affected conversations about federation termination.
* @param deletedDomain the domain that stopped federating
*/
private onFederationDelete = async (deletedDomain: string) => {
const conversationsToLeave = this.conversationState
.conversations()
.filter(conversation => conversation.domain === deletedDomain);

conversationsToLeave.forEach(async conversation => {
await this.insertFederationStopSystemMessage(conversation, [deletedDomain]);
if (conversation.is1to1()) {
conversation.status(ConversationStatus.PAST_MEMBER);
return;
}

await this.leaveConversation(conversation, false);
});

const conversationsToRemoveTheirDeletedDomainUsers = this.conversationState
.conversations()
.filter(conversation => conversation.domain !== deletedDomain);

conversationsToRemoveTheirDeletedDomainUsers.forEach(async conversation => {
const usersToRemove = conversation.allUserEntities().filter(user => user.domain === deletedDomain);
if (usersToRemove.length === 0) {
return;
}
await this.insertFederationStopSystemMessage(conversation, [deletedDomain]);
await this.removeDeletedFederationUsers(conversation, usersToRemove);
});
};

/**
* For the `federation.connectionRemoved` event: (Backend A & B stopped federating, user is on C)
- receive the event from backend
- Identify all conversations that are not owned from A or B domain and that contain users from A and B
- remove users from A and B from those conversations
- insert system message in those conversations about backend A and B stopping to federate
- identify all conversations owned by domain A that contains users from B
- remove users from B from those conversations
- insert system message in those conversations about backend A and B stopping to federate
- Identify all conversations owned by domain B that contains users from A
- remove users from A from those conversations
- insert system message in those conversations about backend A and B stopping to federate
* @param domains The domains that stopped federating with each other
*/
private readonly onFederationConnectionRemove = async (domains: string[]) => {
const selfUser = this.userState.self();
const [domainOne, domainTwo] = domains;
const allConversations = this.conversationState.conversations();

allConversations
.filter(conversation => conversation.domain === domainOne)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these two blocks are almost identical except domain type. We can create a function and call it twice with each domain.

const handleFederationUsers = async (domainToFilter: string, userDomainToDelete: string) => {
  allConversations
    .filter(conversation => conversation.domain === domainToFilter)
    .forEach(async conversation => {
      const usersToDelete = conversation.allUserEntities().filter(user => user.domain === userDomainToDelete);
      if (usersToDelete.length > 0) {
        await this.removeDeletedFederationUsers(conversation, usersToDelete);
        await this.insertFederationStopSystemMessage(conversation, [domainOne, domainTwo]);
      }
    });
};

await handleFederationUsers(domainOne, domainTwo);
await handleFederationUsers(domainTwo, domainOne);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's is a good suggestion, indeed. But without unit tests to back it, it's also risky.
I'd recommend writing tests first (I can help with that), make sure that we are compliant with the specs and then refactoring to a cleaner/more performant code.

.forEach(async conversation => {
const usersToDelete = conversation.allUserEntities().filter(user => user.domain === domainTwo);
if (usersToDelete.length > 0) {
await this.removeDeletedFederationUsers(conversation, usersToDelete);
await this.insertFederationStopSystemMessage(conversation, [domainOne, domainTwo]);
}
});

allConversations
.filter(conversation => conversation.domain === domainTwo)
.forEach(async conversation => {
const usersToDelete = conversation.allUserEntities().filter(user => user.domain === domainOne);
if (usersToDelete.length > 0) {
await this.removeDeletedFederationUsers(conversation, usersToDelete);
await this.insertFederationStopSystemMessage(conversation, [domainOne, domainTwo]);
}
});

allConversations
.filter(conversation => {
if (conversation.domain !== selfUser.qualifiedId.domain) {
return false;
}

const hasAnyUserFromDomainOne = conversation
.allUserEntities()
.find(user => user.qualifiedId.domain === domainOne);
arjita-mitra marked this conversation as resolved.
Show resolved Hide resolved
const hasAnyUserFromDomainTwo = conversation
.allUserEntities()
.find(user => user.qualifiedId.domain === domainTwo);

return hasAnyUserFromDomainOne && hasAnyUserFromDomainTwo;
})
.forEach(async conversation => {
const usersToDelete = conversation
.allUserEntities()
.filter(user => user.domain === domainOne || user.domain === domainTwo);
arjita-mitra marked this conversation as resolved.
Show resolved Hide resolved
if (usersToDelete.length > 0) {
await this.removeDeletedFederationUsers(conversation, usersToDelete);
await this.insertFederationStopSystemMessage(conversation, [domainOne, domainTwo]);
}
});
};

private readonly removeDeletedFederationUsers = async (conversation: Conversation, usersToRemove: User[]) => {
if (usersToRemove.length === 0) {
return;
}

try {
for (const user of usersToRemove) {
await this.removeMember(conversation, user.qualifiedId, {localOnly: true});
}
} catch (error) {
console.warn('failed to remove users', error);
}
};

private readonly insertFederationStopSystemMessage = async (conversation: Conversation, domains: string[]) => {
const currentTimestamp = this.serverTimeHandler.toServerTimestamp();
const selfUser = this.userState.self();
const event = EventBuilder.buildFederationStop(conversation, selfUser, domains, currentTimestamp);
await this.eventRepository.injectEvent(event, EventRepository.SOURCE.INJECTED);
};

private readonly updateLocalMessageEntity = async ({
obj: updatedEvent,
oldObj: oldEvent,
Expand Down Expand Up @@ -1615,18 +1758,18 @@ export class ConversationRepository {
* @param userId ID of member to be removed from the conversation
* @returns Resolves when member was removed from the conversation
*/
private async removeMemberFromConversation(conversationEntity: Conversation, userId: QualifiedId) {
const response = await this.core.service!.conversation.removeUserFromConversation(
conversationEntity.qualifiedId,
userId,
);
private async removeMemberFromConversation(conversationEntity: Conversation, userId: QualifiedId, localOnly = false) {
const currentTimestamp = this.serverTimeHandler.toServerTimestamp();
const response = localOnly
? EventBuilder.buildMemberLeave(conversationEntity, userId, true, currentTimestamp)
: await this.core.service!.conversation.removeUserFromConversation(conversationEntity.qualifiedId, userId);

const roles = conversationEntity.roles();
delete roles[userId.id];
conversationEntity.roles(roles);
const currentTimestamp = this.serverTimeHandler.toServerTimestamp();
const event = response || EventBuilder.buildMemberLeave(conversationEntity, userId, true, currentTimestamp);
await this.eventRepository.injectEvent(event, EventRepository.SOURCE.BACKEND_RESPONSE);
return event;
await this.eventRepository.injectEvent(response, EventRepository.SOURCE.BACKEND_RESPONSE);

return response;
}

/**
Expand All @@ -1652,7 +1795,11 @@ export class ConversationRepository {
* @param clearContent Should we clear the conversation content from the database?
* @returns Resolves when member was removed from the conversation
*/
public async removeMember(conversationEntity: Conversation, userId: QualifiedId, clearContent: boolean = false) {
public async removeMember(
conversationEntity: Conversation,
userId: QualifiedId,
{clearContent = false, localOnly = false} = {},
) {
const isUserLeaving = this.userState.self().qualifiedId.id === userId.id;
const isMLSConversation = conversationEntity.isUsingMLSProtocol;

Expand All @@ -1662,7 +1809,7 @@ export class ConversationRepository {

return isMLSConversation
? this.removeMemberFromMLSConversation(conversationEntity, userId)
: this.removeMemberFromConversation(conversationEntity, userId);
: this.removeMemberFromConversation(conversationEntity, userId, localOnly);
}

/**
Expand Down Expand Up @@ -2018,6 +2165,7 @@ export class ConversationRepository {
// Prevent logging typing events
return;
}

const {time, from, qualified_conversation, type} = event;
const extra: Record<string, unknown> = {};
extra.messageId = 'id' in event && event.id;
Expand Down Expand Up @@ -2329,6 +2477,7 @@ export class ConversationRepository {
case ClientEvent.CONVERSATION.KNOCK:
case ClientEvent.CONVERSATION.CALL_TIME_OUT:
case ClientEvent.CONVERSATION.FAILED_TO_ADD_USERS:
case ClientEvent.CONVERSATION.FEDERATION_STOP:
case ClientEvent.CONVERSATION.LEGAL_HOLD_UPDATE:
case ClientEvent.CONVERSATION.LOCATION:
case ClientEvent.CONVERSATION.MISSED_MESSAGES:
Expand Down
22 changes: 22 additions & 0 deletions src/script/conversation/EventBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ export type DegradedMessageEvent = ConversationEvent<DegradedMessageEventData> &
export type DeleteEvent = ConversationEvent<{deleted_time: number; message_id: string; time: string}> & {
type: CONVERSATION.MESSAGE_DELETE;
};
export type FederationStopEvent = ConversationEvent<{domains: string[]}> & {
type: CONVERSATION.FEDERATION_STOP;
};
export type GroupCreationEventData = {
allTeamMembers: boolean;
name: string;
Expand Down Expand Up @@ -206,6 +209,7 @@ export type ClientConversationEvent =
| ErrorEvent
| CompositeMessageAddEvent
| ConfirmationEvent
| FederationStopEvent
| DeleteEvent
| DeleteEverywhereEvent
| DegradedMessageEvent
Expand Down Expand Up @@ -479,6 +483,24 @@ export const EventBuilder = {
};
},

buildFederationStop(
conversationEntity: Conversation,
selfUser: User,
domains: string[],
currentTimestamp: number,
): FederationStopEvent {
return {
...buildQualifiedId(conversationEntity),
data: {
domains,
},
id: createUuid(),
from: selfUser.id,
time: conversationEntity.getNextIsoDate(currentTimestamp),
type: CONVERSATION.FEDERATION_STOP,
};
},

buildMessageAdd(conversationEntity: Conversation, currentTimestamp: number, senderId: string): MessageAddEvent {
return {
...buildQualifiedId(conversationEntity),
Expand Down
Loading
Loading