From 2da386887f931f7d1295da20bfc4d03039053f04 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 23 Oct 2023 11:41:50 +0200 Subject: [PATCH 01/86] fix: Allow member join/leave events to also show unread count badge [WPB-5148] (#16085) --- src/script/conversation/ConversationCellState.ts | 13 ++++--------- test/helper/UserGenerator.ts | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/script/conversation/ConversationCellState.ts b/src/script/conversation/ConversationCellState.ts index 5059f037def..28d241e53ea 100644 --- a/src/script/conversation/ConversationCellState.ts +++ b/src/script/conversation/ConversationCellState.ts @@ -248,14 +248,9 @@ const _getStateGroupActivity = { return ''; }, icon: (conversationEntity: Conversation): ConversationStatusIcon | void => { - const lastMessageEntity = conversationEntity.getNewestMessage(); - const isMemberRemoval = lastMessageEntity.isMember() && (lastMessageEntity as MemberMessage).isMemberRemoval(); - - if (isMemberRemoval) { - return conversationEntity.showNotificationsEverything() - ? ConversationStatusIcon.UNREAD_MESSAGES - : ConversationStatusIcon.MUTED; - } + return conversationEntity.showNotificationsEverything() + ? ConversationStatusIcon.UNREAD_MESSAGES + : ConversationStatusIcon.MUTED; }, match: (conversationEntity: Conversation) => { const lastMessageEntity = conversationEntity.getNewestMessage(); @@ -307,7 +302,7 @@ const _getStateRemoved = { return ''; }, - icon: () => ConversationStatusIcon.NONE, + icon: () => ConversationStatusIcon.UNREAD_MESSAGES, match: (conversationEntity: Conversation) => conversationEntity.removed_from_conversation(), }; diff --git a/test/helper/UserGenerator.ts b/test/helper/UserGenerator.ts index 32a44806e95..71441fc9453 100644 --- a/test/helper/UserGenerator.ts +++ b/test/helper/UserGenerator.ts @@ -48,7 +48,7 @@ export function generateAPIUser( handle: faker.internet.userName(), id: id.id, // replace special chars to avoid escaping problems with querying the DOM - name: faker.person.fullName().replace(/[^a-zA-Z ]/, ''), + name: faker.person.fullName().replace(/[^a-zA-Z ]/g, ''), qualified_id: id, ...overwites, }; From 9f84497b46ccabc9107a8bcf7bdffa76b79d2499 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 23 Oct 2023 11:42:01 +0200 Subject: [PATCH 02/86] runfix: Make sure event is stored in DB before processing quotes (#16086) --- src/script/event/preprocessor/QuotedMessageMiddleware.ts | 9 ++++----- src/script/main/app.ts | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/script/event/preprocessor/QuotedMessageMiddleware.ts b/src/script/event/preprocessor/QuotedMessageMiddleware.ts index 8c1652b5d8b..73f43f39573 100644 --- a/src/script/event/preprocessor/QuotedMessageMiddleware.ts +++ b/src/script/event/preprocessor/QuotedMessageMiddleware.ts @@ -64,9 +64,9 @@ export class QuotedMessageMiddleware implements EventMiddleware { const originalMessageId = event.data.message_id; const {replies} = await this.findRepliesToMessage(event.conversation, originalMessageId); this.logger.info(`Invalidating '${replies.length}' replies to deleted message '${originalMessageId}'`); - replies.forEach(reply => { + replies.forEach(async reply => { reply.data.quote = {error: {type: QuoteEntity.ERROR.MESSAGE_NOT_FOUND}}; - this.eventService.replaceEvent(reply); + await this.eventService.replaceEvent(reply); }); return event; } @@ -78,13 +78,12 @@ export class QuotedMessageMiddleware implements EventMiddleware { } this.logger.info(`Updating '${replies.length}' replies to updated message '${originalMessageId}'`); - replies.forEach(reply => { + replies.forEach(async reply => { const quote = reply.data.quote; if (quote && typeof quote !== 'string' && 'message_id' in quote && 'id' in event) { quote.message_id = event.id as string; } - // we want to update the messages quoting the original message later, thus the timeout - setTimeout(() => this.eventService.replaceEvent(reply)); + await this.eventService.replaceEvent(reply); }); const decoratedData = {...event.data, quote: originalEvent.data.quote}; return {...event, data: decoratedData}; diff --git a/src/script/main/app.ts b/src/script/main/app.ts index 04d930ba563..afff8e75b1f 100644 --- a/src/script/main/app.ts +++ b/src/script/main/app.ts @@ -387,9 +387,9 @@ export class App { eventRepository.setEventProcessMiddlewares([ serviceMiddleware, - quotedMessageMiddleware, readReceiptMiddleware, eventStorageMiddleware, + quotedMessageMiddleware, ]); // Setup all the event processors const federationEventProcessor = new FederationEventProcessor(eventRepository, serverTimeHandler, selfUser); From e8b7b1067c0394fb3a8f4a1b51458307728a3d6b Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 23 Oct 2023 13:58:21 +0200 Subject: [PATCH 03/86] runfix: split handling quotes and replies (#16087) --- ...test.ts => QuoteDecoderMiddleware.test.ts} | 55 +--------- .../preprocessor/QuoteDecoderMiddleware.ts | 103 ++++++++++++++++++ .../RepliesUpdaterMiddleware.test.ts | 99 +++++++++++++++++ ...dleware.ts => RepliesUpdaterMiddleware.ts} | 63 ++--------- src/script/main/app.ts | 7 +- 5 files changed, 220 insertions(+), 107 deletions(-) rename src/script/event/preprocessor/{QuotedMessageMiddleware.test.ts => QuoteDecoderMiddleware.test.ts} (60%) create mode 100644 src/script/event/preprocessor/QuoteDecoderMiddleware.ts create mode 100644 src/script/event/preprocessor/RepliesUpdaterMiddleware.test.ts rename src/script/event/preprocessor/{QuotedMessageMiddleware.ts => RepliesUpdaterMiddleware.ts} (65%) diff --git a/src/script/event/preprocessor/QuotedMessageMiddleware.test.ts b/src/script/event/preprocessor/QuoteDecoderMiddleware.test.ts similarity index 60% rename from src/script/event/preprocessor/QuotedMessageMiddleware.test.ts rename to src/script/event/preprocessor/QuoteDecoderMiddleware.test.ts index 0ec426f2d58..35257e84925 100644 --- a/src/script/event/preprocessor/QuotedMessageMiddleware.test.ts +++ b/src/script/event/preprocessor/QuoteDecoderMiddleware.test.ts @@ -21,14 +21,13 @@ import {Quote} from '@wireapp/protocol-messaging'; import {Conversation} from 'src/script/entity/Conversation'; import {User} from 'src/script/entity/User'; -import {ClientEvent} from 'src/script/event/Client'; import {MessageHasher} from 'src/script/message/MessageHasher'; import {QuoteEntity} from 'src/script/message/QuoteEntity'; import {createMessageAddEvent, toSavedEvent} from 'test/helper/EventGenerator'; import {arrayToBase64} from 'Util/util'; import {createUuid} from 'Util/uuid'; -import {QuotedMessageMiddleware} from './QuotedMessageMiddleware'; +import {QuotedMessageMiddleware} from './QuoteDecoderMiddleware'; import {EventService} from '../EventService'; @@ -101,57 +100,5 @@ describe('QuotedMessageMiddleware', () => { expect(parsedEvent.data.quote.message_id).toEqual(quotedMessage.id); expect(parsedEvent.data.quote.user_id).toEqual(quotedMessage.from); }); - - it('updates quotes in DB when a message is edited', async () => { - const [quotedMessageMiddleware, {eventService}] = buildQuotedMessageMiddleware(); - const originalMessage = toSavedEvent(createMessageAddEvent()); - const replies = [ - createMessageAddEvent({dataOverrides: {quote: {message_id: originalMessage.id} as any}}), - createMessageAddEvent({dataOverrides: {quote: {message_id: originalMessage.id} as any}}), - ]; - eventService.loadEvent.mockResolvedValue(originalMessage); - eventService.loadEventsReplyingToMessage.mockResolvedValue(replies); - - const event = createMessageAddEvent({dataOverrides: {replacing_message_id: originalMessage.id}}); - - jest.useFakeTimers(); - - await quotedMessageMiddleware.processEvent(event); - jest.advanceTimersByTime(1); - - expect(eventService.replaceEvent).toHaveBeenCalledWith( - jasmine.objectContaining({data: jasmine.objectContaining({quote: {message_id: event.id}})}), - ); - jest.useRealTimers(); - }); - - it('invalidates quotes in DB when a message is deleted', () => { - const [quotedMessageMiddleware, {eventService}] = buildQuotedMessageMiddleware(); - const originalMessage = toSavedEvent(createMessageAddEvent()); - const replies = [ - createMessageAddEvent({dataOverrides: {quote: {message_id: originalMessage.id} as any}}), - createMessageAddEvent({dataOverrides: {quote: {message_id: originalMessage.id} as any}}), - ]; - spyOn(eventService, 'loadEvent').and.returnValue(Promise.resolve(originalMessage)); - spyOn(eventService, 'loadEventsReplyingToMessage').and.returnValue(Promise.resolve(replies)); - spyOn(eventService, 'replaceEvent').and.returnValue(Promise.resolve()); - - const event = { - conversation: 'conversation-uuid', - data: { - replacing_message_id: 'original-id', - }, - id: 'new-id', - type: ClientEvent.CONVERSATION.MESSAGE_DELETE, - } as any; - - return quotedMessageMiddleware.processEvent(event).then(() => { - expect(eventService.replaceEvent).toHaveBeenCalledWith( - jasmine.objectContaining({ - data: jasmine.objectContaining({quote: {error: {type: QuoteEntity.ERROR.MESSAGE_NOT_FOUND}}}), - }), - ); - }); - }); }); }); diff --git a/src/script/event/preprocessor/QuoteDecoderMiddleware.ts b/src/script/event/preprocessor/QuoteDecoderMiddleware.ts new file mode 100644 index 00000000000..17da5532472 --- /dev/null +++ b/src/script/event/preprocessor/QuoteDecoderMiddleware.ts @@ -0,0 +1,103 @@ +/* + * 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 {Quote} from '@wireapp/protocol-messaging'; + +import {MessageAddEvent} from 'src/script/conversation/EventBuilder'; +import {getLogger, Logger} from 'Util/Logger'; +import {base64ToArray} from 'Util/util'; + +import {QuoteEntity} from '../../message/QuoteEntity'; +import {StoredEvent} from '../../storage/record/EventRecord'; +import {ClientEvent} from '../Client'; +import {EventMiddleware, IncomingEvent} from '../EventProcessor'; +import type {EventService} from '../EventService'; + +export class QuotedMessageMiddleware implements EventMiddleware { + private readonly logger: Logger; + + constructor(private readonly eventService: EventService) { + this.logger = getLogger('QuotedMessageMiddleware'); + } + + /** + * Handles validation of the event if it contains a quote. + * If the event does contain a quote, will also decorate the event with some metadata regarding the quoted message + * + * @param event event in the DB format + * @returns the original event if no quote is found (or does not validate). The decorated event if the quote is valid + */ + async processEvent(event: IncomingEvent): Promise { + switch (event.type) { + case ClientEvent.CONVERSATION.MESSAGE_ADD: { + const originalMessageId = event.data.replacing_message_id; + return originalMessageId ? this.handleEditEvent(event, originalMessageId) : this.handleAddEvent(event); + } + } + return event; + } + + private async handleEditEvent(event: MessageAddEvent, originalMessageId: string): Promise { + const originalEvent = (await this.eventService.loadEvent(event.conversation, originalMessageId)) as StoredEvent< + MessageAddEvent | undefined + >; + if (!originalEvent) { + return event; + } + + const decoratedData = {...event.data, quote: originalEvent.data.quote}; + return {...event, data: decoratedData}; + } + + private async handleAddEvent(event: MessageAddEvent): Promise { + const rawQuote = event.data.quote; + + if (!rawQuote || typeof rawQuote !== 'string') { + return event; + } + + const encodedQuote = base64ToArray(rawQuote); + const quote = Quote.decode(encodedQuote); + this.logger.info(`Found quoted message: ${quote.quotedMessageId}`); + + const messageId = quote.quotedMessageId; + + const quotedMessage = await this.eventService.loadEvent(event.conversation, messageId); + if (!quotedMessage) { + this.logger.warn(`Quoted message with ID "${messageId}" not found.`); + const quoteData = { + error: { + type: QuoteEntity.ERROR.MESSAGE_NOT_FOUND, + }, + }; + + const decoratedData = {...event.data, quote: quoteData}; + return {...event, data: decoratedData}; + } + + const quoteData = { + message_id: messageId, + user_id: quotedMessage.from, + hash: quote.quotedMessageSha256, + }; + + const decoratedData = {...event.data, quote: quoteData}; + return {...event, data: decoratedData}; + } +} diff --git a/src/script/event/preprocessor/RepliesUpdaterMiddleware.test.ts b/src/script/event/preprocessor/RepliesUpdaterMiddleware.test.ts new file mode 100644 index 00000000000..af2993e6de2 --- /dev/null +++ b/src/script/event/preprocessor/RepliesUpdaterMiddleware.test.ts @@ -0,0 +1,99 @@ +/* + * 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 {Conversation} from 'src/script/entity/Conversation'; +import {User} from 'src/script/entity/User'; +import {ClientEvent} from 'src/script/event/Client'; +import {QuoteEntity} from 'src/script/message/QuoteEntity'; +import {createMessageAddEvent, toSavedEvent} from 'test/helper/EventGenerator'; +import {createUuid} from 'Util/uuid'; + +import {RepliesUpdaterMiddleware} from './RepliesUpdaterMiddleware'; + +import {EventService} from '../EventService'; + +function buildRepliesUpdaterMiddleware() { + const eventService = { + loadEvent: jest.fn(() => []), + loadEventsReplyingToMessage: jest.fn(), + loadReplacingEvent: jest.fn(), + replaceEvent: jest.fn(), + } as unknown as jest.Mocked; + + return [new RepliesUpdaterMiddleware(eventService), {eventService}] as const; +} + +describe('QuotedMessageMiddleware', () => { + const conversation = new Conversation(createUuid()); + conversation.selfUser(new User()); + + describe('processEvent', () => { + it('updates quotes in DB when a message is edited', async () => { + const [repliesUpdaterMiddleware, {eventService}] = buildRepliesUpdaterMiddleware(); + const originalMessage = toSavedEvent(createMessageAddEvent()); + const replies = [ + createMessageAddEvent({dataOverrides: {quote: {message_id: originalMessage.id} as any}}), + createMessageAddEvent({dataOverrides: {quote: {message_id: originalMessage.id} as any}}), + ]; + eventService.loadEvent.mockResolvedValue(originalMessage); + eventService.loadEventsReplyingToMessage.mockResolvedValue(replies); + + const event = createMessageAddEvent({dataOverrides: {replacing_message_id: originalMessage.id}}); + + jest.useFakeTimers(); + + await repliesUpdaterMiddleware.processEvent(event); + jest.advanceTimersByTime(1); + + expect(eventService.replaceEvent).toHaveBeenCalledWith( + jasmine.objectContaining({data: jasmine.objectContaining({quote: {message_id: event.id}})}), + ); + jest.useRealTimers(); + }); + + it('invalidates quotes in DB when a message is deleted', () => { + const [repliesUpdaterMiddleware, {eventService}] = buildRepliesUpdaterMiddleware(); + const originalMessage = toSavedEvent(createMessageAddEvent()); + const replies = [ + createMessageAddEvent({dataOverrides: {quote: {message_id: originalMessage.id} as any}}), + createMessageAddEvent({dataOverrides: {quote: {message_id: originalMessage.id} as any}}), + ]; + spyOn(eventService, 'loadEvent').and.returnValue(Promise.resolve(originalMessage)); + spyOn(eventService, 'loadEventsReplyingToMessage').and.returnValue(Promise.resolve(replies)); + spyOn(eventService, 'replaceEvent').and.returnValue(Promise.resolve()); + + const event = { + conversation: 'conversation-uuid', + data: { + replacing_message_id: 'original-id', + }, + id: 'new-id', + type: ClientEvent.CONVERSATION.MESSAGE_DELETE, + } as any; + + return repliesUpdaterMiddleware.processEvent(event).then(() => { + expect(eventService.replaceEvent).toHaveBeenCalledWith( + jasmine.objectContaining({ + data: jasmine.objectContaining({quote: {error: {type: QuoteEntity.ERROR.MESSAGE_NOT_FOUND}}}), + }), + ); + }); + }); + }); +}); diff --git a/src/script/event/preprocessor/QuotedMessageMiddleware.ts b/src/script/event/preprocessor/RepliesUpdaterMiddleware.ts similarity index 65% rename from src/script/event/preprocessor/QuotedMessageMiddleware.ts rename to src/script/event/preprocessor/RepliesUpdaterMiddleware.ts index 73f43f39573..9ab26b1cce3 100644 --- a/src/script/event/preprocessor/QuotedMessageMiddleware.ts +++ b/src/script/event/preprocessor/RepliesUpdaterMiddleware.ts @@ -17,11 +17,8 @@ * */ -import {Quote} from '@wireapp/protocol-messaging'; - import {DeleteEvent, MessageAddEvent} from 'src/script/conversation/EventBuilder'; import {getLogger, Logger} from 'Util/Logger'; -import {base64ToArray} from 'Util/util'; import {QuoteEntity} from '../../message/QuoteEntity'; import {StoredEvent} from '../../storage/record/EventRecord'; @@ -29,7 +26,7 @@ import {ClientEvent} from '../Client'; import {EventMiddleware, IncomingEvent} from '../EventProcessor'; import type {EventService} from '../EventService'; -export class QuotedMessageMiddleware implements EventMiddleware { +export class RepliesUpdaterMiddleware implements EventMiddleware { private readonly logger: Logger; constructor(private readonly eventService: EventService) { @@ -37,29 +34,27 @@ export class QuotedMessageMiddleware implements EventMiddleware { } /** - * Handles validation of the event if it contains a quote. - * If the event does contain a quote, will also decorate the event with some metadata regarding the quoted message + * Will update all the messages that refer to a particular edited message * * @param event event in the DB format - * @returns the original event if no quote is found (or does not validate). The decorated event if the quote is valid */ async processEvent(event: IncomingEvent): Promise { switch (event.type) { case ClientEvent.CONVERSATION.MESSAGE_ADD: { const originalMessageId = event.data.replacing_message_id; - return originalMessageId ? this.handleEditEvent(event, originalMessageId) : this.handleAddEvent(event); + return originalMessageId ? this.handleEditEvent(event, originalMessageId) : event; } case ClientEvent.CONVERSATION.MESSAGE_DELETE: { return this.handleDeleteEvent(event); } - - default: { - return event; - } } + return event; } + /** + * will invalidate all the replies to a deleted message + */ private async handleDeleteEvent(event: DeleteEvent): Promise { const originalMessageId = event.data.message_id; const {replies} = await this.findRepliesToMessage(event.conversation, originalMessageId); @@ -71,7 +66,10 @@ export class QuotedMessageMiddleware implements EventMiddleware { return event; } - private async handleEditEvent(event: MessageAddEvent, originalMessageId: string): Promise { + /** + * will update the message ID of all the replies to an edited message + */ + private async handleEditEvent(event: MessageAddEvent, originalMessageId: string) { const {originalEvent, replies} = await this.findRepliesToMessage(event.conversation, originalMessageId); if (!originalEvent) { return event; @@ -85,44 +83,7 @@ export class QuotedMessageMiddleware implements EventMiddleware { } await this.eventService.replaceEvent(reply); }); - const decoratedData = {...event.data, quote: originalEvent.data.quote}; - return {...event, data: decoratedData}; - } - - private async handleAddEvent(event: MessageAddEvent): Promise { - const rawQuote = event.data.quote; - - if (!rawQuote || typeof rawQuote !== 'string') { - return event; - } - - const encodedQuote = base64ToArray(rawQuote); - const quote = Quote.decode(encodedQuote); - this.logger.info(`Found quoted message: ${quote.quotedMessageId}`); - - const messageId = quote.quotedMessageId; - - const quotedMessage = await this.eventService.loadEvent(event.conversation, messageId); - if (!quotedMessage) { - this.logger.warn(`Quoted message with ID "${messageId}" not found.`); - const quoteData = { - error: { - type: QuoteEntity.ERROR.MESSAGE_NOT_FOUND, - }, - }; - - const decoratedData = {...event.data, quote: quoteData}; - return {...event, data: decoratedData}; - } - - const quoteData = { - message_id: messageId, - user_id: quotedMessage.from, - hash: quote.quotedMessageSha256, - }; - - const decoratedData = {...event.data, quote: quoteData}; - return {...event, data: decoratedData}; + return event; } private async findRepliesToMessage( diff --git a/src/script/main/app.ts b/src/script/main/app.ts index afff8e75b1f..e949a62ebd4 100644 --- a/src/script/main/app.ts +++ b/src/script/main/app.ts @@ -71,8 +71,9 @@ import {EventService} from '../event/EventService'; import {EventServiceNoCompound} from '../event/EventServiceNoCompound'; import {NotificationService} from '../event/NotificationService'; import {EventStorageMiddleware} from '../event/preprocessor/EventStorageMiddleware'; -import {QuotedMessageMiddleware} from '../event/preprocessor/QuotedMessageMiddleware'; +import {QuotedMessageMiddleware} from '../event/preprocessor/QuoteDecoderMiddleware'; import {ReceiptsMiddleware} from '../event/preprocessor/ReceiptsMiddleware'; +import {RepliesUpdaterMiddleware} from '../event/preprocessor/RepliesUpdaterMiddleware'; import {ServiceMiddleware} from '../event/preprocessor/ServiceMiddleware'; import {FederationEventProcessor} from '../event/processor/FederationEventProcessor'; import {GiphyRepository} from '../extension/GiphyRepository'; @@ -384,12 +385,14 @@ export class App { const serviceMiddleware = new ServiceMiddleware(conversationRepository, userRepository, selfUser); const quotedMessageMiddleware = new QuotedMessageMiddleware(this.service.event); const readReceiptMiddleware = new ReceiptsMiddleware(this.service.event, conversationRepository, selfUser); + const repliesUpdaterMiddleware = new RepliesUpdaterMiddleware(this.service.event); eventRepository.setEventProcessMiddlewares([ serviceMiddleware, readReceiptMiddleware, - eventStorageMiddleware, quotedMessageMiddleware, + eventStorageMiddleware, + repliesUpdaterMiddleware, ]); // Setup all the event processors const federationEventProcessor = new FederationEventProcessor(eventRepository, serverTimeHandler, selfUser); From f09bc728a8e7ce44e12d093f65ab599e120d0eaf Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 23 Oct 2023 14:43:07 +0200 Subject: [PATCH 04/86] runfix: make sure we find original event when dealing with updated quotes (#16088) --- src/script/event/preprocessor/RepliesUpdaterMiddleware.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/script/event/preprocessor/RepliesUpdaterMiddleware.ts b/src/script/event/preprocessor/RepliesUpdaterMiddleware.ts index 9ab26b1cce3..5e97ff6d821 100644 --- a/src/script/event/preprocessor/RepliesUpdaterMiddleware.ts +++ b/src/script/event/preprocessor/RepliesUpdaterMiddleware.ts @@ -70,7 +70,7 @@ export class RepliesUpdaterMiddleware implements EventMiddleware { * will update the message ID of all the replies to an edited message */ private async handleEditEvent(event: MessageAddEvent, originalMessageId: string) { - const {originalEvent, replies} = await this.findRepliesToMessage(event.conversation, originalMessageId); + const {originalEvent, replies} = await this.findRepliesToMessage(event.conversation, originalMessageId, event.id); if (!originalEvent) { return event; } @@ -89,8 +89,10 @@ export class RepliesUpdaterMiddleware implements EventMiddleware { private async findRepliesToMessage( conversationId: string, messageId: string, + /** in case the message was edited, we need to query the DB using the old event ID */ + previousMessageId?: string, ): Promise<{originalEvent?: MessageAddEvent; replies: StoredEvent[]}> { - const originalEvent = await this.eventService.loadEvent(conversationId, messageId); + const originalEvent = await this.eventService.loadEvent(conversationId, previousMessageId ?? messageId); if (!originalEvent || originalEvent.type !== ClientEvent.CONVERSATION.MESSAGE_ADD) { return { From 1c9e0f68a440b52c639ee7d9f6549792a2aa25d2 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 23 Oct 2023 14:48:03 +0200 Subject: [PATCH 05/86] runfix: Select mention before deleting it (#16089) --- .../components/RichTextEditor/nodes/Mention.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/script/components/RichTextEditor/nodes/Mention.tsx b/src/script/components/RichTextEditor/nodes/Mention.tsx index 3b679dce3ba..6332eaf126f 100644 --- a/src/script/components/RichTextEditor/nodes/Mention.tsx +++ b/src/script/components/RichTextEditor/nodes/Mention.tsx @@ -41,6 +41,7 @@ import { NodeKey, NodeSelection, RangeSelection, + $isRangeSelection, } from 'lexical'; import {KEY} from 'Util/KeyboardUtil'; @@ -75,10 +76,20 @@ export const Mention = (props: MentionComponentProps) => { }, [className, classNameFocused, isFocused]); const deleteMention = useCallback( - (payload: KeyboardEvent) => { - if (isSelected && $isNodeSelection($getSelection())) { - payload.preventDefault(); + (event: KeyboardEvent) => { + const currentSelection = $getSelection(); + const rangeSelection = $isRangeSelection(currentSelection) ? currentSelection : null; + const shouldSelect = nodeKey === rangeSelection?.getNodes()[0]?.getKey(); + // If the cursor is right before the mention, we first select the mention before deleting it + if (shouldSelect) { + event.preventDefault(); + setSelected(true); + return true; + } + // When the mention is selected, we actually delete it + if (isSelected && $isNodeSelection($getSelection())) { + event.preventDefault(); const node = $getNodeByKey(nodeKey); if ($isMentionNode(node)) { From 8782bd8ebcd70424d4253701520000e966ed4382 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 23 Oct 2023 15:07:05 +0200 Subject: [PATCH 06/86] runfix: Do not send typing stop event when no typing has occured (#16090) --- src/script/components/InputBar/InputBar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/script/components/InputBar/InputBar.tsx b/src/script/components/InputBar/InputBar.tsx index 14b5c8cdbc1..094689dd9c8 100644 --- a/src/script/components/InputBar/InputBar.tsx +++ b/src/script/components/InputBar/InputBar.tsx @@ -170,6 +170,7 @@ export const InputBar = ({ const isReplying = !!replyMessageEntity; const isConnectionRequest = isOutgoingRequest || isIncomingRequest; const hasLocalEphemeralTimer = isSelfDeletingMessagesEnabled && !!localMessageTimer && !hasGlobalMessageTimer; + const isTypingRef = useRef(false); // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly const isScaledDown = useMatchMedia('max-width: 768px'); @@ -192,6 +193,7 @@ export const InputBar = ({ text: textValue, onTypingChange: useCallback( isTyping => { + isTypingRef.current = isTyping; if (isTyping) { void conversationRepository.sendTypingStart(conversation); } else { @@ -567,7 +569,7 @@ export const InputBar = ({ loadDraftState={loadDraft} onShiftTab={onShiftTab} onSend={sendMessage} - onBlur={() => isTypingIndicatorEnabled && conversationRepository.sendTypingStop(conversation)} + onBlur={() => isTypingRef.current && conversationRepository.sendTypingStop(conversation)} > {isScaledDown ? ( <> From ed8d8a9bd539e7cefff4c4f2ce7f4b4cfe84f769 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 24 Oct 2023 09:46:59 +0200 Subject: [PATCH 07/86] refactor: move reaction event handling to storage middleware [WPB-4255] (#16092) --- .../conversation/ConversationRepository.ts | 72 ------------------- src/script/conversation/EventBuilder.ts | 1 + src/script/entity/message/ContentMessage.ts | 7 -- src/script/event/EventTypeHandling.ts | 1 + .../EventStorageMiddleware.ts | 12 ++-- .../eventHandlers/index.ts | 1 + .../reactionEventHandler.test.ts | 68 ++++++++++++++++++ .../eventHandlers/reactionEventHandler.ts | 47 ++++++++++++ .../EventStorageMiddleware/types.ts | 3 +- test/helper/EventGenerator.ts | 22 +++++- 10 files changed, 149 insertions(+), 85 deletions(-) create mode 100644 src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.test.ts create mode 100644 src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index c350143dc07..7700dbfd8c2 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -109,7 +109,6 @@ import { MemberLeaveEvent, MessageHiddenEvent, OneToOneCreationEvent, - ReactionEvent, TeamMemberLeaveEvent, } from '../conversation/EventBuilder'; import {Conversation} from '../entity/Conversation'; @@ -129,7 +128,6 @@ import {NOTIFICATION_HANDLING_STATE} from '../event/NotificationHandlingState'; import {isMemberMessage} from '../guards/Message'; import * as LegalHoldEvaluator from '../legal-hold/LegalHoldEvaluator'; import {MessageCategory} from '../message/MessageCategory'; -import {SuperType} from '../message/SuperType'; import {SystemMessageType} from '../message/SystemMessageType'; import {addOtherSelfClientsToMLSConversation} from '../mls'; import {PropertiesRepository} from '../properties/PropertiesRepository'; @@ -2326,9 +2324,6 @@ export class ConversationRepository { case ClientEvent.CONVERSATION.ONE2ONE_CREATION: return this.on1to1Creation(conversationEntity, eventJson); - case ClientEvent.CONVERSATION.REACTION: - return this.onReaction(conversationEntity, eventJson); - case CONVERSATION_EVENT.RECEIPT_MODE_UPDATE: return this.onReceiptModeChanged(conversationEntity, eventJson); @@ -2896,46 +2891,6 @@ export class ConversationRepository { } } - /** - * Someone reacted to a message. - * - * @param conversationEntity Conversation entity that a message was reacted upon in - * @param eventJson JSON data of 'conversation.reaction' event - * @returns Resolves when the event was handled - */ - private async onReaction(conversationEntity: Conversation, eventJson: ReactionEvent) { - const conversationId = conversationEntity.id; - const eventData = eventJson.data; - const messageId = eventData.message_id; - - try { - const messageEntity = await this.messageRepository.getMessageInConversationById(conversationEntity, messageId); - if (!messageEntity || !messageEntity.isContent()) { - const type = messageEntity ? messageEntity.type : 'unknown'; - - this.logger.error(`Cannot react to '${type}' message '${messageId}' in conversation '${conversationId}'`); - throw new ConversationError(ConversationError.TYPE.WRONG_TYPE, ConversationError.MESSAGE.WRONG_TYPE); - } - - const changes = messageEntity.getUpdatedReactions(eventJson); - if (changes) { - const logMessage = `Updating reactions of message '${messageId}' in conversation '${conversationId}'`; - this.logger.debug(logMessage, {changes, event: eventJson}); - - this.eventService.updateEventSequentially(messageEntity.primary_key, changes); - return await this.prepareReactionNotification(conversationEntity, messageEntity, eventJson); - } - } catch (error) { - const isNotFound = error.type === ConversationError.TYPE.MESSAGE_NOT_FOUND; - if (!isNotFound) { - const logMessage = `Failed to handle reaction to message '${messageId}' in conversation '${conversationId}'`; - this.logger.error(logMessage, error); - throw error; - } - } - return undefined; - } - private async onButtonActionConfirmation(conversationEntity: Conversation, eventJson: ButtonActionConfirmationEvent) { const {messageId, buttonId} = eventJson.data; try { @@ -3154,33 +3109,6 @@ export class ConversationRepository { } } - /** - * Forward the reaction event to the Notification repository for browser and audio notifications. - * - * @param conversationEntity Conversation that event was received in - * @param messageEntity Message that has been reacted upon - * @param eventJson JSON data of received reaction event - * @returns Resolves when the notification was prepared - */ - private async prepareReactionNotification( - conversationEntity: Conversation, - messageEntity: ContentMessage, - eventJson: ReactionEvent, - ) { - const {data: event_data, from} = eventJson; - - const messageFromSelf = messageEntity.from === this.userState.self().id; - if (messageFromSelf && event_data.reaction) { - const userEntity = await this.userRepository.getUserById({domain: messageEntity.fromDomain, id: from}); - const reactionMessageEntity = new Message(messageEntity.id, SuperType.REACTION); - reactionMessageEntity.user(userEntity); - reactionMessageEntity.reaction = event_data.reaction; - return {conversationEntity, messageEntity: reactionMessageEntity}; - } - - return {conversationEntity}; - } - private updateMessagesUserEntities(messageEntities: Message[], options: {localOnly?: boolean} = {}) { return Promise.all(messageEntities.map(messageEntity => this.updateMessageUserEntities(messageEntity, options))); } diff --git a/src/script/conversation/EventBuilder.ts b/src/script/conversation/EventBuilder.ts index 09524d1ec02..3f8ae775e92 100644 --- a/src/script/conversation/EventBuilder.ts +++ b/src/script/conversation/EventBuilder.ts @@ -167,6 +167,7 @@ export type MessageAddEvent = ConversationEvent< reactions?: UserReactionMap; edited_time?: string; status: StatusType; + version?: number; }; export type MissedEvent = BaseEvent & {id: string; type: CONVERSATION.MISSED_MESSAGES}; export type MLSConversationRecoveredEvent = BaseEvent & {id: string; type: CONVERSATION.MLS_CONVERSATION_RECOVERED}; diff --git a/src/script/entity/message/ContentMessage.ts b/src/script/entity/message/ContentMessage.ts index ec255863ca9..51e3796ade5 100644 --- a/src/script/entity/message/ContentMessage.ts +++ b/src/script/entity/message/ContentMessage.ts @@ -51,8 +51,6 @@ export class ContentMessage extends Message { // raw content of a file that was supposed to be sent but failed. Is undefined if the message has been successfully sent public readonly fileData: ko.Observable = ko.observable(); public readonly quote: ko.Observable; - // TODO: Rename to `reactionsUsers` - public readonly reactions_user_ids: ko.PureComputed; public readonly was_edited: ko.PureComputed; public replacing_message_id: null | string = null; readonly edited_timestamp: ko.Observable = ko.observable(null); @@ -65,11 +63,6 @@ export class ContentMessage extends Message { this.was_edited = ko.pureComputed(() => !!this.edited_timestamp()); this.reactions_user_ets = ko.observableArray(); - this.reactions_user_ids = ko.pureComputed(() => { - return this.reactions_user_ets() - .map(user_et => user_et.name()) - .join(', '); - }); this.quote = ko.observable(); this.readReceipts = ko.observableArray([]); diff --git a/src/script/event/EventTypeHandling.ts b/src/script/event/EventTypeHandling.ts index 89c8ec374c2..3c65fe51c90 100644 --- a/src/script/event/EventTypeHandling.ts +++ b/src/script/event/EventTypeHandling.ts @@ -53,6 +53,7 @@ export const EventTypeHandling = { ClientEvent.CONVERSATION.LEGAL_HOLD_UPDATE, ClientEvent.CONVERSATION.LOCATION, ClientEvent.CONVERSATION.MESSAGE_ADD, + ClientEvent.CONVERSATION.REACTION, ClientEvent.CONVERSATION.MISSED_MESSAGES, ClientEvent.CONVERSATION.MLS_CONVERSATION_RECOVERED, ClientEvent.CONVERSATION.ONE2ONE_CREATION, diff --git a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts index 66aa216cb49..0fa8f486e73 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts @@ -19,7 +19,7 @@ import {User} from 'src/script/entity/User'; -import {handleLinkPreviewEvent, handleEditEvent, handleAssetEvent} from './eventHandlers'; +import {handleLinkPreviewEvent, handleEditEvent, handleAssetEvent, handleReactionEvent} from './eventHandlers'; import {EventValidationError} from './eventHandlers/EventValidationError'; import {HandledEvents, DBOperation} from './types'; @@ -54,11 +54,11 @@ export class EventStorageMiddleware implements EventMiddleware { // Then ask the different handlers which DB operations to perform const operation = await this.getDbOperation(event, duplicateEvent); // And finally execute the operation - return operation ? this.execDBOperation(operation, event.conversation) : event; + return this.execDBOperation(operation, event.conversation); } - private async getDbOperation(event: HandledEvents, duplicateEvent?: HandledEvents): Promise { - const handlers = [handleEditEvent, handleLinkPreviewEvent, handleAssetEvent]; + private async getDbOperation(event: HandledEvents, duplicateEvent?: HandledEvents): Promise { + const handlers = [handleEditEvent, handleLinkPreviewEvent, handleAssetEvent, handleReactionEvent]; for (const handler of handlers) { const operation = await handler(event, { duplicateEvent, @@ -103,6 +103,10 @@ export class EventStorageMiddleware implements EventMiddleware { await this.eventService.replaceEvent(operation.updates); break; + case 'sequential-update': + await this.eventService.updateEventSequentially(operation.event.primary_key, operation.updates); + break; + case 'delete': await this.eventService.deleteEvent(conversationId, operation.id); } diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/index.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/index.ts index 469bf765b44..b3df744b674 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/index.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/index.ts @@ -20,3 +20,4 @@ export * from './editedEventHandler'; export * from './linkPreviewEventHandler'; export * from './assetEventHandler'; +export * from './reactionEventHandler'; diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.test.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.test.ts new file mode 100644 index 00000000000..2a8b6c40a90 --- /dev/null +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.test.ts @@ -0,0 +1,68 @@ +/* + * 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 {createMessageAddEvent, createReactionEvent, toSavedEvent} from 'test/helper/EventGenerator'; +import {createUuid} from 'Util/uuid'; + +import {handleReactionEvent} from './reactionEventHandler'; + +describe('reactionEventHandler', () => { + it('throws an error if the target message does not exist', async () => { + const operation = handleReactionEvent(createReactionEvent(createUuid(), '🫶'), { + findEvent: () => Promise.resolve(undefined), + selfUserId: createUuid(), + }); + + await expect(operation).rejects.toThrow('Reaction event to a non-existing message'); + }); + + it('successfully updates a message with new reactions when they arrive', async () => { + const baseReactions = {'first-user': '👍'}; + const baseVersion = 10; + const targetMessage = toSavedEvent( + createMessageAddEvent({overrides: {reactions: baseReactions, version: baseVersion}}), + ); + const reactionEvent = createReactionEvent(createUuid(), '🫶'); + + const operation: any = await handleReactionEvent(reactionEvent, { + findEvent: () => Promise.resolve(targetMessage), + selfUserId: createUuid(), + }); + + expect(operation.type).toBe('sequential-update'); + expect(operation.updates.reactions).toEqual({...baseReactions, [reactionEvent.from]: '🫶'}); + expect(operation.updates.version).toEqual(baseVersion + 1); + }); + + it('successfully deletes a reaction', async () => { + const reactor = createUuid(); + const baseReactions = {[reactor]: '👍'}; + const targetMessage = toSavedEvent(createMessageAddEvent({overrides: {reactions: baseReactions}})); + const reactionEvent = createReactionEvent(createUuid(), ''); + reactionEvent.from = reactor; + + const operation: any = await handleReactionEvent(reactionEvent, { + findEvent: () => Promise.resolve(targetMessage), + selfUserId: createUuid(), + }); + + expect(operation.type).toBe('sequential-update'); + expect(operation.updates.reactions).toEqual({...baseReactions, [reactor]: ''}); + }); +}); diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts new file mode 100644 index 00000000000..bedc473c122 --- /dev/null +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts @@ -0,0 +1,47 @@ +/* + * 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 {MessageAddEvent, ReactionEvent} from 'src/script/conversation/EventBuilder'; +import {StoredEvent} from 'src/script/storage'; + +import {EventValidationError} from './EventValidationError'; + +import {CONVERSATION} from '../../../Client'; +import {EventHandler} from '../types'; + +function computeEventUpdates(target: StoredEvent, reaction: ReactionEvent) { + const version = (target.version ?? 1) + 1; + const updatedReactions = {...target.reactions, [reaction.from]: reaction.data.reaction}; + return {reactions: updatedReactions, version: version}; +} + +export const handleReactionEvent: EventHandler = async (event, {findEvent}) => { + if (event.type !== CONVERSATION.REACTION) { + return undefined; + } + const targetEvent = (await findEvent(event.data.message_id)) as StoredEvent; + if (!targetEvent) { + throw new EventValidationError('Reaction event to a non-existing message'); + } + return { + type: 'sequential-update', + event: targetEvent, + updates: computeEventUpdates(targetEvent, event), + }; +}; diff --git a/src/script/event/preprocessor/EventStorageMiddleware/types.ts b/src/script/event/preprocessor/EventStorageMiddleware/types.ts index 66a79d03ef1..531ff5cfc65 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/types.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/types.ts @@ -27,6 +27,7 @@ import {IdentifiedUpdatePayload} from '../../EventService'; export type HandledEvents = ClientConversationEvent | ConversationEvent; export type DBOperation = | {type: 'update'; event: HandledEvents; updates: IdentifiedUpdatePayload} + | {type: 'sequential-update'; event: EventRecord; updates: Partial} | {type: 'delete'; event: HandledEvents; id: string} | {type: 'insert'; event: HandledEvents}; @@ -34,7 +35,7 @@ export type EventHandler = ( event: HandledEvents, optionals: { selfUserId: string; - duplicateEvent: HandledEvents | undefined; + duplicateEvent?: HandledEvents | undefined; findEvent: (eventId: string) => Promise; }, ) => Promise; diff --git a/test/helper/EventGenerator.ts b/test/helper/EventGenerator.ts index b27bfab3f30..496c0bd46a8 100644 --- a/test/helper/EventGenerator.ts +++ b/test/helper/EventGenerator.ts @@ -18,7 +18,13 @@ */ import {AssetTransferState} from 'src/script/assets/AssetTransferState'; -import {AssetAddEvent, DeleteEvent, EventBuilder, MessageAddEvent} from 'src/script/conversation/EventBuilder'; +import { + AssetAddEvent, + DeleteEvent, + EventBuilder, + MessageAddEvent, + ReactionEvent, +} from 'src/script/conversation/EventBuilder'; import {Conversation} from 'src/script/entity/Conversation'; import {CONVERSATION} from 'src/script/event/Client'; import {createUuid} from 'Util/uuid'; @@ -48,6 +54,20 @@ export function createMessageAddEvent({ }; } +export function createReactionEvent(targetMessageId: string, reaction: string = '👍'): ReactionEvent { + return { + conversation: createUuid(), + data: { + message_id: targetMessageId, + reaction, + }, + from: createUuid(), + id: createUuid(), + time: new Date().toISOString(), + type: CONVERSATION.REACTION, + }; +} + export function createDeleteEvent(deleteMessageId: string, conversationId: string = createUuid()): DeleteEvent { return { conversation: conversationId, From 6ca01ab65f283537dabe955e78d63e41b828848f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20G=C3=B3rka?= Date: Tue, 24 Oct 2023 09:58:02 +0200 Subject: [PATCH 08/86] runfix: improve is mls enabled check (#16094) --- package.json | 2 +- src/script/mls/isMLSSupportedByEnvironment.ts | 14 +---- yarn.lock | 58 +++++++++++-------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index c6614763af9..d3ad8b65349 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@peculiar/x509": "1.9.5", "@wireapp/avs": "9.5.2", "@wireapp/commons": "5.2.1", - "@wireapp/core": "42.17.0", + "@wireapp/core": "42.18.0", "@wireapp/lru-cache": "3.8.1", "@wireapp/react-ui-kit": "9.9.11", "@wireapp/store-engine-dexie": "2.1.6", diff --git a/src/script/mls/isMLSSupportedByEnvironment.ts b/src/script/mls/isMLSSupportedByEnvironment.ts index 4a2df21e2db..7dedf8382d7 100644 --- a/src/script/mls/isMLSSupportedByEnvironment.ts +++ b/src/script/mls/isMLSSupportedByEnvironment.ts @@ -34,17 +34,5 @@ export const isMLSSupportedByEnvironment = async () => { } const apiClient = container.resolve(APIClient); - const isMLSEnabledOnBackend = apiClient.backendFeatures.supportsMLS; - - if (!isMLSEnabledOnBackend) { - return false; - } - - let isBackendRemovalKeyPresent = false; - try { - const backendRemovalKey = (await apiClient.api.client.getPublicKeys()).removal; - isBackendRemovalKeyPresent = !!backendRemovalKey; - } catch {} - - return isBackendRemovalKeyPresent; + return apiClient.supportsMLS(); }; diff --git a/yarn.lock b/yarn.lock index 77abeed1cbd..0c4a704a0a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5295,11 +5295,11 @@ __metadata: languageName: node linkType: hard -"@wireapp/api-client@npm:^26.4.0": - version: 26.4.0 - resolution: "@wireapp/api-client@npm:26.4.0" +"@wireapp/api-client@npm:^26.5.0": + version: 26.5.0 + resolution: "@wireapp/api-client@npm:26.5.0" dependencies: - "@wireapp/commons": ^5.2.1 + "@wireapp/commons": ^5.2.2 "@wireapp/priority-queue": ^2.1.4 "@wireapp/protocol-messaging": 1.44.0 axios: 1.5.1 @@ -5312,7 +5312,7 @@ __metadata: tough-cookie: 4.1.3 ws: 8.14.2 zod: 3.22.4 - checksum: 900233b8ec83803ade60e5ec4121ef21103be33e6587a6065bd82713d0b235b010c89db59be58c50711175bd7518584477adcb06f05ce67ef54e722e3fcb5f1b + checksum: 32875d4e0b6dfdde296fc912572d98d2f19fc2d2ef4a8a3c28308604e850b39ffbcefca34a17c45213ec477e7c134a42098b431ccc143e1c1efe738dbc2eb5d6 languageName: node linkType: hard @@ -5330,7 +5330,7 @@ __metadata: languageName: node linkType: hard -"@wireapp/commons@npm:5.2.1, @wireapp/commons@npm:^5.2.1": +"@wireapp/commons@npm:5.2.1": version: 5.2.1 resolution: "@wireapp/commons@npm:5.2.1" dependencies: @@ -5342,6 +5342,18 @@ __metadata: languageName: node linkType: hard +"@wireapp/commons@npm:^5.2.2": + version: 5.2.2 + resolution: "@wireapp/commons@npm:5.2.2" + dependencies: + ansi-regex: 5.0.1 + fs-extra: 11.1.0 + logdown: 3.3.1 + platform: 1.3.6 + checksum: ae78630f8299eaae9ee136136981dabdcb4c100c43ec1430882fc154ae21e9fdb17999c1b892140ca5547625e7f502ba83e85ecf62312c8525098865032b6928 + languageName: node + linkType: hard + "@wireapp/copy-config@npm:2.1.9": version: 2.1.9 resolution: "@wireapp/copy-config@npm:2.1.9" @@ -5365,20 +5377,20 @@ __metadata: languageName: node linkType: hard -"@wireapp/core@npm:42.17.0": - version: 42.17.0 - resolution: "@wireapp/core@npm:42.17.0" +"@wireapp/core@npm:42.18.0": + version: 42.18.0 + resolution: "@wireapp/core@npm:42.18.0" dependencies: - "@wireapp/api-client": ^26.4.0 - "@wireapp/commons": ^5.2.1 + "@wireapp/api-client": ^26.5.0 + "@wireapp/commons": ^5.2.2 "@wireapp/core-crypto": 1.0.0-rc.13 "@wireapp/cryptobox": 12.8.0 - "@wireapp/promise-queue": ^2.2.6 + "@wireapp/promise-queue": ^2.2.7 "@wireapp/protocol-messaging": 1.44.0 "@wireapp/store-engine": 5.1.4 "@wireapp/store-engine-dexie": ^2.1.6 axios: 1.5.1 - bazinga64: ^6.3.1 + bazinga64: ^6.3.2 deepmerge-ts: 5.1.0 hash.js: 1.1.7 http-status-codes: 2.3.0 @@ -5387,7 +5399,7 @@ __metadata: long: ^5.2.0 uuidjs: 4.2.13 zod: 3.22.4 - checksum: 97176143fbab1c36abb7cb0ec6372347b108729cb49d43c8d332c11bba023b3ccbb49806d6edaea1a116f27e3e34ed7c53ade665e3e59fd0552129c2c10cb4eb + checksum: 348ea24ff583893ec1a229d0eca09b7963d37727bb31fd358ad7d19e8c8f532c7330a4cf27e4952744bad9431026c53bc090adf8eb357e83973af616b33fc347 languageName: node linkType: hard @@ -5470,10 +5482,10 @@ __metadata: languageName: node linkType: hard -"@wireapp/promise-queue@npm:^2.2.6": - version: 2.2.6 - resolution: "@wireapp/promise-queue@npm:2.2.6" - checksum: 6de05205a44b62dea38b6a0c00ec8d8f9bd96c98d8e4fa6cf711a9fb5cf5fd39f818432d3c676649e1d5cb638fe9386b24cc098b4514c378d995295ed2f8f3d6 +"@wireapp/promise-queue@npm:^2.2.7": + version: 2.2.7 + resolution: "@wireapp/promise-queue@npm:2.2.7" + checksum: 0e607b00325fa702c9222da4e424e31d46ba456e0b8d6af795062f79e3c774581f2e9da9860a8123a7315c510e7da09850030336e356965a5429721a9bbc7976 languageName: node linkType: hard @@ -6471,10 +6483,10 @@ __metadata: languageName: node linkType: hard -"bazinga64@npm:^6.3.1": - version: 6.3.1 - resolution: "bazinga64@npm:6.3.1" - checksum: f720936b3919f8df3b903675a9557f36b5a959ec458d7536756b805a9a1e3c81d5d6a367adb0245b80e1ef870cd5f94491ac9566738016b5613b5e5e3bccce6e +"bazinga64@npm:^6.3.2": + version: 6.3.2 + resolution: "bazinga64@npm:6.3.2" + checksum: 5865c96c45120da1fc8728ef0c464b2cac85ecb955bd173912d6201ad3b96eaab9c4b1d81659e21a983c0fcaf6d6b91948499f917fc616258a032a0f31a7d16d languageName: node linkType: hard @@ -18523,7 +18535,7 @@ __metadata: "@wireapp/avs": 9.5.2 "@wireapp/commons": 5.2.1 "@wireapp/copy-config": 2.1.9 - "@wireapp/core": 42.17.0 + "@wireapp/core": 42.18.0 "@wireapp/eslint-config": 3.0.4 "@wireapp/lru-cache": 3.8.1 "@wireapp/prettier-config": 0.6.3 From 62096e8bc25b28d1fd0ff8f8ac28c353d9074c76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Oct 2023 08:15:04 +0000 Subject: [PATCH 09/86] chore(deps-dev): Bump @types/markdown-it from 13.0.4 to 13.0.5 (#16095) Bumps [@types/markdown-it](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/markdown-it) from 13.0.4 to 13.0.5. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/markdown-it) --- updated-dependencies: - dependency-name: "@types/markdown-it" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d3ad8b65349..973a7dc8906 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@types/libsodium-wrappers": "^0", "@types/linkify-it": "3.0.4", "@types/loadable__component": "^5", - "@types/markdown-it": "13.0.4", + "@types/markdown-it": "13.0.5", "@types/node": "^20.8.7", "@types/open-graph": "0.2.4", "@types/platform": "1.3.5", diff --git a/yarn.lock b/yarn.lock index 0c4a704a0a2..7ed8e077037 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4614,13 +4614,13 @@ __metadata: languageName: node linkType: hard -"@types/markdown-it@npm:13.0.4": - version: 13.0.4 - resolution: "@types/markdown-it@npm:13.0.4" +"@types/markdown-it@npm:13.0.5": + version: 13.0.5 + resolution: "@types/markdown-it@npm:13.0.5" dependencies: "@types/linkify-it": "*" "@types/mdurl": "*" - checksum: 0af9c349467599f984e8faf548144e5c68ca8926d45e155cbc622897290fadb1fd31fced8194a2a1095406805dd21719d2bc74d8dc0295581d0eed771a4fd58b + checksum: 3c87efe8e24f77dc9aa1962b8f18d37530b63e33ab363653e568816de6d5b6185a65690da729e2757ba370a3499635da6b4fc4d9a99df9d85959d8c64c7fe8e9 languageName: node linkType: hard @@ -18518,7 +18518,7 @@ __metadata: "@types/libsodium-wrappers": ^0 "@types/linkify-it": 3.0.4 "@types/loadable__component": ^5 - "@types/markdown-it": 13.0.4 + "@types/markdown-it": 13.0.5 "@types/node": ^20.8.7 "@types/open-graph": 0.2.4 "@types/platform": 1.3.5 From d22b0809a56baba81414c3324807fd706a39fd18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Oct 2023 08:18:42 +0000 Subject: [PATCH 10/86] chore(deps-dev): Bump @wireapp/copy-config from 2.1.9 to 2.1.10 (#16097) Bumps [@wireapp/copy-config](https://github.com/wireapp/wire-web-packages) from 2.1.9 to 2.1.10. - [Commits](https://github.com/wireapp/wire-web-packages/compare/@wireapp/copy-config@2.1.9...@wireapp/copy-config@2.1.10) --- updated-dependencies: - dependency-name: "@wireapp/copy-config" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 973a7dc8906..fdbb120df49 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@types/speakingurl": "13.0.5", "@types/underscore": "1.11.12", "@types/webpack-env": "1.18.3", - "@wireapp/copy-config": "2.1.9", + "@wireapp/copy-config": "2.1.10", "@wireapp/eslint-config": "3.0.4", "@wireapp/prettier-config": "0.6.3", "@wireapp/store-engine": "^5.1.4", diff --git a/yarn.lock b/yarn.lock index 7ed8e077037..e8371bd0162 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5354,9 +5354,9 @@ __metadata: languageName: node linkType: hard -"@wireapp/copy-config@npm:2.1.9": - version: 2.1.9 - resolution: "@wireapp/copy-config@npm:2.1.9" +"@wireapp/copy-config@npm:2.1.10": + version: 2.1.10 + resolution: "@wireapp/copy-config@npm:2.1.10" dependencies: axios: 1.5.1 copy: 0.3.2 @@ -5366,7 +5366,7 @@ __metadata: logdown: 3.3.1 bin: copy-config: lib/cli.js - checksum: 37ee9d82c6eabc33573ede5c0d9e467c2d6c3dd8f0c2717378ef8c4b84b568613d64e33f4f2903b9d4d612b25ea40f22881e0f44b51cd4f72185f0606294a168 + checksum: 5b7fa44b067c51dc567d29ebcb9e2fa87cd5464fb4bb5fcef93d4fa5596cf729d1549f0c52f2a9241f0e415678af56765925b8329f49c7efe6e93dcc97bdf1b2 languageName: node linkType: hard @@ -18534,7 +18534,7 @@ __metadata: "@types/webpack-env": 1.18.3 "@wireapp/avs": 9.5.2 "@wireapp/commons": 5.2.1 - "@wireapp/copy-config": 2.1.9 + "@wireapp/copy-config": 2.1.10 "@wireapp/core": 42.18.0 "@wireapp/eslint-config": 3.0.4 "@wireapp/lru-cache": 3.8.1 From 9aa0ac0c7bf8218c2c260dc2bfaffea097a56a3f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Oct 2023 08:22:26 +0000 Subject: [PATCH 11/86] chore(deps): Bump emoji-picker-react from 4.5.3 to 4.5.5 (#16099) Bumps [emoji-picker-react](https://github.com/ealush/emoji-picker-react) from 4.5.3 to 4.5.5. - [Release notes](https://github.com/ealush/emoji-picker-react/releases) - [Commits](https://github.com/ealush/emoji-picker-react/commits) --- updated-dependencies: - dependency-name: emoji-picker-react dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 19 +++++-------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index fdbb120df49..6881da29e75 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "countly-sdk-web": "23.6.2", "date-fns": "2.30.0", "dexie-batch": "0.4.3", - "emoji-picker-react": "4.5.3", + "emoji-picker-react": "4.5.5", "highlight.js": "11.9.0", "http-status-codes": "2.3.0", "jimp": "0.22.10", diff --git a/yarn.lock b/yarn.lock index e8371bd0162..dae8e61e7c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6988,13 +6988,6 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.2.1": - version: 1.2.1 - resolution: "clsx@npm:1.2.1" - checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 - languageName: node - linkType: hard - "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -8330,14 +8323,12 @@ __metadata: languageName: node linkType: hard -"emoji-picker-react@npm:4.5.3": - version: 4.5.3 - resolution: "emoji-picker-react@npm:4.5.3" - dependencies: - clsx: ^1.2.1 +"emoji-picker-react@npm:4.5.5": + version: 4.5.5 + resolution: "emoji-picker-react@npm:4.5.5" peerDependencies: react: ">=16" - checksum: 79681d0e4f3a733c9fa715559b8e3e6c20810e745844718d7072d909f9e5eb2870ba782df5d23d74e0b3ce6d733bbe045b42bf4b27bf1cfc0f5ecfbc971cfe61 + checksum: 9413fcb6bce335047ef514ee1aaf14285836dc5ddd80224f290cf08da0a1e4d0e2871adde2df4a5eef1c32168ebcc1f6a1dc3cf4916f2d9dbcd311a909fd454e languageName: node linkType: hard @@ -18564,7 +18555,7 @@ __metadata: dexie-batch: 0.4.3 dotenv: 16.3.1 dpdm: 3.14.0 - emoji-picker-react: 4.5.3 + emoji-picker-react: 4.5.5 eslint: ^8.52.0 eslint-plugin-prettier: ^5.0.1 fake-indexeddb: 4.0.2 From 6809a25726f5b63398201656359ee56b1fe57545 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Oct 2023 08:25:12 +0000 Subject: [PATCH 12/86] chore(deps-dev): Bump @types/node from 20.8.7 to 20.8.8 (#16096) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.8.7 to 20.8.8. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 6881da29e75..a77d6542a8d 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@types/linkify-it": "3.0.4", "@types/loadable__component": "^5", "@types/markdown-it": "13.0.5", - "@types/node": "^20.8.7", + "@types/node": "^20.8.8", "@types/open-graph": "0.2.4", "@types/platform": "1.3.5", "@types/react": "18.2.28", diff --git a/yarn.lock b/yarn.lock index dae8e61e7c2..7a0f4ec2671 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4671,12 +4671,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.8.7": - version: 20.8.7 - resolution: "@types/node@npm:20.8.7" +"@types/node@npm:^20.8.8": + version: 20.8.8 + resolution: "@types/node@npm:20.8.8" dependencies: undici-types: ~5.25.1 - checksum: 2173c0c03daefcb60c03a61b1371b28c8fe412e7a40dc6646458b809d14a85fbc7aeb369d957d57f0aaaafd99964e77436f29b3b579232d8f2b20c58abbd1d25 + checksum: 028a9606e4ef594a4bc7b3310596499d7ce01b2e30f4d1d906ad8ec30c24cea7ec1b3dc181dd5df8d8d2bfe8de54bf3e28ae93be174b4c7d81c0db8326e4f35c languageName: node linkType: hard @@ -18510,7 +18510,7 @@ __metadata: "@types/linkify-it": 3.0.4 "@types/loadable__component": ^5 "@types/markdown-it": 13.0.5 - "@types/node": ^20.8.7 + "@types/node": ^20.8.8 "@types/open-graph": 0.2.4 "@types/platform": 1.3.5 "@types/react": 18.2.28 From 78e123dd9e3ebed96632f145fbbd8fbceb33b010 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Oct 2023 08:27:30 +0000 Subject: [PATCH 13/86] chore(deps): Bump @wireapp/react-ui-kit from 9.9.11 to 9.9.12 (#16100) Bumps [@wireapp/react-ui-kit](https://github.com/wireapp/wire-web-packages) from 9.9.11 to 9.9.12. - [Commits](https://github.com/wireapp/wire-web-packages/compare/@wireapp/react-ui-kit@9.9.11...@wireapp/react-ui-kit@9.9.12) --- updated-dependencies: - dependency-name: "@wireapp/react-ui-kit" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index a77d6542a8d..e392c5c20b0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "@wireapp/commons": "5.2.1", "@wireapp/core": "42.18.0", "@wireapp/lru-cache": "3.8.1", - "@wireapp/react-ui-kit": "9.9.11", + "@wireapp/react-ui-kit": "9.9.12", "@wireapp/store-engine-dexie": "2.1.6", "@wireapp/store-engine-sqleet": "1.8.9", "@wireapp/webapp-events": "0.18.3", diff --git a/yarn.lock b/yarn.lock index 7a0f4ec2671..24ed18aa6c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4361,12 +4361,12 @@ __metadata: languageName: node linkType: hard -"@types/color@npm:3.0.4": - version: 3.0.4 - resolution: "@types/color@npm:3.0.4" +"@types/color@npm:3.0.5": + version: 3.0.5 + resolution: "@types/color@npm:3.0.5" dependencies: "@types/color-convert": "*" - checksum: 46935626ddeb8ae8877488b1f8f2f71b17a529c113673301a1dd7a64d917cb7a748545ac34ed30c188d66a2cd09c33abc24c87bd5b3a80d520cf1f35aaa2e476 + checksum: 82cce7cb132b5c0898dd69b92a4663e34ad8f19db6a009bef7ef79d4840ddb4d5f7e793e2e64e3b4a60da92dc2ae33d1aa498981d0fcf0562917c9550603a99d languageName: node linkType: hard @@ -5513,11 +5513,11 @@ __metadata: languageName: node linkType: hard -"@wireapp/react-ui-kit@npm:9.9.11": - version: 9.9.11 - resolution: "@wireapp/react-ui-kit@npm:9.9.11" +"@wireapp/react-ui-kit@npm:9.9.12": + version: 9.9.12 + resolution: "@wireapp/react-ui-kit@npm:9.9.12" dependencies: - "@types/color": 3.0.4 + "@types/color": 3.0.5 color: 4.2.3 emotion-normalize: 11.0.1 react-select: 5.7.5 @@ -5530,7 +5530,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 2bb5023b171b9b5da5084b783b8a55221f971b60acd8f7f8770f81578dd2edfa05ed8854c856235f0648231278762ac977305d602a953f31c89e762abb396b85 + checksum: 4129c63b988ccd9471177f3cc2c73d7dff161e0a9f3ebe85e431adf982e5f2a3ddb0cb11f7b66821a39512cddafcb2a99b30b6ada9aedc74bd56e1da0dbf17b5 languageName: node linkType: hard @@ -18530,7 +18530,7 @@ __metadata: "@wireapp/eslint-config": 3.0.4 "@wireapp/lru-cache": 3.8.1 "@wireapp/prettier-config": 0.6.3 - "@wireapp/react-ui-kit": 9.9.11 + "@wireapp/react-ui-kit": 9.9.12 "@wireapp/store-engine": ^5.1.4 "@wireapp/store-engine-dexie": 2.1.6 "@wireapp/store-engine-sqleet": 1.8.9 From 7a42dd63cb54a3ee37aee4893c6b78978c7675ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Oct 2023 08:27:59 +0000 Subject: [PATCH 14/86] chore(deps): Bump @wireapp/commons from 5.2.1 to 5.2.2 (#16098) Bumps [@wireapp/commons](https://github.com/wireapp/wire-web-packages) from 5.2.1 to 5.2.2. - [Commits](https://github.com/wireapp/wire-web-packages/compare/@wireapp/commons@5.2.1...@wireapp/commons@5.2.2) --- updated-dependencies: - dependency-name: "@wireapp/commons" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index e392c5c20b0..a974ff3c704 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "@lexical/react": "0.12.2", "@peculiar/x509": "1.9.5", "@wireapp/avs": "9.5.2", - "@wireapp/commons": "5.2.1", + "@wireapp/commons": "5.2.2", "@wireapp/core": "42.18.0", "@wireapp/lru-cache": "3.8.1", "@wireapp/react-ui-kit": "9.9.12", diff --git a/yarn.lock b/yarn.lock index 24ed18aa6c2..20286e269d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5330,19 +5330,7 @@ __metadata: languageName: node linkType: hard -"@wireapp/commons@npm:5.2.1": - version: 5.2.1 - resolution: "@wireapp/commons@npm:5.2.1" - dependencies: - ansi-regex: 5.0.1 - fs-extra: 11.1.0 - logdown: 3.3.1 - platform: 1.3.6 - checksum: 1510b705a40d45ceaf07b12b5a199d94fe977d3b2faaafc298ff167a65b820471f5863f9f93f27d2003f9f44ee3401423d6e12bb38ecd7808f8b2fc72821d411 - languageName: node - linkType: hard - -"@wireapp/commons@npm:^5.2.2": +"@wireapp/commons@npm:5.2.2, @wireapp/commons@npm:^5.2.2": version: 5.2.2 resolution: "@wireapp/commons@npm:5.2.2" dependencies: @@ -18524,7 +18512,7 @@ __metadata: "@types/underscore": 1.11.12 "@types/webpack-env": 1.18.3 "@wireapp/avs": 9.5.2 - "@wireapp/commons": 5.2.1 + "@wireapp/commons": 5.2.2 "@wireapp/copy-config": 2.1.10 "@wireapp/core": 42.18.0 "@wireapp/eslint-config": 3.0.4 From 26bc2d00b7e8c48251e68ee8d59d388fc7558cf6 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 24 Oct 2023 14:43:12 +0200 Subject: [PATCH 15/86] feat: Sort user reactions chronologically [WPB-4253] (#16101) --- .../Message/ContentMessage/ContentMessage.tsx | 20 +-- .../MessageActions/MessageActions.test.tsx | 1 - .../MessageActions/MessageActions.tsx | 3 - .../MessageReactions.test.tsx | 2 - .../MessageReactions/MessageReactions.tsx | 134 ++++++++---------- .../MessageReactionsList.test.tsx | 19 ++- .../MessageReactions/MessageReactionsList.tsx | 23 ++- .../MessagesList/Message/MessageWrapper.tsx | 2 +- .../DetailViewModal/DetailViewModalFooter.tsx | 3 +- src/script/components/UserList/UserList.tsx | 4 +- .../conversation/ConversationRepository.ts | 14 -- src/script/conversation/EventBuilder.ts | 4 +- src/script/conversation/EventMapper.ts | 7 +- .../conversation/MessageRepository.test.ts | 66 ++++----- src/script/conversation/MessageRepository.ts | 32 ++--- src/script/entity/message/ContentMessage.ts | 65 +-------- .../reactionEventHandler.test.ts | 57 ++++---- .../eventHandlers/reactionEventHandler.ts | 15 +- .../MessageDetails/MessageDetails.test.tsx | 4 +- .../MessageDetails/MessageDetails.tsx | 134 +++--------------- .../MessageDetails/UserReactions.tsx | 73 ++++++++++ src/script/page/RightSidebar/RightSidebar.tsx | 1 - src/script/storage/record/EventRecord.ts | 4 +- src/script/user/UserRepository.ts | 4 + src/script/util/ReactionUtil.test.ts | 114 +++++++++++++++ src/script/util/ReactionUtil.ts | 72 ++++++---- test/helper/UserGenerator.ts | 7 + 27 files changed, 452 insertions(+), 432 deletions(-) create mode 100644 src/script/page/RightSidebar/MessageDetails/UserReactions.tsx create mode 100644 src/script/util/ReactionUtil.test.ts diff --git a/src/script/components/MessagesList/Message/ContentMessage/ContentMessage.tsx b/src/script/components/MessagesList/Message/ContentMessage/ContentMessage.tsx index da96641e786..8471a36ca37 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/ContentMessage.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/ContentMessage.tsx @@ -29,7 +29,6 @@ import {useRelativeTimestamp} from 'src/script/hooks/useRelativeTimestamp'; import {StatusType} from 'src/script/message/StatusType'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {getMessageAriaLabel} from 'Util/conversationMessages'; -import {groupByReactionUsers} from 'Util/ReactionUtil'; import {ContentAsset} from './asset'; import {MessageActionsMenu} from './MessageActions/MessageActions'; @@ -65,7 +64,7 @@ export interface ContentMessageProps extends Omit void; } -const ContentMessageComponent: React.FC = ({ +export const ContentMessageComponent: React.FC = ({ conversation, message, findMessage, @@ -102,18 +101,19 @@ const ContentMessageComponent: React.FC = ({ reactions, status, user, + quote, } = useKoSubscribableChildren(message, [ 'senderName', 'timestamp', 'ephemeral_caption', 'ephemeral_status', 'assets', - 'other_likes', 'was_edited', 'failedToSend', 'reactions', 'status', 'user', + 'quote', ]); const shouldShowAvatar = (): boolean => { @@ -140,9 +140,6 @@ const ContentMessageComponent: React.FC = ({ setActionMenuVisibility(isMessageFocused || msgFocusState); }, [msgFocusState, isMessageFocused]); - const reactionGroupedByUser = groupByReactionUsers(reactions); - const reactionsTotalCount = Array.from(reactionGroupedByUser).length; - return (
= ({
)} - {message.quote() && ( + {quote && ( = ({ handleActionMenuVisibility={setActionMenuVisibility} contextMenu={contextMenu} isMessageFocused={msgFocusState} - messageWithSection={hasMarker} handleReactionClick={onClickReaction} - reactionsTotalCount={reactionsTotalCount} + reactionsTotalCount={reactions.length} isRemovedFromConversation={conversation.removed_from_conversation()} /> )} @@ -253,7 +249,7 @@ const ContentMessageComponent: React.FC = ({ onClickReactionDetails(message)} @@ -263,5 +259,3 @@ const ContentMessageComponent: React.FC = ({ ); }; - -export {ContentMessageComponent}; diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.test.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.test.tsx index bec180349a6..f6c8ecf343a 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.test.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.test.tsx @@ -31,7 +31,6 @@ const defaultProps: MessageActionsMenuProps = { contextMenu: {entries: ko.observable([{label: 'option1', text: 'option1'}])}, isMessageFocused: true, handleActionMenuVisibility: jest.fn(), - messageWithSection: false, handleReactionClick: jest.fn(), reactionsTotalCount: 0, isRemovedFromConversation: false, diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.tsx index cd8299b1d42..272cfaa4ee6 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.tsx @@ -59,7 +59,6 @@ export interface MessageActionsMenuProps { contextMenu: {entries: ko.Subscribable}; isMessageFocused: boolean; handleActionMenuVisibility: (isVisible: boolean) => void; - messageWithSection: boolean; handleReactionClick: (emoji: string) => void; reactionsTotalCount: number; isRemovedFromConversation: boolean; @@ -71,7 +70,6 @@ const MessageActionsMenu: FC = ({ isMessageFocused, handleActionMenuVisibility, message, - messageWithSection, handleReactionClick, reactionsTotalCount, isRemovedFromConversation, @@ -181,7 +179,6 @@ const MessageActionsMenu: FC = ({ handleKeyDown={handleKeyDown} resetActionMenuStates={resetActionMenuStates} wrapperRef={wrapperRef} - message={message} handleReactionClick={handleReactionClick} /> {message.isReplyable() && ( diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.test.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.test.tsx index cbc9d7841e6..c828a2e9035 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.test.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.test.tsx @@ -21,7 +21,6 @@ import React from 'react'; import {render, act, fireEvent, waitFor} from '@testing-library/react'; -import {ContentMessage} from 'src/script/entity/message/ContentMessage'; import {t} from 'Util/LocalizerUtil'; import {MessageReactions, MessageReactionsProps} from './MessageReactions'; @@ -32,7 +31,6 @@ const thumbsUpEmoji = '👍'; const likeEmoji = '❤️'; const wrapperRef = React.createRef(); const defaultProps: MessageReactionsProps = { - message: new ContentMessage(), handleReactionClick: jest.fn(), messageFocusedTabIndex: 0, currentMsgActionName: '', diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.tsx index 2d613020179..cb296eaa04d 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.tsx @@ -17,9 +17,8 @@ * */ -import {useState, useCallback, RefObject, FC, useRef} from 'react'; +import {useState, RefObject, FC, useRef} from 'react'; -import {ContentMessage} from 'src/script/entity/message/ContentMessage'; import {KEY} from 'Util/KeyboardUtil'; import {t} from 'Util/LocalizerUtil'; @@ -46,7 +45,6 @@ export interface MessageReactionsProps { handleCurrentMsgAction: (actionName: string) => void; resetActionMenuStates: () => void; wrapperRef: RefObject; - message: ContentMessage; handleReactionClick: (emoji: string) => void; } @@ -58,7 +56,6 @@ const MessageReactions: FC = ({ handleKeyDown, resetActionMenuStates, wrapperRef, - message, handleReactionClick, }) => { const isThumbUpAction = currentMsgActionName === MessageActionsId.THUMBSUP; @@ -84,20 +81,30 @@ const MessageReactions: FC = ({ setShowEmojis(false); }; - const handleReactionCurrentState = useCallback( - (actionName = '') => { - const isActive = !!actionName; - handleCurrentMsgAction(actionName); - handleMenuOpen(isActive); - setShowEmojis(isActive); - }, - [handleCurrentMsgAction, handleMenuOpen], - ); + const handleReactionCurrentState = (actionName = '') => { + const isActive = !!actionName; + handleCurrentMsgAction(actionName); + handleMenuOpen(isActive); + setShowEmojis(isActive); + }; + + const handleEmojiBtnClick = (event: React.MouseEvent) => { + event.stopPropagation(); + const selectedMsgActionName = event.currentTarget.dataset.uieName; + if (currentMsgActionName === selectedMsgActionName) { + // reset on double click + handleReactionCurrentState(''); + } else if (selectedMsgActionName) { + handleReactionCurrentState(selectedMsgActionName); + showReactions(event.currentTarget.getBoundingClientRect()); + } + }; - const handleEmojiBtnClick = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - const selectedMsgActionName = event.currentTarget.dataset.uieName; + const handleEmojiKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + const selectedMsgActionName = event.currentTarget.dataset.uieName; + handleKeyDown(event); + if ([KEY.SPACE, KEY.ENTER].includes(event.key)) { if (currentMsgActionName === selectedMsgActionName) { // reset on double click handleReactionCurrentState(''); @@ -105,72 +112,47 @@ const MessageReactions: FC = ({ handleReactionCurrentState(selectedMsgActionName); showReactions(event.currentTarget.getBoundingClientRect()); } - }, - [currentMsgActionName, handleReactionCurrentState], - ); - - const handleEmojiKeyDown = useCallback( - (event: React.KeyboardEvent) => { - event.stopPropagation(); - const selectedMsgActionName = event.currentTarget.dataset.uieName; - handleKeyDown(event); - if ([KEY.SPACE, KEY.ENTER].includes(event.key)) { - if (currentMsgActionName === selectedMsgActionName) { - // reset on double click - handleReactionCurrentState(''); - } else if (selectedMsgActionName) { - handleReactionCurrentState(selectedMsgActionName); - showReactions(event.currentTarget.getBoundingClientRect()); - } - } - }, - [currentMsgActionName, handleKeyDown, handleReactionCurrentState], - ); + } + }; const showReactions = (rect: DOMRect) => { setPOSX(rect.x); setPOSY(rect.y); }; - const handleMsgActionClick = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - const actionType = event.currentTarget.dataset.uieName; - switch (actionType) { - case MessageActionsId.EMOJI: - handleEmojiBtnClick(event); - break; - case MessageActionsId.THUMBSUP: - toggleActiveMenu(event); - handleReactionClick(thumbsUpEmoji); - break; - case MessageActionsId.HEART: - toggleActiveMenu(event); - handleReactionClick(likeEmoji); - break; - } - }, - [handleEmojiBtnClick, handleReactionClick, toggleActiveMenu], - ); + const handleMsgActionClick = (event: React.MouseEvent) => { + event.stopPropagation(); + const actionType = event.currentTarget.dataset.uieName; + switch (actionType) { + case MessageActionsId.EMOJI: + handleEmojiBtnClick(event); + break; + case MessageActionsId.THUMBSUP: + toggleActiveMenu(event); + handleReactionClick(thumbsUpEmoji); + break; + case MessageActionsId.HEART: + toggleActiveMenu(event); + handleReactionClick(likeEmoji); + break; + } + }; - const handleMsgActionKeyDown = useCallback( - (event: React.KeyboardEvent) => { - event.stopPropagation(); - const actionType = event.currentTarget.dataset.uieName; - switch (actionType) { - case MessageActionsId.EMOJI: - handleEmojiKeyDown(event); - break; - case MessageActionsId.THUMBSUP: - handleKeyDown(event); - break; - case MessageActionsId.HEART: - handleKeyDown(event); - break; - } - }, - [handleEmojiKeyDown, handleKeyDown], - ); + const handleMsgActionKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + const actionType = event.currentTarget.dataset.uieName; + switch (actionType) { + case MessageActionsId.EMOJI: + handleEmojiKeyDown(event); + break; + case MessageActionsId.THUMBSUP: + handleKeyDown(event); + break; + case MessageActionsId.HEART: + handleKeyDown(event); + break; + } + }; return ( <> diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.test.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.test.tsx index 19b1a74d45d..0bd8c913920 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.test.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.test.tsx @@ -20,15 +20,20 @@ import {render, fireEvent, within} from '@testing-library/react'; import {withTheme} from 'src/script/auth/util/test/TestUtil'; -import {createUuid} from 'Util/uuid'; +import {ReactionMap} from 'src/script/storage'; +import {generateQualifiedId} from 'test/helper/UserGenerator'; import {MessageReactionsList, MessageReactionsListProps} from './MessageReactionsList'; -const reactions = { - '1': '😇,😊', - '2': '😊,👍,😉,😇', - '3': '😇', -}; +const user1 = generateQualifiedId(); +const user2 = generateQualifiedId(); +const user3 = generateQualifiedId(); +const reactions: ReactionMap = [ + ['😇', [user1, user2, user3]], + ['😊', [user1, user2]], + ['👍', [user2]], + ['😉', [user2]], +]; const defaultProps: MessageReactionsListProps = { reactions: reactions, @@ -37,7 +42,7 @@ const defaultProps: MessageReactionsListProps = { isMessageFocused: false, onLastReactionKeyEvent: jest.fn(), isRemovedFromConversation: false, - userId: createUuid(), + selfUserId: generateQualifiedId(), }; describe('MessageReactionsList', () => { diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.tsx index 4c3b49442cf..5a2b95d7897 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.tsx @@ -19,16 +19,19 @@ import {FC} from 'react'; +import type {QualifiedId} from '@wireapp/api-client/lib/user/'; + +import {ReactionMap} from 'src/script/storage'; import {getEmojiUnicode} from 'Util/EmojiUtil'; -import {Reactions, groupByReactionUsers, sortReactionsByUserCount} from 'Util/ReactionUtil'; +import {matchQualifiedIds} from 'Util/QualifiedId'; import {EmojiPill} from './EmojiPill'; import {messageReactionWrapper} from './MessageReactions.styles'; export interface MessageReactionsListProps { - reactions: Reactions; + reactions: ReactionMap; handleReactionClick: (emoji: string) => void; - userId: string; + selfUserId: QualifiedId; isMessageFocused: boolean; onTooltipReactionCountClick: () => void; onLastReactionKeyEvent: () => void; @@ -36,20 +39,14 @@ export interface MessageReactionsListProps { } const MessageReactionsList: FC = ({reactions, ...props}) => { - const reactionGroupedByUser = groupByReactionUsers(reactions); - const reactionsGroupedByUserArray = Array.from(reactionGroupedByUser); - const reactionsList = - reactionsGroupedByUserArray.length > 1 - ? sortReactionsByUserCount(reactionsGroupedByUserArray) - : reactionsGroupedByUserArray; - const {userId, ...emojiPillProps} = props; + const {selfUserId, ...emojiPillProps} = props; return (
- {reactionsList.map(([emoji, users], index) => { + {reactions.map(([emoji, users], index) => { const emojiUnicode = getEmojiUnicode(emoji); - const emojiListCount = reactionsList.length; - const hasUserReacted = users.includes(userId); + const emojiListCount = users.length; + const hasUserReacted = users.some(user => matchQualifiedIds(selfUserId, user)); return ( = ({ if (!messageEntity.isContent()) { return; } - return void messageRepository.toggleReaction(conversationEntity, messageEntity, reaction, selfId.id); + return void messageRepository.toggleReaction(conversationEntity, messageEntity, reaction, selfId); }; const {handleMenuOpen} = useMessageActionsState(); const resetActionMenuStates = useCallback(() => { @@ -124,7 +124,6 @@ const DetailViewModalFooter: FC = ({ handleCurrentMsgAction={setCurrentMsgAction} resetActionMenuStates={resetActionMenuStates} wrapperRef={wrapperRef} - message={messageEntity} handleReactionClick={handleReactionClick} toggleActiveMenu={toggleActiveMenu} handleKeyDown={handleKeyDown} diff --git a/src/script/components/UserList/UserList.tsx b/src/script/components/UserList/UserList.tsx index 8702c21ffc0..ad3e87e285a 100644 --- a/src/script/components/UserList/UserList.tsx +++ b/src/script/components/UserList/UserList.tsx @@ -52,7 +52,7 @@ const USER_CHUNK_SIZE = 64; export interface UserListProps { conversation?: Conversation; - conversationRepository: ConversationRepository; + conversationRepository?: ConversationRepository; conversationState?: ConversationState; highlightedUsers?: User[]; infos?: Record; @@ -166,7 +166,7 @@ export const UserList = ({ if (userEntity.isService) { return; } - if (conversationRepository.conversationRoleRepository.isUserGroupAdmin(conversation, userEntity)) { + if (conversationRepository?.conversationRoleRepository.isUserGroupAdmin(conversation, userEntity)) { admins.push(userEntity); } else { members.push(userEntity); diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index 7700dbfd8c2..de5ef83f457 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -3137,20 +3137,6 @@ export class ConversationRepository { return messageEntity; }); } - if (messageEntity.isContent()) { - const userIds = Object.keys(messageEntity.reactions()); - - messageEntity.reactions_user_ets.removeAll(); - if (userIds.length) { - // TODO(Federation): Make code federation-aware. - return this.userRepository - .getUsersById(userIds.map(userId => ({domain: '', id: userId}))) - .then(userEntities => { - messageEntity.reactions_user_ets(userEntities); - return messageEntity; - }); - } - } return messageEntity; } diff --git a/src/script/conversation/EventBuilder.ts b/src/script/conversation/EventBuilder.ts index 3f8ae775e92..4f3383b7290 100644 --- a/src/script/conversation/EventBuilder.ts +++ b/src/script/conversation/EventBuilder.ts @@ -41,7 +41,7 @@ import type {User} from '../entity/User'; import {CALL, ClientEvent, CONVERSATION} from '../event/Client'; import {StatusType} from '../message/StatusType'; import {VerificationMessageType} from '../message/VerificationMessageType'; -import {ReadReceipt, UserReactionMap} from '../storage'; +import {ReactionMap, ReadReceipt, UserReactionMap} from '../storage'; export interface BaseEvent { conversation: string; @@ -164,7 +164,7 @@ export type MessageAddEvent = ConversationEvent< /** who have received/read the event */ read_receipts?: ReadReceipt[]; /** who reacted to the event */ - reactions?: UserReactionMap; + reactions?: UserReactionMap | ReactionMap; edited_time?: string; status: StatusType; version?: number; diff --git a/src/script/conversation/EventMapper.ts b/src/script/conversation/EventMapper.ts index 7f821f239e0..6a4fa92993a 100644 --- a/src/script/conversation/EventMapper.ts +++ b/src/script/conversation/EventMapper.ts @@ -24,6 +24,7 @@ import {LinkPreview, Mention} from '@wireapp/protocol-messaging'; import {t} from 'Util/LocalizerUtil'; import {getLogger, Logger} from 'Util/Logger'; +import {userReactionMapToReactionMap} from 'Util/ReactionUtil'; import {base64ToArray} from 'Util/util'; import { @@ -188,7 +189,7 @@ export class EventMapper { } if (event.reactions) { - originalEntity.reactions(event.reactions); + originalEntity.reactions(userReactionMapToReactionMap(event.reactions)); originalEntity.version = event.version; } @@ -410,7 +411,9 @@ export class EventMapper { messageEntity.waitingButtonId(waiting_button_id); } if (messageEntity.isReactable()) { - (messageEntity as ContentMessage).reactions((event as LegacyEventRecord).reactions || {}); + (messageEntity as ContentMessage).reactions( + userReactionMapToReactionMap((event as LegacyEventRecord).reactions ?? {}), + ); } if (ephemeral_expires) { diff --git a/src/script/conversation/MessageRepository.test.ts b/src/script/conversation/MessageRepository.test.ts index c5772445fbe..08076cf36e9 100644 --- a/src/script/conversation/MessageRepository.test.ts +++ b/src/script/conversation/MessageRepository.test.ts @@ -32,6 +32,7 @@ import {Message} from 'src/script/entity/message/Message'; import {Text} from 'src/script/entity/message/Text'; import {User} from 'src/script/entity/User'; import {ConversationError} from 'src/script/error/ConversationError'; +import {generateQualifiedId} from 'test/helper/UserGenerator'; import {createUuid} from 'Util/uuid'; import {ConversationRepository} from './ConversationRepository'; @@ -45,6 +46,7 @@ import {ContentMessage} from '../entity/message/ContentMessage'; import {EventRepository} from '../event/EventRepository'; import {EventService} from '../event/EventService'; import {PropertiesRepository} from '../properties/PropertiesRepository'; +import {ReactionMap} from '../storage'; import {TeamState} from '../team/TeamState'; import {ServerTimeHandler, serverTimeHandler} from '../time/serverTimeHandler'; import {UserRepository} from '../user/UserRepository'; @@ -256,27 +258,26 @@ describe('MessageRepository', () => { describe('updateUserReactions', () => { it("should add reaction if it doesn't exist", async () => { const [messageRepository] = await buildMessageRepository(); - const reactions = { - user1: 'like,love', - user2: 'happy,sad', - }; - const userId = 'user1'; + const userId = generateQualifiedId(); + const reactions: ReactionMap = [ + ['like', [userId]], + ['love', [userId]], + ['sad', [{id: 'user2', domain: ''}]], + ['happy', [{id: 'user2', domain: ''}]], + ]; const reaction = 'cry'; - const expectedReactions = { - user1: 'like,love,cry', - user2: 'happy,sad', - }; + const expectedReactions = 'like,love,cry'; const result = messageRepository.updateUserReactions(reactions, userId, reaction); - expect(result).toEqual(expectedReactions[userId]); + expect(result).toEqual(expectedReactions); }); it('should set the reaction for the user for the first time', async () => { const [messageRepository] = await buildMessageRepository(); - const reactions = { - user1: 'like,love,haha', - user2: 'happy,sad', - }; - const userId = 'user3'; + const userId = generateQualifiedId(); + const reactions: ReactionMap = [ + ['sad', [{id: 'user2', domain: ''}]], + ['happy', [{id: 'user2', domain: ''}]], + ]; const reaction = 'like'; const expectedReactions = 'like'; const result = messageRepository.updateUserReactions(reactions, userId, reaction); @@ -285,33 +286,28 @@ describe('MessageRepository', () => { it('should delete reaction if it exists', async () => { const [messageRepository] = await buildMessageRepository(); - const reactions = { - user1: 'like,love,haha', - user2: 'happy,sad', - }; - const userId = 'user1'; + const userId = generateQualifiedId(); + const reactions: ReactionMap = [ + ['like', [userId]], + ['love', [userId]], + ['haha', [userId]], + ['sad', [{id: 'user2', domain: ''}]], + ['happy', [{id: 'user2', domain: ''}]], + ]; const reaction = 'haha'; - const expectedReactions = { - user1: 'like,love', - user2: 'happy,sad', - }; + const expectedReactions = 'like,love'; const result = messageRepository.updateUserReactions(reactions, userId, reaction); - expect(result).toEqual(expectedReactions[userId]); + expect(result).toEqual(expectedReactions); }); + it('should return an empty string if no reactions for a user', async () => { const [messageRepository] = await buildMessageRepository(); - const reactions = { - user1: 'like', - user2: 'happy,sad', - }; - const userId = 'user1'; + const userId = generateQualifiedId(); + const reactions: ReactionMap = [['like', [userId]]]; const reaction = 'like'; - const expectedReactions = { - user1: '', - user2: 'happy,sad', - }; + const expectedReactions = ''; const result = messageRepository.updateUserReactions(reactions, userId, reaction); - expect(result).toEqual(expectedReactions[userId]); + expect(result).toEqual(expectedReactions); }); }); }); diff --git a/src/script/conversation/MessageRepository.ts b/src/script/conversation/MessageRepository.ts index 7bef461ff53..2f3adba21f8 100644 --- a/src/script/conversation/MessageRepository.ts +++ b/src/script/conversation/MessageRepository.ts @@ -94,8 +94,7 @@ import {StatusType} from '../message/StatusType'; import {PropertiesRepository} from '../properties/PropertiesRepository'; import {PROPERTIES_TYPE} from '../properties/PropertiesType'; import {Core} from '../service/CoreSingleton'; -import type {EventRecord} from '../storage'; -import {UserReactionMap} from '../storage/record/EventRecord'; +import type {EventRecord, ReactionMap} from '../storage'; import {TeamState} from '../team/TeamState'; import {ServerTimeHandler} from '../time/serverTimeHandler'; import {UserType} from '../tracking/attribute'; @@ -827,32 +826,21 @@ export class MessageRepository { } } - public updateUserReactions(reactions: UserReactionMap, userId: string, reaction: ReactionType) { - const userReactions = reactions[userId] || ''; - const updatedReactions = {...reactions}; - - if (userReactions) { - const reactionsArr = userReactions.split(','); - const reactionIndex = reactionsArr.indexOf(reaction); - if (reactionIndex === -1) { - reactionsArr.push(reaction); - } else { - reactionsArr.splice(reactionIndex, 1); - } - // if all reactions removed return empty string - updatedReactions[userId] = reactionsArr.join(','); - } else { - // first time reacted - updatedReactions[userId] = reaction; - } - return updatedReactions[userId]; + public updateUserReactions(reactions: ReactionMap, userId: QualifiedId, reaction: ReactionType) { + const userReactions = reactions + .filter(([, users]) => users.some(user => matchQualifiedIds(user, userId))) + .map(([reaction]) => reaction); + const updatedReactions = userReactions.includes(reaction) + ? userReactions.filter(r => r !== reaction) + : [...userReactions, reaction]; + return updatedReactions.join(','); } public toggleReaction( conversationEntity: Conversation, message_et: ContentMessage, reaction: string, - userId: string, + userId: QualifiedId, ) { if (conversationEntity.removed_from_conversation()) { return null; diff --git a/src/script/entity/message/ContentMessage.ts b/src/script/entity/message/ContentMessage.ts index 51e3796ade5..ba81c023a1e 100644 --- a/src/script/entity/message/ContentMessage.ts +++ b/src/script/entity/message/ContentMessage.ts @@ -19,7 +19,6 @@ import type {QualifiedUserClients} from '@wireapp/api-client/lib/conversation'; import {QualifiedId} from '@wireapp/api-client/lib/user'; -import {ReactionType} from '@wireapp/core/lib/conversation/ReactionType'; import ko from 'knockout'; import {copyText} from 'Util/ClipboardUtil'; @@ -35,70 +34,26 @@ import {Text as TextAsset} from './Text'; import {AssetRepository} from '../../assets/AssetRepository'; import type {QuoteEntity} from '../../message/QuoteEntity'; import {SuperType} from '../../message/SuperType'; -import {UserReactionMap} from '../../storage'; -import {User} from '../User'; +import {ReactionMap, ReadReceipt} from '../../storage'; export class ContentMessage extends Message { - private readonly isLikedProvisional: ko.Observable; - public readonly reactions_user_ets: ko.ObservableArray; public readonly assets: ko.ObservableArray = ko.observableArray(); - public readonly is_liked: ko.PureComputed; - public readonly like_caption: ko.PureComputed; - public readonly other_likes: ko.PureComputed; public readonly failedToSend: ko.Observable< {queued?: QualifiedUserClients | QualifiedId[]; failed?: QualifiedId[]} | undefined > = ko.observable(); // raw content of a file that was supposed to be sent but failed. Is undefined if the message has been successfully sent public readonly fileData: ko.Observable = ko.observable(); - public readonly quote: ko.Observable; + public readonly quote = ko.observable(); public readonly was_edited: ko.PureComputed; public replacing_message_id: null | string = null; readonly edited_timestamp: ko.Observable = ko.observable(null); - // TODO(Federation): Make reactions federation-aware. - readonly reactions: ko.Observable = ko.observable({}); + readonly reactions = ko.observable([]); public super_type = SuperType.CONTENT; + public readonly readReceipts = ko.observableArray(); constructor(id?: string) { super(id); this.was_edited = ko.pureComputed(() => !!this.edited_timestamp()); - - this.reactions_user_ets = ko.observableArray(); - - this.quote = ko.observable(); - this.readReceipts = ko.observableArray([]); - - this.isLikedProvisional = ko.observable(null); - this.is_liked = ko.pureComputed({ - read: () => { - const isLikedProvisional = this.isLikedProvisional(); - const reactionsUserEts = this.reactions_user_ets(); - if (isLikedProvisional !== null) { - this.isLikedProvisional(null); - return isLikedProvisional; - } - return reactionsUserEts.some(user => user.isMe); - }, - write: value => { - return this.isLikedProvisional(value); - }, - }); - this.other_likes = ko.pureComputed(() => this.reactions_user_ets().filter(user_et => !user_et.isMe)); - - this.like_caption = ko.pureComputed(() => { - const maxShownNames = 2; - const likesAmount = this.reactions_user_ets().length; - if (likesAmount <= maxShownNames) { - return this.reactions_user_ets() - .map(user => user.name()) - .join(', '); - } - const caption = - likesAmount > 1 - ? t('conversationLikesCaptionPlural', likesAmount.toString()) - : t('conversationLikesCaptionSingular', likesAmount.toString()); - - return caption; - }); } readonly displayEditedTimestamp = () => { @@ -125,18 +80,6 @@ export class ContentMessage extends Message { return this.assets()[0]; } - getUpdatedReactions({ - data: event_data, - from, - }: { - data: {reaction: ReactionType}; - from: string; - }): false | {reactions: UserReactionMap; version: number} { - const reactions = event_data && event_data.reaction; - const userReactions = {...this.reactions(), [from]: reactions}; - return {reactions: userReactions, version: this.version + 1}; - } - /** * @param userId The user id to check * @returns `true` if the message mentions the user, `false` otherwise. diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.test.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.test.ts index 2a8b6c40a90..f4d851819fc 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.test.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.test.ts @@ -32,37 +32,42 @@ describe('reactionEventHandler', () => { await expect(operation).rejects.toThrow('Reaction event to a non-existing message'); }); - it('successfully updates a message with new reactions when they arrive', async () => { - const baseReactions = {'first-user': '👍'}; - const baseVersion = 10; - const targetMessage = toSavedEvent( - createMessageAddEvent({overrides: {reactions: baseReactions, version: baseVersion}}), - ); - const reactionEvent = createReactionEvent(createUuid(), '🫶'); + describe('legacy reaction format', () => { + it('successfully updates a message on the legacy reaction format with new reactions when they arrive', async () => { + const baseReactions = {'first-user': '👍'}; + const baseVersion = 10; + const targetMessage = toSavedEvent( + createMessageAddEvent({overrides: {reactions: baseReactions, version: baseVersion}}), + ); + const reactionEvent = createReactionEvent(createUuid(), '🫶'); - const operation: any = await handleReactionEvent(reactionEvent, { - findEvent: () => Promise.resolve(targetMessage), - selfUserId: createUuid(), + const operation: any = await handleReactionEvent(reactionEvent, { + findEvent: () => Promise.resolve(targetMessage), + selfUserId: createUuid(), + }); + + expect(operation.type).toBe('sequential-update'); + expect(operation.updates.reactions).toEqual([ + ['👍', [{domain: '', id: 'first-user'}]], + ['🫶', [{domain: '', id: reactionEvent.from}]], + ]); + expect(operation.updates.version).toEqual(baseVersion + 1); }); - expect(operation.type).toBe('sequential-update'); - expect(operation.updates.reactions).toEqual({...baseReactions, [reactionEvent.from]: '🫶'}); - expect(operation.updates.version).toEqual(baseVersion + 1); - }); + it('successfully deletes a reaction from a legacy reaction format', async () => { + const reactor = createUuid(); + const baseReactions = {[reactor]: '👍'}; + const targetMessage = toSavedEvent(createMessageAddEvent({overrides: {reactions: baseReactions}})); + const reactionEvent = createReactionEvent(createUuid(), ''); + reactionEvent.from = reactor; - it('successfully deletes a reaction', async () => { - const reactor = createUuid(); - const baseReactions = {[reactor]: '👍'}; - const targetMessage = toSavedEvent(createMessageAddEvent({overrides: {reactions: baseReactions}})); - const reactionEvent = createReactionEvent(createUuid(), ''); - reactionEvent.from = reactor; + const operation: any = await handleReactionEvent(reactionEvent, { + findEvent: () => Promise.resolve(targetMessage), + selfUserId: createUuid(), + }); - const operation: any = await handleReactionEvent(reactionEvent, { - findEvent: () => Promise.resolve(targetMessage), - selfUserId: createUuid(), + expect(operation.type).toBe('sequential-update'); + expect(operation.updates.reactions).toEqual([]); }); - - expect(operation.type).toBe('sequential-update'); - expect(operation.updates.reactions).toEqual({...baseReactions, [reactor]: ''}); }); }); diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts index bedc473c122..81d32303287 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts @@ -19,16 +19,25 @@ import {MessageAddEvent, ReactionEvent} from 'src/script/conversation/EventBuilder'; import {StoredEvent} from 'src/script/storage'; +import {addReaction, userReactionMapToReactionMap} from 'Util/ReactionUtil'; import {EventValidationError} from './EventValidationError'; import {CONVERSATION} from '../../../Client'; import {EventHandler} from '../types'; -function computeEventUpdates(target: StoredEvent, reaction: ReactionEvent) { +function computeEventUpdates(target: StoredEvent, reactionEvent: ReactionEvent) { const version = (target.version ?? 1) + 1; - const updatedReactions = {...target.reactions, [reaction.from]: reaction.data.reaction}; - return {reactions: updatedReactions, version: version}; + const { + data: {reaction}, + qualified_from, + from, + } = reactionEvent; + const reactionMap = target.reactions ? userReactionMapToReactionMap(target.reactions) : []; + return { + reactions: addReaction(reactionMap, reaction, qualified_from ?? {id: from, domain: ''}), + version: version, + }; } export const handleReactionEvent: EventHandler = async (event, {findEvent}) => { diff --git a/src/script/page/RightSidebar/MessageDetails/MessageDetails.test.tsx b/src/script/page/RightSidebar/MessageDetails/MessageDetails.test.tsx index 56e56bf67e4..3ad0c0f2cc3 100644 --- a/src/script/page/RightSidebar/MessageDetails/MessageDetails.test.tsx +++ b/src/script/page/RightSidebar/MessageDetails/MessageDetails.test.tsx @@ -78,12 +78,12 @@ describe('MessageDetails', () => { message.timestamp(timestamp); message.user(user); - const getUsersById = jest.fn(async (ids: QualifiedId[]) => { + const findUsersByIds = jest.fn((ids: QualifiedId[]) => { return ids.map(id => new User(id.id, 'test-domain.mock')); }); const userRepository = { - getUsersById, + findUsersByIds, } as unknown as UserRepository; const defaultProps = getDefaultParams(); diff --git a/src/script/page/RightSidebar/MessageDetails/MessageDetails.tsx b/src/script/page/RightSidebar/MessageDetails/MessageDetails.tsx index c39aa5c8c5f..54fc6560730 100644 --- a/src/script/page/RightSidebar/MessageDetails/MessageDetails.tsx +++ b/src/script/page/RightSidebar/MessageDetails/MessageDetails.tsx @@ -17,40 +17,25 @@ * */ -import {FC, Fragment, useCallback, useEffect, useMemo, useState} from 'react'; +import {FC, useMemo, useState} from 'react'; -import type {QualifiedId} from '@wireapp/api-client/lib/user/'; -import {amplify} from 'amplify'; import cx from 'classnames'; -import {WebAppEvents} from '@wireapp/webapp-events'; - import {FadingScrollbar} from 'Components/FadingScrollbar'; import {Icon} from 'Components/Icon'; -import {EmojiImg} from 'Components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiImg'; -import { - messageReactionDetailsMargin, - reactionsCountAlignment, -} from 'Components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.styles'; import {UserSearchableList} from 'Components/UserSearchableList'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; -import {getEmojiTitleFromEmojiUnicode, getEmojiUnicode} from 'Util/EmojiUtil'; import {t} from 'Util/LocalizerUtil'; -import {getEmojiUrl, groupByReactionUsers} from 'Util/ReactionUtil'; -import {capitalizeFirstChar} from 'Util/StringUtil'; import {formatLocale} from 'Util/TimeUtil'; -import {panelContentTitleStyles} from './MessageDetails.styles'; +import {UsersReactions} from './UserReactions'; import {ConversationRepository} from '../../../conversation/ConversationRepository'; import {Conversation} from '../../../entity/Conversation'; import {ContentMessage} from '../../../entity/message/ContentMessage'; -import {Message} from '../../../entity/message/Message'; import {User} from '../../../entity/User'; -import {isContentMessage} from '../../../guards/Message'; import {SuperType} from '../../../message/SuperType'; import {SearchRepository} from '../../../search/SearchRepository'; -import {UserReactionMap} from '../../../storage'; import {TeamRepository} from '../../../team/TeamRepository'; import {UserRepository} from '../../../user/UserRepository'; import {PanelHeader} from '../PanelHeader'; @@ -66,19 +51,6 @@ const MESSAGE_STATES = { const formatUserCount = (users: User[]): string => (users.length ? ` (${users.length})` : ''); -const getTotalReactionUsersCount = (reactions: Map): number => { - let total = 0; - reactions.forEach(reaction => { - total += reaction.length; - }); - return total; -}; - -const formatReactionCount = (reactions: Map): string => { - const total = getTotalReactionUsersCount(reactions); - return total ? ` (${total})` : ''; -}; - const sortUsers = (userA: User, userB: User): number => userA.name().localeCompare(userB.name(), undefined, {sensitivity: 'base'}); @@ -94,7 +66,6 @@ interface MessageDetailsProps { userRepository: UserRepository; showReactions?: boolean; selfUser: User; - updateEntity: (message: Message) => void; togglePanel: (state: PanelState, entity: PanelEntity, addMode?: boolean) => void; } @@ -108,13 +79,8 @@ const MessageDetails: FC = ({ userRepository, selfUser, onClose, - updateEntity, togglePanel, }) => { - const [receiptUsers, setReceiptUsers] = useState([]); - const [reactionUsers, setReactionUsers] = useState>(new Map()); - const [messageId, setMessageId] = useState(messageEntity.id); - const [isReceiptsOpen, setIsReceiptsOpen] = useState(!showReactions); const { @@ -124,10 +90,15 @@ const MessageDetails: FC = ({ readReceipts, edited_timestamp: editedTimestamp, } = useKoSubscribableChildren(messageEntity, ['timestamp', 'user', 'reactions', 'readReceipts', 'edited_timestamp']); + const totalNbReactions = reactions.reduce((acc, [, users]) => acc + users.length, 0); const teamId = activeConversation.team_id; const supportsReceipts = messageSender.isMe && teamId; + const receiptUsers = userRepository + .findUsersByIds(readReceipts.map(({userId, domain}) => ({domain: domain || '', id: userId}))) + .sort(sortUsers); + const supportsReactions = useMemo(() => { const isPing = messageEntity.super_type === SuperType.PING; const isEphemeral = messageEntity?.isEphemeral(); @@ -144,36 +115,10 @@ const MessageDetails: FC = ({ return receiptUsers.length ? MESSAGE_STATES.RECEIPTS : MESSAGE_STATES.NO_RECEIPTS; } - return getTotalReactionUsersCount(reactionUsers) ? MESSAGE_STATES.REACTIONS : MESSAGE_STATES.NO_REACTIONS; - }, [supportsReceipts, isReceiptsOpen, messageEntity, receiptUsers, reactionUsers]); - - const getReactions = useCallback(async (reactions: UserReactionMap) => { - const usersMap = new Map(); - const currentReactions = Object.keys(reactions); - const usersReactions = await userRepository.getUsersById( - currentReactions.map(userId => ({domain: '', id: userId})), - ); - usersReactions.forEach(user => { - usersMap.set(user.id, user); - }); - const reactionsGroupByUser = groupByReactionUsers(reactions); - const reactionsGroupByUserMap = new Map(); - reactionsGroupByUser.forEach((userIds, reaction) => { - reactionsGroupByUserMap.set( - reaction, - userIds.map(userId => usersMap.get(userId)!), - ); - }); - - setReactionUsers(reactionsGroupByUserMap); - }, []); + return reactions.length > 0 ? MESSAGE_STATES.REACTIONS : MESSAGE_STATES.NO_REACTIONS; + }, [supportsReceipts, isReceiptsOpen, reactions.length, messageEntity.expectsReadConfirmation, receiptUsers.length]); const receiptTimes = useMemo(() => { - const userIds: QualifiedId[] = readReceipts.map(({userId, domain}) => ({domain: domain || '', id: userId})); - userRepository.getUsersById(userIds).then((users: User[]) => { - setReceiptUsers(users.sort(sortUsers)); - }); - return readReceipts.reduce>((times, {userId, time}) => { times[userId] = formatTime(time); return times; @@ -186,7 +131,7 @@ const MessageDetails: FC = ({ 'messageDetailsTitleReceipts', messageEntity?.expectsReadConfirmation ? formatUserCount(receiptUsers) : '', ); - const reactionsTitle = t('messageDetailsTitleReactions', formatReactionCount(reactionUsers)); + const reactionsTitle = t('messageDetailsTitleReactions', totalNbReactions > 0 ? ` (${totalNbReactions})` : ''); const panelTitle = useMemo(() => { if (!supportsReceipts) { @@ -208,28 +153,6 @@ const MessageDetails: FC = ({ const onReactions = () => setIsReceiptsOpen(false); - useEffect(() => { - if (supportsReactions && reactions) { - getReactions(reactions); - } - }, [getReactions, supportsReactions, reactions]); - - useEffect(() => { - amplify.subscribe(WebAppEvents.CONVERSATION.MESSAGE.UPDATED, (oldId: string, updatedMessageEntity: Message) => { - // listen for any changes to local message entities. - // if the id of the message being viewed has changed, we store the new ID. - if (oldId === messageId) { - updateEntity(updatedMessageEntity); - setMessageId(updatedMessageEntity.id); - - if (supportsReactions && isContentMessage(updatedMessageEntity)) { - const messageReactions = updatedMessageEntity.reactions(); - getReactions(messageReactions); - } - } - }); - }, [messageId, supportsReactions]); - const onParticipantClick = (userEntity: User) => togglePanel(PanelState.GROUP_PARTICIPANT_USER, userEntity); return ( @@ -275,35 +198,14 @@ const MessageDetails: FC = ({ /> )} - {messageState === MESSAGE_STATES.REACTIONS && - Array.from(reactionUsers).map(reactions => { - const [reactionKey, users] = reactions; - const emojiUnicode = getEmojiUnicode(reactionKey); - const emojiUrl = getEmojiUrl(emojiUnicode); - const emojiName = getEmojiTitleFromEmojiUnicode(emojiUnicode); - const capitalizedEmojiName = capitalizeFirstChar(emojiName); - const emojiCount = users.length; - return ( - -
- - {capitalizedEmojiName} - ({emojiCount}) -
- -
- ); - })} + {messageState === MESSAGE_STATES.REACTIONS && ( + userRepository.findUsersByIds(ids)} + onParticipantClick={onParticipantClick} + /> + )} {messageState === MESSAGE_STATES.NO_RECEIPTS && (
diff --git a/src/script/page/RightSidebar/MessageDetails/UserReactions.tsx b/src/script/page/RightSidebar/MessageDetails/UserReactions.tsx new file mode 100644 index 00000000000..d2ea6bce5f7 --- /dev/null +++ b/src/script/page/RightSidebar/MessageDetails/UserReactions.tsx @@ -0,0 +1,73 @@ +/* + * 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 {Fragment} from 'react'; + +import type {QualifiedId} from '@wireapp/api-client/lib/user'; + +import {EmojiImg} from 'Components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiImg'; +import { + messageReactionDetailsMargin, + reactionsCountAlignment, +} from 'Components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.styles'; +import {UserList} from 'Components/UserList'; +import {User} from 'src/script/entity/User'; +import {ReactionMap} from 'src/script/storage'; +import {getEmojiTitleFromEmojiUnicode, getEmojiUnicode} from 'Util/EmojiUtil'; +import {getEmojiUrl} from 'Util/ReactionUtil'; +import {capitalizeFirstChar} from 'Util/StringUtil'; + +import {panelContentTitleStyles} from './MessageDetails.styles'; + +interface UsersReactionsProps { + reactions: ReactionMap; + findUsers: (userId: QualifiedId[]) => User[]; + selfUser: User; + onParticipantClick: (user: User) => void; +} + +export function UsersReactions({reactions, selfUser, findUsers, onParticipantClick}: UsersReactionsProps) { + return reactions.map(reaction => { + const [reactionKey, userIds] = reaction; + const emojiUnicode = getEmojiUnicode(reactionKey); + const emojiUrl = getEmojiUrl(emojiUnicode); + const emojiName = getEmojiTitleFromEmojiUnicode(emojiUnicode); + const capitalizedEmojiName = capitalizeFirstChar(emojiName); + const users = findUsers(userIds); + const emojiCount = users.length; + + return ( + +
+ + {capitalizedEmojiName} + ({emojiCount}) +
+ +
+ ); + }); +} diff --git a/src/script/page/RightSidebar/RightSidebar.tsx b/src/script/page/RightSidebar/RightSidebar.tsx index cd49d18364d..3898891cf4a 100644 --- a/src/script/page/RightSidebar/RightSidebar.tsx +++ b/src/script/page/RightSidebar/RightSidebar.tsx @@ -308,7 +308,6 @@ const RightSidebar: FC = ({ selfUser={selfUser} conversationRepository={conversationRepository} messageEntity={messageEntity} - updateEntity={rightSidebar.updateEntity} teamRepository={teamRepository} searchRepository={searchRepository} showReactions={rightSidebar.showReactions} diff --git a/src/script/storage/record/EventRecord.ts b/src/script/storage/record/EventRecord.ts index 90294702bef..ae3a6333383 100644 --- a/src/script/storage/record/EventRecord.ts +++ b/src/script/storage/record/EventRecord.ts @@ -40,7 +40,9 @@ export interface AssetRecord { token?: string; } +/** @deprecated as of Oct 2023, this is the old format we stored reactions in */ export type UserReactionMap = {[userId: string]: ReactionType}; +export type ReactionMap = [reaction: string, userIds: QualifiedId[]][]; /** * Represent an event that has been sent by the current device @@ -96,7 +98,7 @@ export type LegacyEventRecord = { previews?: string[]; qualified_conversation?: QualifiedId; qualified_from?: QualifiedId; - reactions?: UserReactionMap; + reactions?: ReactionMap | UserReactionMap; read_receipts?: ReadReceipt[]; selected_button_id?: string; server_time?: string; diff --git a/src/script/user/UserRepository.ts b/src/script/user/UserRepository.ts index 1d33c941e00..9509b02e060 100644 --- a/src/script/user/UserRepository.ts +++ b/src/script/user/UserRepository.ts @@ -587,6 +587,10 @@ export class UserRepository { return fetchedUserEntities; } + findUsersByIds(userIds: QualifiedId[]): User[] { + return this.userState.users().filter(user => userIds.find(userId => matchQualifiedIds(user.qualifiedId, userId))); + } + /** * Find a local user. */ diff --git a/src/script/util/ReactionUtil.test.ts b/src/script/util/ReactionUtil.test.ts new file mode 100644 index 00000000000..c95e5610b2c --- /dev/null +++ b/src/script/util/ReactionUtil.test.ts @@ -0,0 +1,114 @@ +/* + * Wire + * Copyright (C) 2018 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 {generateQualifiedId} from 'test/helper/UserGenerator'; + +import {addReaction, userReactionMapToReactionMap} from './ReactionUtil'; +import {createUuid} from './uuid'; + +import {ReactionMap} from '../storage'; + +describe('ReactionUtil', () => { + describe('userReactionMapToReactionMap', () => { + it('converts a user reaction map to a reaction map', () => { + const userId = {id: createUuid(), domain: ''}; + const userReactions = {[userId.id]: '👍,👎'}; + const reactionMap = userReactionMapToReactionMap(userReactions); + + expect(reactionMap).toEqual([ + ['👍', [userId]], + ['👎', [userId]], + ]); + }); + + it('converts multiple users reactions to a reaction map', () => { + const userId = {id: createUuid(), domain: ''}; + const otherUserId = {id: createUuid(), domain: ''}; + const userReactions = {[userId.id]: '👍,👎', [otherUserId.id]: '👍,❤️'}; + const reactionMap = userReactionMapToReactionMap(userReactions); + + expect(reactionMap).toEqual([ + ['👍', [userId, otherUserId]], + ['👎', [userId]], + ['❤️', [otherUserId]], + ]); + }); + }); + + describe('addReaction', () => { + it('adds a single reaction', () => { + const userId = generateQualifiedId(); + const updatedReactions = addReaction([], '👍', userId); + + expect(updatedReactions).toEqual([['👍', [userId]]]); + }); + + it('adds a new reaction to a reaction list', () => { + const userId = generateQualifiedId(); + const reactions: ReactionMap = [['👎', [generateQualifiedId()]]]; + const updatedReactions = addReaction(reactions.slice(), '👍', userId); + + expect(updatedReactions).toEqual([...reactions, ['👍', [userId]]]); + }); + + it('adds a already existing reaction to a reaction list', () => { + const userId = generateQualifiedId(); + const firstReactorId = generateQualifiedId(); + const reactions: ReactionMap = [['👍', [firstReactorId]]]; + const updatedReactions = addReaction(reactions.slice(), '👍', userId); + + expect(updatedReactions).toEqual([['👍', [firstReactorId, userId]]]); + }); + + it('removes a user reaction', () => { + const userId = generateQualifiedId(); + const reactions: ReactionMap = [['👍', [userId]]]; + const updatedReactions = addReaction(reactions.slice(), '', userId); + + expect(updatedReactions).toEqual([]); + }); + + it('leaves other user reactions if a single reaction is removed', () => { + const userId = generateQualifiedId(); + const reactions: ReactionMap = [ + ['👍', [userId]], + ['👎', [userId]], + ]; + const updatedReactions = addReaction(reactions.slice(), '👍', userId); + + expect(updatedReactions).toEqual([['👍', [userId]]]); + }); + + it('keeps the order at which the reactions were received', () => { + const userId = generateQualifiedId(); + const reactorId = generateQualifiedId(); + const reactions: ReactionMap = [ + ['👍', [userId]], + ['👎', [userId]], + ]; + const updatedReactions = addReaction(reactions.slice(), '👍,❤️', reactorId); + + expect(updatedReactions).toEqual([ + ['👍', [userId, reactorId]], + ['👎', [userId]], + ['❤️', [reactorId]], + ]); + }); + }); +}); diff --git a/src/script/util/ReactionUtil.ts b/src/script/util/ReactionUtil.ts index a5fb20e910e..573af21a326 100644 --- a/src/script/util/ReactionUtil.ts +++ b/src/script/util/ReactionUtil.ts @@ -17,42 +17,60 @@ * */ -export interface Reactions { - [key: string]: string; -} +import type {QualifiedId} from '@wireapp/api-client/lib/user'; -type ReactionsGroupedByUser = Map; +import {matchQualifiedIds} from './QualifiedId'; -export function groupByReactionUsers(reactions: Reactions): ReactionsGroupedByUser { - const reactionsGroupedByUser = new Map(); +import {ReactionMap, UserReactionMap} from '../storage'; - for (const user in reactions) { - const userReactions = reactions[user] && reactions[user]?.split(','); +function isReactionMap(reactions: UserReactionMap | ReactionMap): reactions is ReactionMap { + return Array.isArray(reactions); +} - for (const reaction of userReactions) { - const users = reactionsGroupedByUser.get(reaction) || []; - users.push(user); - reactionsGroupedByUser.set(reaction, users); - } +/** + * Will convert the legacy user reaction map to the new reaction map format. + * The new map format will allow keeping track of the order the reactions arrived in. + */ +export function userReactionMapToReactionMap(userReactions: UserReactionMap | ReactionMap): ReactionMap { + if (isReactionMap(userReactions)) { + return userReactions; } + return Object.entries(userReactions).reduce((acc, [userId, reactions]) => { + reactions.split(',').forEach(reaction => { + const existingReaction = acc.find(([r]) => r === reaction); + const qualifiedId = {id: userId, domain: ''}; + if (existingReaction) { + existingReaction[1].push(qualifiedId); + } else { + acc.push([reaction, [qualifiedId]]); + } + }); + return acc; + }, []); +} + +export function addReaction(reactions: ReactionMap, reactionsStr: string, userId: QualifiedId) { + const userReactions = reactionsStr.split(','); - return reactionsGroupedByUser; + // First step is to remove all of this user's reactions + const filteredReactions = reactions.map(([reaction, users]) => { + return [reaction, users.filter(user => !matchQualifiedIds(user, userId))]; + }); + + userReactions + .filter(([reaction]) => !!reaction) + .forEach(reaction => { + const existingEntry = filteredReactions.find(([r]) => r === reaction); + if (existingEntry) { + existingEntry[1].push(userId); + } else { + filteredReactions.push([reaction, [userId]]); + } + }); + return filteredReactions.filter(([, users]) => users.length > 0); } // Maps to the static server emojis url export function getEmojiUrl(unicode: string) { return `/image/emojis/img-apple-64/${unicode}.png`; } - -/** - * - * @param reactionsList This is an array of tuples, each tuple consists of two elements a - * string representing an emoji and an array of strings representing users' reactions for that emoji. - * @returns tuples are sorted in descending order based on the length of the user - * reactions array for each emoji. - */ -export function sortReactionsByUserCount(reactionsList: [string, string[]][]) { - return reactionsList.sort( - ([, reactionAUserList], [, reactionBUserList]) => reactionBUserList.length - reactionAUserList.length, - ); -} diff --git a/test/helper/UserGenerator.ts b/test/helper/UserGenerator.ts index 71441fc9453..996617d4817 100644 --- a/test/helper/UserGenerator.ts +++ b/test/helper/UserGenerator.ts @@ -27,6 +27,13 @@ import type {User} from '../../src/script/entity/User'; import {serverTimeHandler} from '../../src/script/time/serverTimeHandler'; import {UserMapper} from '../../src/script/user/UserMapper'; +export function generateQualifiedId(): QualifiedId { + return { + id: createUuid(), + domain: 'test.wire.link', + }; +} + export function generateAPIUser( id: QualifiedId = {id: createUuid(), domain: 'test.wire.link'}, overwites?: Partial, From aadbc2f6a844657e8f68d3f5e132ad1851acb7b8 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 24 Oct 2023 15:28:37 +0200 Subject: [PATCH 16/86] runfix: Update openState of emoji/mention picker when menu is closed (#16102) --- .../EmojiPickerPlugin/EmojiPickerPlugin.tsx | 68 ++++++------------- .../plugins/MentionsPlugin/MentionsPlugin.tsx | 20 +++--- 2 files changed, 29 insertions(+), 59 deletions(-) diff --git a/src/script/components/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx b/src/script/components/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx index 3786662956b..aab1add81f6 100644 --- a/src/script/components/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx @@ -17,18 +17,13 @@ * */ -import {MutableRefObject, useCallback, useMemo, useState} from 'react'; +import {MutableRefObject, useMemo, useState} from 'react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import { - MenuOption, - MenuRenderFn, - MenuTextMatch, - useBasicTypeaheadTriggerMatch, -} from '@lexical/react/LexicalTypeaheadMenuPlugin'; +import {MenuOption, MenuRenderFn, MenuTextMatch} from '@lexical/react/LexicalTypeaheadMenuPlugin'; // The emoji list comes from the emoji-picker-react package that we also use for reactions. It's a little hacky how we import it but since it's typechecked, we will be warned if this file doesn't exist in the repo with further updates import emojiList from 'emoji-picker-react/src/data/emojis.json'; -import {$createTextNode, $getSelection, $isRangeSelection, TextNode} from 'lexical'; +import {$createTextNode, TextNode} from 'lexical'; import * as ReactDOM from 'react-dom'; import {TypeaheadMenuPlugin} from 'Components/RichTextEditor/plugins/TypeaheadMenuPlugin'; @@ -42,19 +37,19 @@ import {getDOMRangeRect} from '../../utils/getDomRangeRect'; import {getSelectionInfo} from '../../utils/getSelectionInfo'; const TRIGGER = ':'; +const triggerRegexp = new RegExp(`(\\W|^)(${TRIGGER}([\\w+\\-][\\w \\-]*))$`); /** * Will detect emoji triggers in a text * @param text the text in which to look for emoji triggers */ function checkForEmojis(text: string): MenuTextMatch | null { - const match = new RegExp(`(^| )(${TRIGGER}([\\w +\\-][\\w \\-]*))$`).exec(text); + const match = triggerRegexp.exec(text); if (match === null) { return null; } - const search = match[2]; - const term = match[3]; + const [, , search, term] = match; if (term.length === 0) { return null; @@ -117,31 +112,16 @@ export function EmojiPickerPlugin({openStateRef}: Props) { } }; - const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { - minLength: 0, - }); + const checkForEmojiPickerMatch = (text: string) => { + const info = getSelectionInfo([TRIGGER]); - const checkForEmojiPickerMatch = useCallback( - (text: string) => { + if (!info || (info.isTextNode && info.wordCharAfterCursor)) { // Don't show the menu if the next character is a word character - const info = getSelectionInfo([':']); - - if (info?.isTextNode && info.wordCharAfterCursor) { - return null; - } - - const slashMatch = checkForTriggerMatch(text, lexicalEditor); - - if (slashMatch !== null) { - return null; - } - - const queryMatch = checkForEmojis(text); + return null; + } - return queryMatch?.replaceableString ? queryMatch : null; - }, - [checkForTriggerMatch, lexicalEditor], - ); + return checkForEmojis(info.textContent); + }; const options: Array = useMemo(() => { const filteredEmojis = emojiOptions.filter((emoji: EmojiOption) => { @@ -165,23 +145,12 @@ export function EmojiPickerPlugin({openStateRef}: Props) { .slice(0, MAX_EMOJI_SUGGESTION_COUNT); }, [queryString]); - const onSelectOption = (selectedOption: EmojiOption, nodeToRemove: TextNode | null, closeMenu: () => void) => { + const insertEmoji = (selectedOption: EmojiOption, nodeToRemove: TextNode | null, closeMenu: () => void) => { lexicalEditor.update(() => { - const selection = $getSelection(); - - if (!$isRangeSelection(selection) || selectedOption == null) { - return; - } - - if (nodeToRemove) { - nodeToRemove.remove(); - } - - selection.insertNodes([$createTextNode(selectedOption.emoji)]); - increaseUsageCount(selectedOption.title); - - closeMenu(); + nodeToRemove?.replace($createTextNode(selectedOption.emoji)); }); + increaseUsageCount(selectedOption.title); + closeMenu(); }; const rootElement = lexicalEditor.getRootElement(); @@ -238,10 +207,11 @@ export function EmojiPickerPlugin({openStateRef}: Props) { return ( (openStateRef.current = false)} containerId="emoji-typeahead-menu" /> ); diff --git a/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx b/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx index 47060033a59..b2399e98772 100644 --- a/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx @@ -35,19 +35,19 @@ import {getSelectionInfo} from '../../utils/getSelectionInfo'; import {TypeaheadMenuPlugin} from '../TypeaheadMenuPlugin'; const TRIGGER = '@'; +const triggerRegexp = new RegExp(`(^| )(${TRIGGER}(\\S*))$`); /** * Will detect mentions triggers in a text * @param text the text in which to look for mentions triggers */ function checkForMentions(text: string): MenuTextMatch | null { - const match = new RegExp(`(^| )(${TRIGGER}(\\S*))$`).exec(text); + const match = triggerRegexp.exec(text); if (match === null) { return null; } - const search = match[2]; - const term = match[3]; + const [, , search, term] = match; return { leadOffset: match.index, @@ -81,16 +81,16 @@ export function MentionsPlugin({onSearch, openStateRef}: MentionsPluginProps) { const options = results.map(result => new MenuOption(result, result.name())).reverse(); - const handleSelectOption = useCallback( + const insertMention = useCallback( (selectedOption: MenuOption, nodeToReplace: TextNode | null, closeMenu: () => void) => { editor.update(() => { - const mentionNode = $createMentionNode(TRIGGER, selectedOption.value); if (nodeToReplace) { + const mentionNode = $createMentionNode(TRIGGER, selectedOption.value); nodeToReplace.replace(mentionNode); mentionNode.insertAfter($createTextNode(' ')); } - closeMenu(); }); + closeMenu(); }, [editor], ); @@ -98,10 +98,10 @@ export function MentionsPlugin({onSearch, openStateRef}: MentionsPluginProps) { const checkForMentionMatch = useCallback((text: string) => { // Don't show the menu if the next character is a word character const info = getSelectionInfo([TRIGGER]); - if (info?.isTextNode && info.wordCharAfterCursor) { + if (!info || (info.isTextNode && info.wordCharAfterCursor)) { return null; } - return checkForMentions(text); + return checkForMentions(info.textContent); }, []); const rootElement = editor.getRootElement(); @@ -124,7 +124,6 @@ export function MentionsPlugin({onSearch, openStateRef}: MentionsPluginProps) { return null; } - openStateRef.current = true; const {bottom, left} = getPosition(); return ReactDOM.createPortal( @@ -162,10 +161,11 @@ export function MentionsPlugin({onSearch, openStateRef}: MentionsPluginProps) { return ( (openStateRef.current = false)} containerId="mentions-typeahead-menu" isReversed /> From 6cddea0a9d311fe9d29c4674b4e64efe66969d72 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 24 Oct 2023 15:45:58 +0200 Subject: [PATCH 17/86] runfix: Do not open typeahead menu when text is first entered in the input bar (#16103) --- src/script/components/InputBar/InputBar.tsx | 5 +++-- .../RichTextEditor/plugins/TypeaheadMenuPlugin.tsx | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/script/components/InputBar/InputBar.tsx b/src/script/components/InputBar/InputBar.tsx index 094689dd9c8..10f7f969cce 100644 --- a/src/script/components/InputBar/InputBar.tsx +++ b/src/script/components/InputBar/InputBar.tsx @@ -255,9 +255,10 @@ export const InputBar = ({ cancelMessageEditing(true); setEditedMessage(messageEntity); - if (messageEntity.quote() && conversation) { + const quote = messageEntity.quote(); + if (quote && conversation) { void messageRepository - .getMessageInConversationById(conversation, messageEntity.quote().messageId) + .getMessageInConversationById(conversation, quote.messageId) .then(quotedMessage => setReplyMessageEntity(quotedMessage)); } } diff --git a/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx b/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx index e08c845cdb1..a012d6adb84 100644 --- a/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx @@ -672,6 +672,7 @@ export function TypeaheadMenuPlugin({ containerId, isReversed = false, }: TypeaheadMenuPluginProps): JSX.Element | null { + const previousText = useRef(''); const [editor] = useLexicalComposerContext(); const [resolution, setResolution] = useState(null); const [menuVisible, setMenuVisible] = useState(false); @@ -715,6 +716,12 @@ export function TypeaheadMenuPlugin({ return; } + const isInitialTextSet = previousText.current === '' && text.length > 1; + previousText.current = text; + if (isInitialTextSet) { + // Do not trigger the typeahead when the input first loads (goes from empty to a text larger than 1 char) + return; + } const match = triggerFn(text, editor); onQueryChange(match ? match.matchingString : null); From 933ce4d1a970832610b5e2cf7a38db59bd1686b6 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Wed, 25 Oct 2023 09:46:42 +0200 Subject: [PATCH 18/86] runfix: Add missing uie-name on mentions (#16105) --- .../RightSidebar/MessageDetails/UserReactions.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/script/page/RightSidebar/MessageDetails/UserReactions.tsx b/src/script/page/RightSidebar/MessageDetails/UserReactions.tsx index d2ea6bce5f7..9e4df5d5b68 100644 --- a/src/script/page/RightSidebar/MessageDetails/UserReactions.tsx +++ b/src/script/page/RightSidebar/MessageDetails/UserReactions.tsx @@ -59,14 +59,9 @@ export function UsersReactions({reactions, selfUser, findUsers, onParticipantCli {capitalizedEmojiName} ({emojiCount})
- +
+ +
); }); From bbe6f477988a7a58790d4470b6362ae9e427b086 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Wed, 25 Oct 2023 14:42:03 +0200 Subject: [PATCH 19/86] runfix: Return reaction event instead of targeted message event (#16106) --- src/script/conversation/ConversationRepository.ts | 2 +- src/script/event/EventService.ts | 7 ++++--- .../EventStorageMiddleware/EventStorageMiddleware.ts | 2 +- .../eventHandlers/reactionEventHandler.ts | 3 ++- .../event/preprocessor/EventStorageMiddleware/types.ts | 2 +- test/unit_tests/event/EventServiceCommon.js | 8 ++++---- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index de5ef83f457..4b3335518bd 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -2905,7 +2905,7 @@ export class ConversationRepository { } const changes = messageEntity.getSelectionChange(buttonId); if (changes) { - this.eventService.updateEventSequentially(messageEntity.primary_key, changes); + await this.eventService.updateEventSequentially({primary_key: messageEntity.primary_key, ...changes}); } } catch (error) { const isNotFound = error.type === ConversationError.TYPE.MESSAGE_NOT_FOUND; diff --git a/src/script/event/EventService.ts b/src/script/event/EventService.ts index 171e696043a..fc171785dd9 100644 --- a/src/script/event/EventService.ts +++ b/src/script/event/EventService.ts @@ -424,12 +424,13 @@ export class EventService { * @param primaryKey Event primary key * @param changes Changes to update message with */ - async updateEventSequentially(primaryKey: string, changes: Partial = {}): Promise { + async updateEventSequentially(changes: IdentifiedUpdatePayload): Promise { const hasVersionedChanges = !!changes.version; if (!hasVersionedChanges) { throw new ConversationError(ConversationError.TYPE.WRONG_CHANGE, ConversationError.MESSAGE.WRONG_CHANGE); } + const {primary_key: primaryKey, ...updates} = changes; if (this.storageService.db) { // Create a DB transaction to avoid concurrent sequential update. return this.storageService.db.transaction('rw', StorageSchemata.OBJECT_STORE.EVENTS, async () => { @@ -443,7 +444,7 @@ export class EventService { const databaseVersion = record.version || 1; const isSequentialUpdate = changes.version === databaseVersion + 1; if (isSequentialUpdate) { - return this.storageService.update(StorageSchemata.OBJECT_STORE.EVENTS, primaryKey, changes); + return this.storageService.update(StorageSchemata.OBJECT_STORE.EVENTS, primaryKey, updates); } const logMessage = 'Failed sequential database update'; const logObject = { @@ -454,7 +455,7 @@ export class EventService { throw new StorageError(StorageError.TYPE.NON_SEQUENTIAL_UPDATE, StorageError.MESSAGE.NON_SEQUENTIAL_UPDATE); }); } - return this.storageService.update(StorageSchemata.OBJECT_STORE.EVENTS, primaryKey, changes); + return this.storageService.update(StorageSchemata.OBJECT_STORE.EVENTS, primaryKey, updates); } /** diff --git a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts index 0fa8f486e73..be2a6be776e 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts @@ -104,7 +104,7 @@ export class EventStorageMiddleware implements EventMiddleware { break; case 'sequential-update': - await this.eventService.updateEventSequentially(operation.event.primary_key, operation.updates); + await this.eventService.updateEventSequentially(operation.updates); break; case 'delete': diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts index 81d32303287..52c247e4df5 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/reactionEventHandler.ts @@ -35,6 +35,7 @@ function computeEventUpdates(target: StoredEvent, reactionEvent } = reactionEvent; const reactionMap = target.reactions ? userReactionMapToReactionMap(target.reactions) : []; return { + primary_key: target.primary_key, reactions: addReaction(reactionMap, reaction, qualified_from ?? {id: from, domain: ''}), version: version, }; @@ -50,7 +51,7 @@ export const handleReactionEvent: EventHandler = async (event, {findEvent}) => { } return { type: 'sequential-update', - event: targetEvent, + event, updates: computeEventUpdates(targetEvent, event), }; }; diff --git a/src/script/event/preprocessor/EventStorageMiddleware/types.ts b/src/script/event/preprocessor/EventStorageMiddleware/types.ts index 531ff5cfc65..db404ca160d 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/types.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/types.ts @@ -27,7 +27,7 @@ import {IdentifiedUpdatePayload} from '../../EventService'; export type HandledEvents = ClientConversationEvent | ConversationEvent; export type DBOperation = | {type: 'update'; event: HandledEvents; updates: IdentifiedUpdatePayload} - | {type: 'sequential-update'; event: EventRecord; updates: Partial} + | {type: 'sequential-update'; event: HandledEvents; updates: IdentifiedUpdatePayload} | {type: 'delete'; event: HandledEvents; id: string} | {type: 'insert'; event: HandledEvents}; diff --git a/test/unit_tests/event/EventServiceCommon.js b/test/unit_tests/event/EventServiceCommon.js index 0c60d834c29..70f5ae6cfd1 100644 --- a/test/unit_tests/event/EventServiceCommon.js +++ b/test/unit_tests/event/EventServiceCommon.js @@ -372,7 +372,7 @@ const testEventServiceClass = (testedServiceName, className) => { it('fails if changes do not contain version property', () => { const updates = {reactions: ['user-id']}; return testFactory[testedServiceName] - .updateEventSequentially(12, updates) + .updateEventSequentially({primary_key: 12, ...updates}) .then(fail) .catch(error => { expect(error.type).toBe(ConversationError.TYPE.WRONG_CHANGE); @@ -385,7 +385,7 @@ const testEventServiceClass = (testedServiceName, className) => { spyOn(testFactory.storage_service, 'load').and.returnValue(Promise.resolve({version: 2})); return testFactory[testedServiceName] - .updateEventSequentially(12, updates) + .updateEventSequentially({primary_key: 12, ...updates}) .then(fail) .catch(error => { expect(error.type).toBe(StorageError.TYPE.NON_SEQUENTIAL_UPDATE); @@ -399,7 +399,7 @@ const testEventServiceClass = (testedServiceName, className) => { spyOn(testFactory.storage_service, 'update').and.returnValue(Promise.resolve('ok')); return testFactory[testedServiceName] - .updateEventSequentially(12, updates) + .updateEventSequentially({primary_key: 12, ...updates}) .then(fail) .catch(error => { expect(error.type).toBe(StorageError.TYPE.NOT_FOUND); @@ -413,7 +413,7 @@ const testEventServiceClass = (testedServiceName, className) => { spyOn(testFactory.storage_service, 'update').and.returnValue(Promise.resolve('ok')); spyOn(testFactory.storage_service.db, 'transaction').and.callThrough(); - return testFactory[testedServiceName].updateEventSequentially(12, updates).then(() => { + return testFactory[testedServiceName].updateEventSequentially({primary_key: 12, ...updates}).then(() => { expect(testFactory.storage_service.update).toHaveBeenCalledWith(eventStoreName, 12, updates); expect(testFactory.storage_service.db.transaction).toHaveBeenCalled(); }); From af5cb0c81013fe885777b7316795bcc14769e984 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Wed, 25 Oct 2023 14:57:31 +0200 Subject: [PATCH 20/86] runfix: Improve Typeahead removal behavior (#16107) --- .../plugins/TypeaheadMenuPlugin.tsx | 71 +++++++------------ 1 file changed, 26 insertions(+), 45 deletions(-) diff --git a/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx b/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx index a012d6adb84..dfd7e944c40 100644 --- a/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx @@ -305,7 +305,7 @@ function useDynamicPositioning( return () => { resizeObserver.unobserve(targetElement); window.removeEventListener('resize', onReposition); - document.removeEventListener('scroll', handleScroll); + document.removeEventListener('scroll', handleScroll, {capture: true}); }; } @@ -321,9 +321,10 @@ export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ function LexicalPopoverMenu({ close, editor, - anchorElementRef, resolution, + setResolution, options, + anchorClassName, menuRenderFn, containerId, onSelectOption, @@ -332,9 +333,10 @@ function LexicalPopoverMenu({ }: { close: () => void; editor: LexicalEditor; - anchorElementRef: MutableRefObject; resolution: Resolution; + setResolution: (r: Resolution | null) => void; containerId: string; + anchorClassName?: string; options: Array; menuRenderFn: MenuRenderFn; onSelectOption: ( @@ -349,9 +351,17 @@ function LexicalPopoverMenu({ const [menuVisible, setMenuVisible] = useState(false); const [selectedIndex, setHighlightedIndex] = useState(null); + const anchorElementRef = useMenuAnchorRef({ + containerId, + resolution: resolution, + setResolution, + className: `typeahead-menu ${anchorClassName || ''}`, + menuVisible, + }); + useEffect(() => { setHighlightedIndex(isReversed ? options.length - 1 : 0); - }, [resolution.match.matchingString, options, isReversed]); + }, [options.length, isReversed]); const selectOptionAndCleanUp = useCallback( (selectedEntry: TOption) => { @@ -414,12 +424,15 @@ function LexicalPopoverMenu({ const currentSelectedOption = options[selectedIndex]; // Using setTimeout because we need to wait for popover render - setTimeout(() => { + const timer = setTimeout(() => { if (currentSelectedOption.ref != null && currentSelectedOption.ref.current) { scrollIntoViewIfNeeded(currentSelectedOption.ref.current, containerId); } }, 16); + + return () => clearTimeout(timer); } + return undefined; }, [editor, menuVisible, selectedIndex, options, containerId]); useEffect(() => { @@ -543,7 +556,7 @@ interface UseMenuAnchorRefOptions { } function useMenuAnchorRef(opt: UseMenuAnchorRefOptions): MutableRefObject { - const {resolution, setResolution, className, menuVisible} = opt; + const {resolution, setResolution, className, containerId} = opt; const [editor] = useLexicalComposerContext(); const anchorElementRef = useRef(document.createElement('div')); const positionMenu = useCallback(() => { @@ -581,7 +594,7 @@ function useMenuAnchorRef(opt: UseMenuAnchorRefOptions): MutableRefObject { - const rootElement = editor.getRootElement(); - if (resolution === null) { - wasInit.current = false; - } - if (resolution !== null && menuVisible && !wasInit.current) { - // Avoid removing/re-adding the menu when the content changes (causes scroll issues and flickering) - wasInit.current = true; - positionMenu(); - return () => { - if (rootElement !== null) { - rootElement.removeAttribute('aria-controls'); - } - - const containerDiv = anchorElementRef.current; - if (containerDiv !== null && containerDiv.isConnected) { - containerDiv.remove(); - } - }; - } - return () => { - const containerDiv = anchorElementRef.current; - - if (containerDiv !== null) { - containerDiv.remove(); - } - return null; + anchorElementRef.current.remove(); }; - }, [editor, positionMenu, resolution, menuVisible]); + }, []); const onVisibilityChange = useCallback( (isInView: boolean) => { @@ -676,13 +663,6 @@ export function TypeaheadMenuPlugin({ const [editor] = useLexicalComposerContext(); const [resolution, setResolution] = useState(null); const [menuVisible, setMenuVisible] = useState(false); - const anchorElementRef = useMenuAnchorRef({ - containerId, - resolution, - setResolution, - className: `typeahead-menu ${anchorClassName || ''}`, - menuVisible, - }); const closeTypeahead = useCallback(() => { setResolution(null); @@ -748,13 +728,14 @@ export function TypeaheadMenuPlugin({ }; }, [editor, triggerFn, onQueryChange, resolution, closeTypeahead, openTypeahead, menuVisible, setMenuVisible]); - return resolution === null || editor === null ? null : ( + return resolution === null || editor === null || options.length === 0 ? null : ( Date: Wed, 25 Oct 2023 16:44:26 +0200 Subject: [PATCH 21/86] runfix: Improve mention typeahead scrolling behavior (#16108) --- .../plugins/MentionsPlugin/MentionsPlugin.tsx | 80 +++++++------ .../plugins/TypeaheadMenuPlugin.tsx | 109 ++++-------------- 2 files changed, 67 insertions(+), 122 deletions(-) diff --git a/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx b/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx index b2399e98772..ca2fd3725ff 100644 --- a/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx @@ -73,6 +73,50 @@ interface MentionsPluginProps { openStateRef: MutableRefObject; } +function MentionMenu({ + getPosition, + options, + selectedIndex, + selectOptionAndCleanUp, + setHighlightedIndex, +}: { + getPosition: () => {bottom: number; left: number}; + options: MenuOption[]; + selectedIndex: number | null; + selectOptionAndCleanUp: (option: MenuOption) => void; + setHighlightedIndex: (index: number) => void; +}) { + const {bottom, left} = getPosition(); + + return ( + + +
+ {options.map((menuOption, index) => ( + { + setHighlightedIndex(index); + selectOptionAndCleanUp(menuOption); + }} + onMouseEnter={() => { + setHighlightedIndex(index); + }} + /> + ))} +
+
+
+ ); +} + export function MentionsPlugin({onSearch, openStateRef}: MentionsPluginProps) { const [editor] = useLexicalComposerContext(); const [queryString, setQueryString] = useState(); @@ -116,44 +160,12 @@ export function MentionsPlugin({onSearch, openStateRef}: MentionsPluginProps) { return {bottom: window.innerHeight - boundingClientRect.top + 24, left: boundingClientRect.left}; }; - const menuRenderFn: MenuRenderFn = ( - anchorElementRef, - {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, - ) => { + const menuRenderFn: MenuRenderFn = (anchorElementRef, params) => { if (!anchorElementRef.current || !options.length) { return null; } - const {bottom, left} = getPosition(); - - return ReactDOM.createPortal( - - -
- {options.map((menuOption, index) => ( - { - setHighlightedIndex(index); - selectOptionAndCleanUp(menuOption); - }} - onMouseEnter={() => { - setHighlightedIndex(index); - }} - /> - ))} -
-
-
, - anchorElementRef.current, - ); + return ReactDOM.createPortal(, anchorElementRef.current); }; openStateRef.current = options.length > 0; diff --git a/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx b/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx index dfd7e944c40..79ac8accdac 100644 --- a/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx @@ -43,13 +43,11 @@ import { $isTextNode, COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, - createCommand, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, - LexicalCommand, LexicalEditor, RangeSelection, TextNode, @@ -94,28 +92,10 @@ export type MenuRenderFn = ( matchingString: string, ) => ReactPortal | JSX.Element | null; -const scrollIntoViewIfNeeded = (target: HTMLElement, containerId: string) => { - const typeaheadContainerNode = document.getElementById(containerId); - - if (!typeaheadContainerNode) { - return; - } - - const typeaheadRect = typeaheadContainerNode.getBoundingClientRect(); - - if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) { - typeaheadContainerNode.scrollIntoView({ - block: 'center', - }); - } - - if (typeaheadRect.top < 0) { - typeaheadContainerNode.scrollIntoView({ - block: 'center', - }); - } - - target.scrollIntoView({block: 'nearest'}); +const scrollToOption = (index: number, options: TOption[]) => { + const selectedOption = options[index]; + const element = selectedOption && selectedOption.ref?.current; + element?.scrollIntoView({block: 'nearest'}); }; function getTextUpToAnchor(selection: RangeSelection): string | null { @@ -313,11 +293,6 @@ function useDynamicPositioning( }, [targetElement, editor, onVisibilityChange, onReposition, resolution]); } -export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ - index: number; - option: TypeaheadOption; -}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); - function LexicalPopoverMenu({ close, editor, @@ -350,6 +325,7 @@ function LexicalPopoverMenu({ }): JSX.Element | null { const [menuVisible, setMenuVisible] = useState(false); const [selectedIndex, setHighlightedIndex] = useState(null); + const defaultSelectedIndex = isReversed ? options.length - 1 : 0; const anchorElementRef = useMenuAnchorRef({ containerId, @@ -357,11 +333,21 @@ function LexicalPopoverMenu({ setResolution, className: `typeahead-menu ${anchorClassName || ''}`, menuVisible, + onAdded: () => { + // when the menu first renders, we scroll to the initially selected element + scrollToOption(defaultSelectedIndex, options); + }, }); useEffect(() => { - setHighlightedIndex(isReversed ? options.length - 1 : 0); - }, [options.length, isReversed]); + setHighlightedIndex(defaultSelectedIndex); + }, [defaultSelectedIndex]); + + useEffect(() => { + if (selectedIndex !== null) { + scrollToOption(selectedIndex, options); + } + }, [options, selectedIndex]); const selectOptionAndCleanUp = useCallback( (selectedEntry: TOption) => { @@ -394,47 +380,6 @@ function LexicalPopoverMenu({ }; }, [editor]); - useLayoutEffect(() => { - if (options === null) { - setHighlightedIndex(null); - } else if (selectedIndex === null) { - updateSelectedIndex(options.length - 1); - } - }, [options, selectedIndex, updateSelectedIndex]); - - useEffect(() => { - return mergeRegister( - editor.registerCommand( - SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, - ({option}) => { - if (option.ref && option.ref.current != null) { - scrollIntoViewIfNeeded(option.ref.current, containerId); - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW, - ), - ); - }, [editor, updateSelectedIndex, containerId]); - - useEffect(() => { - if (menuVisible && typeof selectedIndex === 'number' && options?.[selectedIndex]) { - const currentSelectedOption = options[selectedIndex]; - - // Using setTimeout because we need to wait for popover render - const timer = setTimeout(() => { - if (currentSelectedOption.ref != null && currentSelectedOption.ref.current) { - scrollIntoViewIfNeeded(currentSelectedOption.ref.current, containerId); - } - }, 16); - - return () => clearTimeout(timer); - } - return undefined; - }, [editor, menuVisible, selectedIndex, options, containerId]); - useEffect(() => { return mergeRegister( editor.registerCommand( @@ -442,15 +387,8 @@ function LexicalPopoverMenu({ payload => { const event = payload; if (options !== null && options.length && selectedIndex !== null) { - const newSelectedIndex = selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0; + const newSelectedIndex = (selectedIndex + 1) % options.length; updateSelectedIndex(newSelectedIndex); - const option = options[newSelectedIndex]; - if (option.ref != null && option.ref.current) { - editor.dispatchCommand(SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, { - index: newSelectedIndex, - option, - }); - } event.preventDefault(); event.stopImmediatePropagation(); } @@ -463,15 +401,8 @@ function LexicalPopoverMenu({ payload => { const event = payload; if (options !== null && options.length && selectedIndex !== null) { - const newSelectedIndex = selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1; + const newSelectedIndex = selectedIndex > 0 ? selectedIndex - 1 : options.length - 1; updateSelectedIndex(newSelectedIndex); - const option = options[newSelectedIndex]; - if (option.ref != null && option.ref.current) { - editor.dispatchCommand(SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, { - index: newSelectedIndex, - option, - }); - } event.preventDefault(); event.stopImmediatePropagation(); } @@ -553,6 +484,7 @@ interface UseMenuAnchorRefOptions { setResolution: (r: Resolution | null) => void; className?: string; menuVisible?: boolean; + onAdded?: () => void; } function useMenuAnchorRef(opt: UseMenuAnchorRefOptions): MutableRefObject { @@ -599,6 +531,7 @@ function useMenuAnchorRef(opt: UseMenuAnchorRefOptions): MutableRefObject Date: Wed, 25 Oct 2023 17:52:03 +0200 Subject: [PATCH 22/86] runfix: Make sure mention menu is positioned on init (#16109) --- .../RichTextEditor/plugins/TypeaheadMenuPlugin.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx b/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx index 79ac8accdac..95b276b620b 100644 --- a/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/TypeaheadMenuPlugin.tsx @@ -253,6 +253,11 @@ function useDynamicPositioning( onVisibilityChange?: (isInView: boolean) => void, ) { const [editor] = useLexicalComposerContext(); + useEffect(() => { + // Trigger initial positioning + onReposition(); + }, []); + useEffect(() => { if (targetElement != null && resolution != null) { const rootElement = editor.getRootElement(); @@ -488,7 +493,7 @@ interface UseMenuAnchorRefOptions { } function useMenuAnchorRef(opt: UseMenuAnchorRefOptions): MutableRefObject { - const {resolution, setResolution, className, containerId} = opt; + const {resolution, setResolution, className, containerId, onAdded} = opt; const [editor] = useLexicalComposerContext(); const anchorElementRef = useRef(document.createElement('div')); const positionMenu = useCallback(() => { @@ -531,12 +536,12 @@ function useMenuAnchorRef(opt: UseMenuAnchorRefOptions): MutableRefObject { return () => { From 736f1dfd494ce1ed4fa8133588fcccee360519cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Oct 2023 16:52:51 +0000 Subject: [PATCH 23/86] chore(deps): Bump crowdin/github-action from 1.13.1 to 1.14.0 (#16110) Bumps [crowdin/github-action](https://github.com/crowdin/github-action) from 1.13.1 to 1.14.0. - [Release notes](https://github.com/crowdin/github-action/releases) - [Commits](https://github.com/crowdin/github-action/compare/v1.13.1...v1.14.0) --- updated-dependencies: - dependency-name: crowdin/github-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync_translations.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync_translations.yml b/.github/workflows/sync_translations.yml index df66e5fa8af..538e7f1fa1d 100644 --- a/.github/workflows/sync_translations.yml +++ b/.github/workflows/sync_translations.yml @@ -37,7 +37,7 @@ jobs: run: yarn translate:merge - name: Download translations - uses: crowdin/github-action@v1.13.1 + uses: crowdin/github-action@v1.14.0 env: GITHUB_TOKEN: ${{secrets.OTTO_THE_BOT_GH_TOKEN}} CROWDIN_PROJECT_ID: 342359 From c5ff1b41e28e29a472eebfb5f6d14c917125805a Mon Sep 17 00:00:00 2001 From: Arjita Date: Thu, 26 Oct 2023 10:01:46 +0200 Subject: [PATCH 24/86] fix: Editing username not possible to add some characters(WPB-4354) (#16111) --- src/i18n/en-US.json | 2 +- .../panels/preferences/accountPreferences/UsernameInput.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/script/page/MainContent/panels/preferences/accountPreferences/UsernameInput.tsx b/src/script/page/MainContent/panels/preferences/accountPreferences/UsernameInput.tsx index 972f8a1d268..49bcf6b864a 100644 --- a/src/script/page/MainContent/panels/preferences/accountPreferences/UsernameInput.tsx +++ b/src/script/page/MainContent/panels/preferences/accountPreferences/UsernameInput.tsx @@ -123,7 +123,7 @@ const UsernameInput: React.FC = ({username, domain, userRepo isDone={usernameInputDone.isDone} onValueChange={changeUsername} maxLength={256 - (domain?.length ?? 0)} - allowedChars="0-9a-zA-Z_" + allowedChars="0-9a-zA-Z_.-" fieldName="username" /> {canEditProfile && ( From 4941905623983078e0894eb1528759f9e8bc2233 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Thu, 26 Oct 2023 10:52:45 +0200 Subject: [PATCH 25/86] runfix: Handle deleting mentions using the delete key (#16104) --- .../components/RichTextEditor/nodes/Mention.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/script/components/RichTextEditor/nodes/Mention.tsx b/src/script/components/RichTextEditor/nodes/Mention.tsx index 6332eaf126f..49a46b8f604 100644 --- a/src/script/components/RichTextEditor/nodes/Mention.tsx +++ b/src/script/components/RichTextEditor/nodes/Mention.tsx @@ -80,13 +80,21 @@ export const Mention = (props: MentionComponentProps) => { const currentSelection = $getSelection(); const rangeSelection = $isRangeSelection(currentSelection) ? currentSelection : null; - const shouldSelect = nodeKey === rangeSelection?.getNodes()[0]?.getKey(); + let shouldSelectNode = false; + if (event.key === 'Backspace') { + shouldSelectNode = nodeKey === rangeSelection?.getNodes()[0]?.getKey(); + } else if (event.key === 'Delete') { + const currentNode = rangeSelection?.getNodes()[0]; + const isOnTheEdgeOfNode = currentNode?.getTextContent().length === rangeSelection?.focus.offset; + shouldSelectNode = currentNode?.getNextSibling()?.getKey() === nodeKey && isOnTheEdgeOfNode; + } // If the cursor is right before the mention, we first select the mention before deleting it - if (shouldSelect) { + if (shouldSelectNode) { event.preventDefault(); setSelected(true); return true; } + // When the mention is selected, we actually delete it if (isSelected && $isNodeSelection($getSelection())) { event.preventDefault(); @@ -98,7 +106,6 @@ export const Mention = (props: MentionComponentProps) => { setSelected(false); } - return false; }, [isSelected, nodeKey, setSelected], From e2e6e3de26f31a81d089822155375431074fbfd2 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Thu, 26 Oct 2023 12:08:06 +0200 Subject: [PATCH 26/86] runfix: gracefully log event validation errors (#16113) --- src/script/event/EventRepository.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/script/event/EventRepository.ts b/src/script/event/EventRepository.ts index 175c18e804b..f22d0d7ac27 100644 --- a/src/script/event/EventRepository.ts +++ b/src/script/event/EventRepository.ts @@ -44,6 +44,7 @@ import {EventValidation} from './EventValidation'; import {validateEvent} from './EventValidator'; import {NOTIFICATION_HANDLING_STATE} from './NotificationHandlingState'; import type {NotificationService} from './NotificationService'; +import {EventValidationError} from './preprocessor/EventStorageMiddleware/eventHandlers/EventValidationError'; import {ClientConversationEvent, EventBuilder} from '../conversation/EventBuilder'; import {CryptographyMapper} from '../cryptography/CryptographyMapper'; @@ -295,7 +296,10 @@ export class EventRepository { * @param source Source of injection * @returns Resolves when the event has been processed */ - async injectEvent(event: ClientConversationEvent | IncomingEvent, source: EventSource = EventSource.INJECTED) { + async injectEvent( + event: ClientConversationEvent | IncomingEvent, + source: EventSource = EventSource.INJECTED, + ): Promise { if (!event) { throw new EventError(EventError.TYPE.NO_EVENT, EventError.MESSAGE.NO_EVENT); } @@ -343,7 +347,6 @@ export class EventRepository { } // Wait for the event handlers to have finished their async tasks await new Promise(res => setTimeout(res, 0)); - return event; } /** @@ -412,8 +415,16 @@ export class EventRepository { * @returns Resolves with the saved record or `true` if the event was skipped */ private async processEvent(event: IncomingEvent | ClientConversationEvent, source: EventSource) { - for (const eventProcessMiddleware of this.eventProcessMiddlewares) { - event = await eventProcessMiddleware.processEvent(event); + try { + for (const eventProcessMiddleware of this.eventProcessMiddlewares) { + event = await eventProcessMiddleware.processEvent(event); + } + } catch (error) { + if (error instanceof EventValidationError) { + this.logger.warn(`Event validation failed: ${error.message}`, error); + return; + } + throw error; } return this.handleEventDistribution(event, source); From 88e648713eb496f6635080177735c0f000705365 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Thu, 26 Oct 2023 12:13:03 +0200 Subject: [PATCH 27/86] runfix: do not mark link preview as edited messages (#16114) --- .../EventStorageMiddleware/EventStorageMiddleware.test.ts | 2 ++ .../EventStorageMiddleware/eventHandlers/editedEventHandler.ts | 2 +- .../eventHandlers/getCommonMessageUpdates.ts | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.test.ts b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.test.ts index 60ffc4fb123..baeb2c6f06d 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.test.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.test.ts @@ -185,6 +185,7 @@ describe('EventStorageMiddleware', () => { linkPreviewEvent.data.previews = ['preview']; const updatedEvent = (await eventStorageMiddleware.processEvent(linkPreviewEvent)) as any; + expect(updatedEvent.edited_time).not.toBeUndefined(); expect(eventService.replaceEvent).toHaveBeenCalled(); expect(eventService.saveEvent).not.toHaveBeenCalled(); expect(updatedEvent.data.previews[0]).toEqual('preview'); @@ -208,6 +209,7 @@ describe('EventStorageMiddleware', () => { const updatedEvent = (await eventStorageMiddleware.processEvent(event)) as any; expect(updatedEvent.time).toEqual(initial_time); expect(updatedEvent.time).not.toEqual(changed_time); + expect(updatedEvent.edited_time).toEqual(changed_time); expect(updatedEvent.data.content).toEqual('new content'); expect(updatedEvent.primary_key).toEqual(originalEvent.primary_key); expect(Object.keys(updatedEvent.reactions).length).toEqual(0); diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/editedEventHandler.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/editedEventHandler.ts index 80f5f88065f..8d41c9195a6 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/editedEventHandler.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/editedEventHandler.ts @@ -56,7 +56,7 @@ function getUpdatesForEditMessage( // Remove reactions, so that likes (hearts) don't stay when a message's text gets edited const commonUpdates = getCommonMessageUpdates(originalEvent, newEvent); - return {...newEvent, ...commonUpdates, reactions: {}}; + return {...newEvent, ...commonUpdates, edited_time: newEvent.time, reactions: {}}; } function computeEventUpdates(originalEvent: StoredEvent, newEvent: MessageAddEvent) { diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/getCommonMessageUpdates.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/getCommonMessageUpdates.ts index 776a890cefc..0de5128b5ef 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/getCommonMessageUpdates.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/getCommonMessageUpdates.ts @@ -24,7 +24,6 @@ export function getCommonMessageUpdates(originalEvent: StoredEvent Date: Thu, 26 Oct 2023 13:32:07 +0200 Subject: [PATCH 28/86] feat: bump core with new core-crypto version (#16115) --- package.json | 2 +- webpack.config.common.js | 2 +- yarn.lock | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index a974ff3c704..ba0cbdca187 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@peculiar/x509": "1.9.5", "@wireapp/avs": "9.5.2", "@wireapp/commons": "5.2.2", - "@wireapp/core": "42.18.0", + "@wireapp/core": "42.19.0", "@wireapp/lru-cache": "3.8.1", "@wireapp/react-ui-kit": "9.9.12", "@wireapp/store-engine-dexie": "2.1.6", diff --git a/webpack.config.common.js b/webpack.config.common.js index bdf15610475..d557c5e2a6e 100644 --- a/webpack.config.common.js +++ b/webpack.config.common.js @@ -146,7 +146,7 @@ module.exports = { new CopyPlugin({ patterns: [ { - context: 'node_modules/@wireapp/core-crypto/platforms/web/assets', + context: 'node_modules/@wireapp/core-crypto/platforms/web', from: '*.wasm', to: `${dist}/min/core-crypto.wasm`, }, diff --git a/yarn.lock b/yarn.lock index 20286e269d5..7ea5ec58acf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5358,20 +5358,20 @@ __metadata: languageName: node linkType: hard -"@wireapp/core-crypto@npm:1.0.0-rc.13": - version: 1.0.0-rc.13 - resolution: "@wireapp/core-crypto@npm:1.0.0-rc.13" - checksum: 8edbf8ffbda8db2b0a2a9dd93fe5c823fb337bb6f4eef0333d3c96ea8d594739b50511eb8d463e11863205537e505fe78fe8a25fcf6e61c675ad30a335cf1b5d +"@wireapp/core-crypto@npm:1.0.0-rc.16": + version: 1.0.0-rc.16 + resolution: "@wireapp/core-crypto@npm:1.0.0-rc.16" + checksum: 95061f0a0ee69205492ffb254a60c81be604b6a7d76e5f3e512129c2a5669adc39114e343f50002ff77db7302e1cfd39ac8dd684a87d28555b95c1e4fda7a4f3 languageName: node linkType: hard -"@wireapp/core@npm:42.18.0": - version: 42.18.0 - resolution: "@wireapp/core@npm:42.18.0" +"@wireapp/core@npm:42.19.0": + version: 42.19.0 + resolution: "@wireapp/core@npm:42.19.0" dependencies: "@wireapp/api-client": ^26.5.0 "@wireapp/commons": ^5.2.2 - "@wireapp/core-crypto": 1.0.0-rc.13 + "@wireapp/core-crypto": 1.0.0-rc.16 "@wireapp/cryptobox": 12.8.0 "@wireapp/promise-queue": ^2.2.7 "@wireapp/protocol-messaging": 1.44.0 @@ -5387,7 +5387,7 @@ __metadata: long: ^5.2.0 uuidjs: 4.2.13 zod: 3.22.4 - checksum: 348ea24ff583893ec1a229d0eca09b7963d37727bb31fd358ad7d19e8c8f532c7330a4cf27e4952744bad9431026c53bc090adf8eb357e83973af616b33fc347 + checksum: a5b26d66d215b697f5da53e85afbdf869a870ee7f56224cd74517e9d1134bad24450b45ce3822608d9b2de37a0fbf720a660bed4f5427edca87b8344b90260b3 languageName: node linkType: hard @@ -18514,7 +18514,7 @@ __metadata: "@wireapp/avs": 9.5.2 "@wireapp/commons": 5.2.2 "@wireapp/copy-config": 2.1.10 - "@wireapp/core": 42.18.0 + "@wireapp/core": 42.19.0 "@wireapp/eslint-config": 3.0.4 "@wireapp/lru-cache": 3.8.1 "@wireapp/prettier-config": 0.6.3 From 684ee466bcb44a038bbd342bb029cd785e2a4374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20G=C3=B3rka?= Date: Thu, 26 Oct 2023 16:32:57 +0200 Subject: [PATCH 29/86] refactor: move subconverstion logic to core (#16116) * refactor: move subconverstion logic to core * test: improve core mock --- package.json | 2 +- src/__mocks__/@wireapp/core.ts | 7 +- src/script/calling/CallingRepository.ts | 17 +-- src/script/calling/mlsConference.ts | 135 ------------------ .../view_model/CallingViewModel.mocks.ts | 82 +---------- .../view_model/CallingViewModel.test.ts | 124 ++-------------- src/script/view_model/CallingViewModel.ts | 93 +++--------- yarn.lock | 10 +- 8 files changed, 53 insertions(+), 417 deletions(-) delete mode 100644 src/script/calling/mlsConference.ts diff --git a/package.json b/package.json index ba0cbdca187..94ba134bfdd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@peculiar/x509": "1.9.5", "@wireapp/avs": "9.5.2", "@wireapp/commons": "5.2.2", - "@wireapp/core": "42.19.0", + "@wireapp/core": "42.19.1", "@wireapp/lru-cache": "3.8.1", "@wireapp/react-ui-kit": "9.9.12", "@wireapp/store-engine-dexie": "2.1.6", diff --git a/src/__mocks__/@wireapp/core.ts b/src/__mocks__/@wireapp/core.ts index edf45749d6f..68501e7c8bd 100644 --- a/src/__mocks__/@wireapp/core.ts +++ b/src/__mocks__/@wireapp/core.ts @@ -37,14 +37,12 @@ export class Account extends EventEmitter { mls: { schedulePeriodicKeyMaterialRenewals: jest.fn(), registerConversation: jest.fn(), - joinConferenceSubconversation: jest.fn(), getGroupIdFromConversationId: jest.fn(), renewKeyMaterial: jest.fn(), getClientIds: jest.fn(), getEpoch: jest.fn(), conversationExists: jest.fn(), exportSecretKey: jest.fn(), - leaveConferenceSubconversation: jest.fn(), on: this.on, emit: this.emit, off: this.off, @@ -64,6 +62,11 @@ export class Account extends EventEmitter { removeUsersFromMLSConversation: jest.fn(), removeUserFromConversation: jest.fn(), }, + subconversation: { + joinConferenceSubconversation: jest.fn(), + leaveConferenceSubconversation: jest.fn(), + subscribeToEpochUpdates: jest.fn(), + }, client: { deleteClient: jest.fn(), }, diff --git a/src/script/calling/CallingRepository.ts b/src/script/calling/CallingRepository.ts index 289f732273c..f284ad256cf 100644 --- a/src/script/calling/CallingRepository.ts +++ b/src/script/calling/CallingRepository.ts @@ -23,6 +23,7 @@ import type {QualifiedId} from '@wireapp/api-client/lib/user'; import type {WebappProperties} from '@wireapp/api-client/lib/user/data'; import {MessageSendingState} from '@wireapp/core/lib/conversation'; import {flattenUserMap} from '@wireapp/core/lib/conversation/message/UserClientsUtil'; +import {SubconversationEpochInfoMember} from '@wireapp/core/lib/conversation/SubconversationService/SubconversationService'; import {amplify} from 'amplify'; import axios from 'axios'; import ko from 'knockout'; @@ -117,13 +118,7 @@ enum CALL_DIRECTION { OUTGOING = 'outgoing', } -export interface SubconversationEpochInfoMember { - userid: `${string}@${string}`; - clientid: string; - in_subconv: boolean; -} - -type SubconversationData = {epoch: number; secretKey: string}; +type SubconversationData = {epoch: number; secretKey: string; members: SubconversationEpochInfoMember[]}; export class CallingRepository { private readonly acceptVersionWarning: (conversationId: QualifiedId) => void; @@ -883,13 +878,9 @@ export class CallingRepository { } } - setEpochInfo( - conversationId: QualifiedId, - subconversationData: SubconversationData, - members: SubconversationEpochInfoMember[], - ) { + setEpochInfo(conversationId: QualifiedId, subconversationData: SubconversationData) { const serializedConversationId = this.serializeQualifiedId(conversationId); - const {epoch, secretKey} = subconversationData; + const {epoch, secretKey, members} = subconversationData; const clients = { convid: serializedConversationId, clients: members, diff --git a/src/script/calling/mlsConference.ts b/src/script/calling/mlsConference.ts deleted file mode 100644 index f6cc66921d1..00000000000 --- a/src/script/calling/mlsConference.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * 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 {SUBCONVERSATION_ID} from '@wireapp/api-client/lib/conversation/Subconversation'; -import {QualifiedId} from '@wireapp/api-client/lib/user'; -import {MLSService} from '@wireapp/core/lib/messagingProtocols/mls'; -import {constructFullyQualifiedClientId} from '@wireapp/core/lib/util/fullyQualifiedClientIdUtils'; - -import {SubconversationEpochInfoMember} from './CallingRepository'; - -import {ConversationState} from '../conversation/ConversationState'; - -const KEY_LENGTH = 32; - -const generateSubconversationMembers = async ( - {mlsService}: {mlsService: MLSService}, - subconversationGroupId: string, - parentGroupId: string, -): Promise => { - const subconversationMemberIds = await mlsService.getClientIds(subconversationGroupId); - const parentMemberIds = await mlsService.getClientIds(parentGroupId); - - return parentMemberIds.map(parentMember => { - const isSubconversationMember = subconversationMemberIds.some( - ({userId, clientId, domain}) => - constructFullyQualifiedClientId(userId, clientId, domain) === - constructFullyQualifiedClientId(parentMember.userId, parentMember.clientId, parentMember.domain), - ); - - return { - userid: `${parentMember.userId}@${parentMember.domain}`, - clientid: parentMember.clientId, - in_subconv: isSubconversationMember, - }; - }); -}; - -export const getSubconversationEpochInfo = async ( - {mlsService}: {mlsService: MLSService}, - conversationId: QualifiedId, - shouldAdvanceEpoch = false, -): Promise<{ - members: SubconversationEpochInfoMember[]; - epoch: number; - secretKey: string; - keyLength: number; -}> => { - const subconversationGroupId = await mlsService.getGroupIdFromConversationId( - conversationId, - SUBCONVERSATION_ID.CONFERENCE, - ); - - const parentGroupId = await mlsService.getGroupIdFromConversationId(conversationId); - - // this method should not be called if the subconversation (and its parent conversation) is not established - if (!subconversationGroupId || !parentGroupId) { - throw new Error( - `Could not obtain epoch info for conference subconversation of conversation ${JSON.stringify( - conversationId, - )}: parent or subconversation group ID is missing`, - ); - } - - const members = await generateSubconversationMembers({mlsService}, subconversationGroupId, parentGroupId); - - if (shouldAdvanceEpoch) { - await mlsService.renewKeyMaterial(subconversationGroupId); - } - - const epoch = Number(await mlsService.getEpoch(subconversationGroupId)); - - const secretKey = await mlsService.exportSecretKey(subconversationGroupId, KEY_LENGTH); - - return {members, epoch, keyLength: KEY_LENGTH, secretKey}; -}; - -export const subscribeToEpochUpdates = async ( - {mlsService, conversationState}: {mlsService: MLSService; conversationState: ConversationState}, - conversationId: QualifiedId, - onEpochUpdate: (info: { - members: SubconversationEpochInfoMember[]; - epoch: number; - secretKey: string; - keyLength: number; - }) => void, -): Promise<() => void> => { - const {epoch: initialEpoch, groupId: subconversationGroupId} = - await mlsService.joinConferenceSubconversation(conversationId); - - const forwardNewEpoch = async ({groupId, epoch}: {groupId: string; epoch: number}) => { - if (groupId !== subconversationGroupId) { - // if the epoch update did not happen in the subconversation directly, check if it happened in the parent conversation - const parentConversation = conversationState.findConversationByGroupId(groupId); - if (!parentConversation) { - return; - } - - const foundSubconversationGroupId = await mlsService.getGroupIdFromConversationId?.( - parentConversation.qualifiedId, - SUBCONVERSATION_ID.CONFERENCE, - ); - - // if the conference subconversation of parent conversation is not known, ignore the epoch update - if (foundSubconversationGroupId !== subconversationGroupId) { - return; - } - } - - const {keyLength, secretKey, members} = await getSubconversationEpochInfo({mlsService}, conversationId); - - return onEpochUpdate({epoch: Number(epoch), keyLength, secretKey, members}); - }; - - mlsService.on('newEpoch', forwardNewEpoch); - - await forwardNewEpoch({groupId: subconversationGroupId, epoch: initialEpoch}); - - return () => mlsService.off('newEpoch', forwardNewEpoch); -}; diff --git a/src/script/view_model/CallingViewModel.mocks.ts b/src/script/view_model/CallingViewModel.mocks.ts index 1028625cc89..8a8b9257703 100644 --- a/src/script/view_model/CallingViewModel.mocks.ts +++ b/src/script/view_model/CallingViewModel.mocks.ts @@ -51,9 +51,8 @@ export function buildCall(conversationId: QualifiedId, convType = CONV_TYPE.ONEO } as any); } -const mockCore = container.resolve(Core); - export function buildCallingViewModel() { + const mockCore = container.resolve(Core); const callingViewModel = new CallingViewModel( mockCallingRepository, {} as any, @@ -70,82 +69,5 @@ export function buildCallingViewModel() { mockCore, ); - return callingViewModel; + return [callingViewModel, {core: mockCore}] as const; } - -export const prepareMLSConferenceMocks = (parentGroupId: string, subGroupId: string) => { - const mockGetClientIdsResponses = { - [parentGroupId]: [ - {userId: 'userId1', clientId: 'clientId1', domain: 'example.com'}, - {userId: 'userId1', clientId: 'clientId1A', domain: 'example.com'}, - {userId: 'userId2', clientId: 'clientId2', domain: 'example.com'}, - {userId: 'userId2', clientId: 'clientId2A', domain: 'example.com'}, - {userId: 'userId3', clientId: 'clientId3', domain: 'example.com'}, - ], - [subGroupId]: [ - {userId: 'userId1', clientId: 'clientId1', domain: 'example.com'}, - {userId: 'userId1', clientId: 'clientId1A', domain: 'example.com'}, - {userId: 'userId2', clientId: 'clientId2', domain: 'example.com'}, - ], - }; - - const expectedMemberListResult = [ - { - userid: 'userId1@example.com', - clientid: 'clientId1', - in_subconv: true, - }, - { - userid: 'userId1@example.com', - clientid: 'clientId1A', - in_subconv: true, - }, - { - userid: 'userId2@example.com', - clientid: 'clientId2', - in_subconv: true, - }, - { - userid: 'userId2@example.com', - clientid: 'clientId2A', - in_subconv: false, - }, - { - userid: 'userId3@example.com', - clientid: 'clientId3', - in_subconv: false, - }, - ]; - - const mockSecretKey = 'secretKey'; - const mockEpochNumber = 1; - - jest - .spyOn(mockCore.service!.mls!, 'joinConferenceSubconversation') - .mockResolvedValue({epoch: mockEpochNumber, groupId: subGroupId}); - - jest - .spyOn(mockCore.service!.mls!, 'getGroupIdFromConversationId') - .mockImplementation((_conversationId, subconversationId) => - subconversationId ? Promise.resolve(subGroupId) : Promise.resolve(parentGroupId), - ); - - jest - .spyOn(mockCore.service!.mls!, 'getClientIds') - .mockImplementation(groupId => - Promise.resolve(mockGetClientIdsResponses[groupId as keyof typeof mockGetClientIdsResponses]), - ); - - jest.spyOn(mockCore.service!.mls!, 'getEpoch').mockImplementation(() => Promise.resolve(mockEpochNumber)); - - jest.spyOn(mockCore.service!.mls!, 'exportSecretKey').mockResolvedValue(mockSecretKey); - - let callClosedCallback: (conversationId: QualifiedId, callType: CONV_TYPE) => void; - - jest.spyOn(mockCallingRepository, 'onCallClosed').mockImplementation(callback => (callClosedCallback = callback)); - jest - .spyOn(mockCallingRepository, 'leaveCall') - .mockImplementation(conversationId => callClosedCallback(conversationId, CONV_TYPE.CONFERENCE_MLS)); - - return {expectedMemberListResult, mockSecretKey, mockEpochNumber}; -}; diff --git a/src/script/view_model/CallingViewModel.test.ts b/src/script/view_model/CallingViewModel.test.ts index 3c3b858e43f..a1abe71daa0 100644 --- a/src/script/view_model/CallingViewModel.test.ts +++ b/src/script/view_model/CallingViewModel.test.ts @@ -17,7 +17,6 @@ * */ -import {waitFor} from '@testing-library/react'; import {ConversationProtocol} from '@wireapp/api-client/lib/conversation'; import {CALL_TYPE, CONV_TYPE, STATE} from '@wireapp/avs'; @@ -25,13 +24,7 @@ import {CALL_TYPE, CONV_TYPE, STATE} from '@wireapp/avs'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; import {createUuid} from 'Util/uuid'; -import { - buildCall, - buildCallingViewModel, - callState, - mockCallingRepository, - prepareMLSConferenceMocks, -} from './CallingViewModel.mocks'; +import {buildCall, buildCallingViewModel, callState, mockCallingRepository} from './CallingViewModel.mocks'; import {LEAVE_CALL_REASON} from '../calling/enum/LeaveCallReason'; import {Conversation} from '../entity/Conversation'; @@ -44,7 +37,7 @@ describe('CallingViewModel', () => { describe('answerCall', () => { it('answers a call directly if no call is ongoing', async () => { - const callingViewModel = buildCallingViewModel(); + const [callingViewModel] = buildCallingViewModel(); const call = buildCall({id: 'conversation1', domain: ''}); await callingViewModel.callActions.answer(call); expect(mockCallingRepository.answerCall).toHaveBeenCalledWith(call); @@ -52,7 +45,7 @@ describe('CallingViewModel', () => { it('lets the user leave previous call before answering a new one', async () => { jest.useFakeTimers(); - const callingViewModel = buildCallingViewModel(); + const [callingViewModel] = buildCallingViewModel(); const joinedCall = buildCall({id: 'conversation1', domain: ''}); joinedCall.state(STATE.MEDIA_ESTAB); callState.calls.push(joinedCall); @@ -73,7 +66,7 @@ describe('CallingViewModel', () => { describe('startCall', () => { it('starts a call directly if no call is ongoing', async () => { - const callingViewModel = buildCallingViewModel(); + const [callingViewModel] = buildCallingViewModel(); const conversation = new Conversation(createUuid()); await callingViewModel.callActions.startAudio(conversation); expect(mockCallingRepository.startCall).toHaveBeenCalledWith(conversation, CALL_TYPE.NORMAL); @@ -81,7 +74,7 @@ describe('CallingViewModel', () => { it('lets the user leave previous call before starting a new one', async () => { jest.useFakeTimers(); - const callingViewModel = buildCallingViewModel(); + const [callingViewModel] = buildCallingViewModel(); const joinedCall = buildCall({id: 'conversation1', domain: ''}); joinedCall.state(STATE.MEDIA_ESTAB); callState.calls.push(joinedCall); @@ -105,15 +98,8 @@ describe('CallingViewModel', () => { jest.useRealTimers(); }); - it('updates epoch info after initiating a call', async () => { - const mockParentGroupId = 'mockParentGroupId1'; - const mockSubGroupId = 'mockSubGroupId1'; - const {expectedMemberListResult, mockEpochNumber, mockSecretKey} = prepareMLSConferenceMocks( - mockParentGroupId, - mockSubGroupId, - ); - - const callingViewModel = buildCallingViewModel(); + it('subscribes to epoch updates after initiating a call', async () => { + const [callingViewModel, {core}] = buildCallingViewModel(); const conversationId = {domain: 'example.com', id: 'conversation1'}; const mlsConversation = new Conversation(conversationId.id, conversationId.domain, ConversationProtocol.MLS); @@ -124,25 +110,15 @@ describe('CallingViewModel', () => { expect(mockCallingRepository.startCall).toHaveBeenCalledWith(mlsConversation, CALL_TYPE.NORMAL); - expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledWith( + expect(core.service?.subconversation.subscribeToEpochUpdates).toHaveBeenCalledWith( conversationId, - { - epoch: mockEpochNumber, - secretKey: mockSecretKey, - }, - expectedMemberListResult, + expect.any(Function), + expect.any(Function), ); }); - it('updates epoch info after answering a call', async () => { - const mockParentGroupId = 'mockParentGroupId2'; - const mockSubGroupId = 'mockSubGroupId2'; - const {expectedMemberListResult, mockEpochNumber, mockSecretKey} = prepareMLSConferenceMocks( - mockParentGroupId, - mockSubGroupId, - ); - - const callingViewModel = buildCallingViewModel(); + it('subscribes to epoch updates after answering a call', async () => { + const [callingViewModel, {core}] = buildCallingViewModel(); const conversationId = {domain: 'example.com', id: 'conversation2'}; const call = buildCall(conversationId, CONV_TYPE.CONFERENCE_MLS); @@ -151,80 +127,10 @@ describe('CallingViewModel', () => { expect(mockCallingRepository.answerCall).toHaveBeenCalledWith(call); - expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledWith( - conversationId, - { - epoch: mockEpochNumber, - secretKey: mockSecretKey, - }, - expectedMemberListResult, - ); - }); - - it('updates epoch info after mls service has emmited "newEpoch" event', async () => { - const mockParentGroupId = 'mockParentGroupId3'; - const mockSubGroupId = 'mockSubGroupId3'; - const {expectedMemberListResult, mockEpochNumber, mockSecretKey} = prepareMLSConferenceMocks( - mockParentGroupId, - mockSubGroupId, - ); - - const callingViewModel = buildCallingViewModel(); - const conversationId = {domain: 'example.com', id: 'conversation3'}; - const call = buildCall(conversationId, CONV_TYPE.CONFERENCE_MLS); - - await callingViewModel.callActions.answer(call); - expect(mockCallingRepository.answerCall).toHaveBeenCalledWith(call); - - //at this point we start to listen to the mls service events - expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledWith( - conversationId, - { - epoch: mockEpochNumber, - secretKey: mockSecretKey, - }, - expectedMemberListResult, - ); - - const newEpochNumber = 2; - callingViewModel.mlsService.emit('newEpoch', { - epoch: newEpochNumber, - groupId: mockSubGroupId, - }); - - await waitFor(() => { - expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledTimes(2); - expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledWith( - conversationId, - { - epoch: newEpochNumber, - secretKey: mockSecretKey, - }, - expectedMemberListResult, - ); - }); - - // once we leave the call, we stop listening to the mls service events - await waitFor(() => { - callingViewModel.callingRepository.leaveCall(conversationId, LEAVE_CALL_REASON.MANUAL_LEAVE_BY_UI_CLICK); - }); - - const anotherEpochNumber = 3; - callingViewModel.mlsService.emit('newEpoch', { - epoch: anotherEpochNumber, - groupId: mockSubGroupId, - }); - - // Wait for all the callback queue tasks to be executed so we know that the function was not called. - // Without this, test will always succeed (even without unsubscribing to epoch changes) because the function was not called YET. - await new Promise(r => setTimeout(r, 0)); - expect(mockCallingRepository.setEpochInfo).not.toHaveBeenCalledWith( + expect(core.service?.subconversation.subscribeToEpochUpdates).toHaveBeenCalledWith( conversationId, - { - epoch: anotherEpochNumber, - secretKey: mockSecretKey, - }, - expectedMemberListResult, + expect.any(Function), + expect.any(Function), ); }); }); diff --git a/src/script/view_model/CallingViewModel.ts b/src/script/view_model/CallingViewModel.ts index a76bf1293af..0becaf214ad 100644 --- a/src/script/view_model/CallingViewModel.ts +++ b/src/script/view_model/CallingViewModel.ts @@ -17,7 +17,6 @@ * */ -import {SUBCONVERSATION_ID} from '@wireapp/api-client/lib/conversation/Subconversation'; import {QualifiedId} from '@wireapp/api-client/lib/user'; import {constructFullyQualifiedClientId} from '@wireapp/core/lib/util/fullyQualifiedClientIdUtils'; import {TaskScheduler} from '@wireapp/core/lib/util/TaskScheduler'; @@ -41,7 +40,6 @@ import {CallingRepository, QualifiedWcallMember} from '../calling/CallingReposit import {callingSubscriptions} from '../calling/callingSubscriptionsHandler'; import {CallState} from '../calling/CallState'; import {LEAVE_CALL_REASON} from '../calling/enum/LeaveCallReason'; -import {getSubconversationEpochInfo, subscribeToEpochUpdates} from '../calling/mlsConference'; import {PrimaryModal} from '../components/Modals/PrimaryModal'; import {Config} from '../Config'; import {ConversationState} from '../conversation/ConversationState'; @@ -161,12 +159,10 @@ export class CallingViewModel { } if (conversation.isUsingMLSProtocol) { - const unsubscribe = await subscribeToEpochUpdates( - {mlsService: this.mlsService, conversationState: this.conversationState}, + const unsubscribe = await this.subconversationService.subscribeToEpochUpdates( conversation.qualifiedId, - ({epoch, keyLength, secretKey, members}) => { - this.callingRepository.setEpochInfo(conversation.qualifiedId, {epoch, secretKey}, members); - }, + (groupId: string) => this.conversationState.findConversationByGroupId(groupId)?.qualifiedId, + data => this.callingRepository.setEpochInfo(conversation.qualifiedId, data), ); callingSubscriptions.addCall(call.conversationId, unsubscribe); @@ -175,12 +171,10 @@ export class CallingViewModel { }; const joinOngoingMlsConference = async (call: Call) => { - const unsubscribe = await subscribeToEpochUpdates( - {mlsService: this.mlsService, conversationState: this.conversationState}, + const unsubscribe = await this.subconversationService.subscribeToEpochUpdates( call.conversationId, - ({epoch, keyLength, secretKey, members}) => { - this.callingRepository.setEpochInfo(call.conversationId, {epoch, secretKey}, members); - }, + (groupId: string) => this.conversationState.findConversationByGroupId(groupId)?.qualifiedId, + data => this.callingRepository.setEpochInfo(call.conversationId, data), ); callingSubscriptions.addCall(call.conversationId, unsubscribe); @@ -217,27 +211,16 @@ export class CallingViewModel { return; } - const subconversationGroupId = await this.mlsService.getGroupIdFromConversationId( + const subconversationEpochInfo = await this.subconversationService.getSubconversationEpochInfo( conversationId, - SUBCONVERSATION_ID.CONFERENCE, + shouldAdvanceEpoch, ); - if (!subconversationGroupId) { + if (!subconversationEpochInfo) { return; } - //we don't want to react to avs callbacks when conversation was not yet established - const doesMLSGroupExist = await this.mlsService.conversationExists(subconversationGroupId); - if (!doesMLSGroupExist) { - return; - } - - const {epoch, secretKey, members} = await getSubconversationEpochInfo( - {mlsService: this.mlsService}, - conversationId, - shouldAdvanceEpoch, - ); - this.callingRepository.setEpochInfo(conversationId, {epoch, secretKey}, members); + this.callingRepository.setEpochInfo(conversationId, subconversationEpochInfo); }; const closeCall = async (conversationId: QualifiedId, conversationType: CONV_TYPE) => { @@ -246,7 +229,7 @@ export class CallingViewModel { return; } - await this.mlsService.leaveConferenceSubconversation(conversationId); + await this.subconversationService.leaveConferenceSubconversation(conversationId); callingSubscriptions.removeCall(conversationId); }; @@ -257,44 +240,6 @@ export class CallingViewModel { } }); - const removeStaleClient = async ( - conversationId: QualifiedId, - memberToRemove: QualifiedWcallMember, - ): Promise => { - const subconversationGroupId = await this.mlsService.getGroupIdFromConversationId( - conversationId, - SUBCONVERSATION_ID.CONFERENCE, - ); - - if (!subconversationGroupId) { - return; - } - - const doesMLSGroupExist = await this.mlsService.conversationExists(subconversationGroupId); - if (!doesMLSGroupExist) { - return; - } - - const { - userId: {id: userId, domain}, - clientid, - } = memberToRemove; - const clientToRemoveQualifiedId = constructFullyQualifiedClientId(userId, clientid, domain); - - const subconversationMembers = await this.mlsService.getClientIds(subconversationGroupId); - - const isSubconversationMember = subconversationMembers.some( - ({userId, clientId, domain}) => - constructFullyQualifiedClientId(userId, clientId, domain) === clientToRemoveQualifiedId, - ); - - if (!isSubconversationMember) { - return; - } - - return void this.mlsService.removeClientsFromConversation(subconversationGroupId, [clientToRemoveQualifiedId]); - }; - const handleCallParticipantChange = (conversationId: QualifiedId, members: QualifiedWcallMember[]) => { const conversation = this.getConversationById(conversationId); if (!conversation?.isUsingMLSProtocol) { @@ -326,7 +271,11 @@ export class CallingViewModel { firingDate, key, // if timer expires = client is stale -> remove client from the subconversation - task: () => removeStaleClient(conversationId, member), + task: () => + this.subconversationService.removeClientFromConferenceSubconversation(conversationId, { + user: {id: member.userId.id, domain: member.userId.domain}, + clientId: member.clientid, + }), }); } }; @@ -455,13 +404,13 @@ export class CallingViewModel { }; } - get mlsService() { - const mlsService = this.core.service?.mls; - if (!mlsService) { - throw new Error('mls service was not initialised'); + get subconversationService() { + const subconversationService = this.core.service?.subconversation; + if (!subconversationService) { + throw new Error('SubconversationService was not initialised'); } - return mlsService; + return subconversationService; } /** diff --git a/yarn.lock b/yarn.lock index 7ea5ec58acf..34f2f489ae6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5365,9 +5365,9 @@ __metadata: languageName: node linkType: hard -"@wireapp/core@npm:42.19.0": - version: 42.19.0 - resolution: "@wireapp/core@npm:42.19.0" +"@wireapp/core@npm:42.19.1": + version: 42.19.1 + resolution: "@wireapp/core@npm:42.19.1" dependencies: "@wireapp/api-client": ^26.5.0 "@wireapp/commons": ^5.2.2 @@ -5387,7 +5387,7 @@ __metadata: long: ^5.2.0 uuidjs: 4.2.13 zod: 3.22.4 - checksum: a5b26d66d215b697f5da53e85afbdf869a870ee7f56224cd74517e9d1134bad24450b45ce3822608d9b2de37a0fbf720a660bed4f5427edca87b8344b90260b3 + checksum: 4e63816a11faa1d697ff3ad16e17785205e84153e3339ee2297ded0df24975f5e187e9ab521bd9b8bc23213159990cdb3087b5a61d6321954051e71c20d56825 languageName: node linkType: hard @@ -18514,7 +18514,7 @@ __metadata: "@wireapp/avs": 9.5.2 "@wireapp/commons": 5.2.2 "@wireapp/copy-config": 2.1.10 - "@wireapp/core": 42.19.0 + "@wireapp/core": 42.19.1 "@wireapp/eslint-config": 3.0.4 "@wireapp/lru-cache": 3.8.1 "@wireapp/prettier-config": 0.6.3 From b82e21f486b2664c791fe61eac130c2d4be4067e Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Thu, 26 Oct 2023 16:52:13 +0200 Subject: [PATCH 30/86] runfix: return the udpated asset event when dealing with asset uploaded events (#16117) --- .../EventStorageMiddleware/eventHandlers/assetEventHandler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/assetEventHandler.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/assetEventHandler.ts index f18489edd13..9c380f5d629 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/assetEventHandler.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/assetEventHandler.ts @@ -66,7 +66,8 @@ function computeEventUpdates( case ASSET_PREVIEW: case RETRY_EVENT: case AssetTransferState.UPLOADED: { - return {type: 'update', event: newEvent, updates: updateEventData(newEventData)}; + const updatedEvent = updateEventData(newEventData); + return {type: 'update', event: updatedEvent, updates: updatedEvent}; } case AssetTransferState.UPLOAD_FAILED: { From 1eda3a5e9fd987f76c7cc8fdab77543b5d48d5c3 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Thu, 26 Oct 2023 17:53:37 +0200 Subject: [PATCH 31/86] runfix: Make sure link previews are handled before edit events (#16118) --- .../EventStorageMiddleware/EventStorageMiddleware.test.ts | 1 + .../EventStorageMiddleware/EventStorageMiddleware.ts | 2 +- .../eventHandlers/getCommonMessageUpdates.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.test.ts b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.test.ts index baeb2c6f06d..710e443f411 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.test.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.test.ts @@ -177,6 +177,7 @@ describe('EventStorageMiddleware', () => { JSON.stringify({ ...linkPreviewEvent, data: {...linkPreviewEvent.data, replacing_message_id: replacingId}, + edited_time: new Date().toISOString(), }), ); eventService.loadEvent.mockResolvedValue(storedEvent); diff --git a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts index be2a6be776e..b31a3aa3eb2 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/EventStorageMiddleware.ts @@ -58,7 +58,7 @@ export class EventStorageMiddleware implements EventMiddleware { } private async getDbOperation(event: HandledEvents, duplicateEvent?: HandledEvents): Promise { - const handlers = [handleEditEvent, handleLinkPreviewEvent, handleAssetEvent, handleReactionEvent]; + const handlers = [handleLinkPreviewEvent, handleEditEvent, handleAssetEvent, handleReactionEvent]; for (const handler of handlers) { const operation = await handler(event, { duplicateEvent, diff --git a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/getCommonMessageUpdates.ts b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/getCommonMessageUpdates.ts index 0de5128b5ef..0c46cfddd07 100644 --- a/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/getCommonMessageUpdates.ts +++ b/src/script/event/preprocessor/EventStorageMiddleware/eventHandlers/getCommonMessageUpdates.ts @@ -24,6 +24,7 @@ export function getCommonMessageUpdates(originalEvent: StoredEvent Date: Fri, 27 Oct 2023 13:53:25 +0200 Subject: [PATCH 32/86] fix: emoji name is not getting rendered correctly for emojis with skin tone modifers (#16119) * fix: emoji name is not getting rendered correctly for emojis with skintone modifiers * fix: review comments --- src/script/util/EmojiUtil.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/script/util/EmojiUtil.ts b/src/script/util/EmojiUtil.ts index a449abedbe3..214faf89938 100644 --- a/src/script/util/EmojiUtil.ts +++ b/src/script/util/EmojiUtil.ts @@ -61,11 +61,32 @@ Object.keys(emojiesList).forEach(key => { const emojiObject = emojiValue[0]; const emojiNames = emojiObject.n; - emojiDictionary.set(key, emojiNames[0].replaceAll('-', ' ')); + // Replace hyphens with spaces, but only if not followed by a number + // example- thumbs down emoji name is -1 + const formattedEmojiName = emojiNames[0].replace(/-(?![0-9])/g, ' '); + + emojiDictionary.set(key, formattedEmojiName); }); +// Function to get the emoji without skintone modifiers +const removeSkinToneModifiers = (emojiUnicode: string): string => { + const skinToneModifiers = new Set(['1f3fd', '1f3fe', '1f3ff', '1f3fc', '1f3fb']); + if (!emojiUnicode) { + return ''; + } + const emojiUnicodeSplitted = emojiUnicode.split('-'); + const unicodeWithoutSkinModifier = emojiUnicodeSplitted.filter(part => !skinToneModifiers.has(part)); + + return unicodeWithoutSkinModifier.join('-'); +}; export const getEmojiTitleFromEmojiUnicode = (emojiUnicode: string): string => { - return emojiDictionary.has(emojiUnicode) ? emojiDictionary.get(emojiUnicode)! : ''; + if (emojiDictionary.has(emojiUnicode)) { + return emojiDictionary.get(emojiUnicode)!; + } + + const unicodeWithoutSkinModifier = removeSkinToneModifiers(emojiUnicode); + + return emojiDictionary.get(unicodeWithoutSkinModifier) || ''; }; export function getEmojiUnicode(emojis: string) { From 6614700cf4a19572579354a2dfa782b3f706fe50 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Oct 2023 15:19:34 +0000 Subject: [PATCH 33/86] chore(deps-dev): Bump @types/node from 20.8.8 to 20.8.9 (#16120) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.8.8 to 20.8.9. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 94ba134bfdd..d4d5621fa44 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@types/linkify-it": "3.0.4", "@types/loadable__component": "^5", "@types/markdown-it": "13.0.5", - "@types/node": "^20.8.8", + "@types/node": "^20.8.9", "@types/open-graph": "0.2.4", "@types/platform": "1.3.5", "@types/react": "18.2.28", diff --git a/yarn.lock b/yarn.lock index 34f2f489ae6..347e10b237f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4671,12 +4671,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.8.8": - version: 20.8.8 - resolution: "@types/node@npm:20.8.8" +"@types/node@npm:^20.8.9": + version: 20.8.9 + resolution: "@types/node@npm:20.8.9" dependencies: - undici-types: ~5.25.1 - checksum: 028a9606e4ef594a4bc7b3310596499d7ce01b2e30f4d1d906ad8ec30c24cea7ec1b3dc181dd5df8d8d2bfe8de54bf3e28ae93be174b4c7d81c0db8326e4f35c + undici-types: ~5.26.4 + checksum: 0c05f3502a9507ff27e91dd6fd574fa6f391b3fafedcfe8e0c8d33351fb22d02c0121f854e5b6b3ecb9a8a468407ddf6e7ac0029fb236d4c7e1361ffc758a01f languageName: node linkType: hard @@ -17788,6 +17788,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 3192ef6f3fd5df652f2dc1cd782b49d6ff14dc98e5dced492aa8a8c65425227da5da6aafe22523c67f035a272c599bb89cfe803c1db6311e44bed3042fc25487 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0" @@ -18498,7 +18505,7 @@ __metadata: "@types/linkify-it": 3.0.4 "@types/loadable__component": ^5 "@types/markdown-it": 13.0.5 - "@types/node": ^20.8.8 + "@types/node": ^20.8.9 "@types/open-graph": 0.2.4 "@types/platform": 1.3.5 "@types/react": 18.2.28 From 89f37d0377367c10d02c6fa2cf3227f4081f71a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Oct 2023 15:23:34 +0000 Subject: [PATCH 34/86] chore(deps): Bump oidc-client-ts from 2.3.0 to 2.4.0 (#16121) Bumps [oidc-client-ts](https://github.com/authts/oidc-client-ts) from 2.3.0 to 2.4.0. - [Release notes](https://github.com/authts/oidc-client-ts/releases) - [Commits](https://github.com/authts/oidc-client-ts/compare/v2.3.0...v2.4.0) --- updated-dependencies: - dependency-name: oidc-client-ts dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index d4d5621fa44..7e91e97b40f 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "long": "5.2.3", "markdown-it": "13.0.2", "murmurhash": "2.0.1", - "oidc-client-ts": "^2.2.5", + "oidc-client-ts": "^2.4.0", "platform": "1.3.6", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/yarn.lock b/yarn.lock index 347e10b237f..cca0dc1cecd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7412,10 +7412,10 @@ __metadata: languageName: node linkType: hard -"crypto-js@npm:^4.1.1": - version: 4.1.1 - resolution: "crypto-js@npm:4.1.1" - checksum: b3747c12ee3a7632fab3b3e171ea50f78b182545f0714f6d3e7e2858385f0f4101a15f2517e033802ce9d12ba50a391575ff4638c9de3dd9b2c4bc47768d5425 +"crypto-js@npm:^4.2.0": + version: 4.2.0 + resolution: "crypto-js@npm:4.2.0" + checksum: f051666dbc077c8324777f44fbd3aaea2986f198fe85092535130d17026c7c2ccf2d23ee5b29b36f7a4a07312db2fae23c9094b644cc35f7858b1b4fcaf27774 languageName: node linkType: hard @@ -13690,13 +13690,13 @@ __metadata: languageName: node linkType: hard -"oidc-client-ts@npm:^2.2.5": - version: 2.3.0 - resolution: "oidc-client-ts@npm:2.3.0" +"oidc-client-ts@npm:^2.4.0": + version: 2.4.0 + resolution: "oidc-client-ts@npm:2.4.0" dependencies: - crypto-js: ^4.1.1 + crypto-js: ^4.2.0 jwt-decode: ^3.1.2 - checksum: 74e20b8df748f901d67aba176f5f68bd8aa5ff7eed92dc92f34479dddc49e238dc722c757c5ab0f3365d170e3031343f650c789b51baa01062e8975c7580e15d + checksum: 8467db689298221f706d3358961efb0ddc789f6bd7d4765e71ae5fe62067999d2ce6e8e7584b9d991b8caa6f7fb383f75841e1cfa9e05808c34632de374f5e68 languageName: node linkType: hard @@ -18582,7 +18582,7 @@ __metadata: markdown-it: 13.0.2 murmurhash: 2.0.1 node-fetch: 2.7.0 - oidc-client-ts: ^2.2.5 + oidc-client-ts: ^2.4.0 os-browserify: 0.3.0 path-browserify: 1.0.1 platform: 1.3.6 From 2136a67b93f4a19550535f5b62f5274722c7aa82 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Oct 2023 15:23:52 +0000 Subject: [PATCH 35/86] chore(deps): Bump emoji-picker-react from 4.5.5 to 4.5.7 (#16122) Bumps [emoji-picker-react](https://github.com/ealush/emoji-picker-react) from 4.5.5 to 4.5.7. - [Release notes](https://github.com/ealush/emoji-picker-react/releases) - [Commits](https://github.com/ealush/emoji-picker-react/commits) --- updated-dependencies: - dependency-name: emoji-picker-react dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 7e91e97b40f..e7b3ea2878e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "countly-sdk-web": "23.6.2", "date-fns": "2.30.0", "dexie-batch": "0.4.3", - "emoji-picker-react": "4.5.5", + "emoji-picker-react": "4.5.7", "highlight.js": "11.9.0", "http-status-codes": "2.3.0", "jimp": "0.22.10", diff --git a/yarn.lock b/yarn.lock index cca0dc1cecd..771516b0aaa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8311,12 +8311,12 @@ __metadata: languageName: node linkType: hard -"emoji-picker-react@npm:4.5.5": - version: 4.5.5 - resolution: "emoji-picker-react@npm:4.5.5" +"emoji-picker-react@npm:4.5.7": + version: 4.5.7 + resolution: "emoji-picker-react@npm:4.5.7" peerDependencies: react: ">=16" - checksum: 9413fcb6bce335047ef514ee1aaf14285836dc5ddd80224f290cf08da0a1e4d0e2871adde2df4a5eef1c32168ebcc1f6a1dc3cf4916f2d9dbcd311a909fd454e + checksum: b599feddfddc6ef20f46603bc19b724b8c0aff8ae341040c500da3bfdc024feb78e6bf949537fd11852bff2e87004eccf2a73c32265fe7f1e3d9dc821304ef89 languageName: node linkType: hard @@ -18550,7 +18550,7 @@ __metadata: dexie-batch: 0.4.3 dotenv: 16.3.1 dpdm: 3.14.0 - emoji-picker-react: 4.5.5 + emoji-picker-react: 4.5.7 eslint: ^8.52.0 eslint-plugin-prettier: ^5.0.1 fake-indexeddb: 4.0.2 From f6dcceec2bece39e503be7d3d0e6fc907beeec63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Oct 2023 15:59:26 +0000 Subject: [PATCH 36/86] chore(deps): Bump @wireapp/commons from 5.2.1 to 5.2.2 in /server (#16125) Bumps [@wireapp/commons](https://github.com/wireapp/wire-web-packages) from 5.2.1 to 5.2.2. - [Commits](https://github.com/wireapp/wire-web-packages/compare/@wireapp/commons@5.2.1...@wireapp/commons@5.2.2) --- updated-dependencies: - dependency-name: "@wireapp/commons" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- server/package.json | 2 +- server/yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/package.json b/server/package.json index 63ffac1379e..65eb6b7314e 100644 --- a/server/package.json +++ b/server/package.json @@ -4,7 +4,7 @@ "main": "dist/index.js", "license": "GPL-3.0", "dependencies": { - "@wireapp/commons": "5.2.1", + "@wireapp/commons": "5.2.2", "dotenv": "16.3.1", "dotenv-extended": "2.9.0", "express": "4.18.2", diff --git a/server/yarn.lock b/server/yarn.lock index 0d20d50b06e..b4ee0d9ba20 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1142,15 +1142,15 @@ __metadata: languageName: node linkType: hard -"@wireapp/commons@npm:5.2.1": - version: 5.2.1 - resolution: "@wireapp/commons@npm:5.2.1" +"@wireapp/commons@npm:5.2.2": + version: 5.2.2 + resolution: "@wireapp/commons@npm:5.2.2" dependencies: ansi-regex: 5.0.1 fs-extra: 11.1.0 logdown: 3.3.1 platform: 1.3.6 - checksum: 1510b705a40d45ceaf07b12b5a199d94fe977d3b2faaafc298ff167a65b820471f5863f9f93f27d2003f9f44ee3401423d6e12bb38ecd7808f8b2fc72821d411 + checksum: ae78630f8299eaae9ee136136981dabdcb4c100c43ec1430882fc154ae21e9fdb17999c1b892140ca5547625e7f502ba83e85ecf62312c8525098865032b6928 languageName: node linkType: hard @@ -5552,7 +5552,7 @@ __metadata: "@types/hbs": 4.0.3 "@types/jest": ^29.5.6 "@types/node": 18.11.18 - "@wireapp/commons": 5.2.1 + "@wireapp/commons": 5.2.2 dotenv: 16.3.1 dotenv-extended: 2.9.0 express: 4.18.2 From 016c954cb924c66e765f429937d430733e629f9a Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 30 Oct 2023 11:44:18 +0100 Subject: [PATCH 37/86] runfix: Avoid showing active conversation if it is not supposed to be visible (#16126) --- .../panels/Conversations/Conversations.test.tsx | 6 +++++- .../LeftSidebar/panels/Conversations/Conversations.tsx | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/script/page/LeftSidebar/panels/Conversations/Conversations.test.tsx b/src/script/page/LeftSidebar/panels/Conversations/Conversations.test.tsx index 176774a7a15..788bb3c5eaf 100644 --- a/src/script/page/LeftSidebar/panels/Conversations/Conversations.test.tsx +++ b/src/script/page/LeftSidebar/panels/Conversations/Conversations.test.tsx @@ -36,7 +36,11 @@ describe('Conversations', () => { removeEventListener: jest.fn(), }, } as any, - listViewModel: {} as any, + listViewModel: { + contentViewModel: { + loadPreviousContent: jest.fn(), + }, + } as any, preferenceNotificationRepository: {notifications: ko.observable([])} as any, propertiesRepository: {getPreference: jest.fn(), savePreference: jest.fn()} as any, selfUser: new User(), diff --git a/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx b/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx index a1ddf772cd5..b2d24ee4b81 100644 --- a/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx +++ b/src/script/page/LeftSidebar/panels/Conversations/Conversations.tsx @@ -127,6 +127,13 @@ const Conversations: React.FC = ({ closeRightSidebar(); }; + useEffect(() => { + if (activeConversation && !conversationState.isVisible(activeConversation)) { + // If the active conversation is not visible, switch to the recent view + listViewModel.contentViewModel.loadPreviousContent(); + } + }, [activeConversation, conversationState, listViewModel.contentViewModel, conversations.length]); + useEffect(() => { if (!activeConversation) { return () => {}; From a929ff65cf2c8786e67cfa7bc3a7f066b6674c2b Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 30 Oct 2023 12:33:47 +0100 Subject: [PATCH 38/86] runfix: Open conversation list when group creation fails (#16127) --- src/script/view_model/ListViewModel.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/script/view_model/ListViewModel.ts b/src/script/view_model/ListViewModel.ts index 0697aea9dfe..ad42f9d61ac 100644 --- a/src/script/view_model/ListViewModel.ts +++ b/src/script/view_model/ListViewModel.ts @@ -132,6 +132,9 @@ export class ListViewModel { } private readonly _initSubscriptions = () => { + amplify.subscribe(WebAppEvents.CONVERSATION.SHOW, (conversation?: Conversation) => { + this.openConversations(conversation?.archivedState()); + }); amplify.subscribe(WebAppEvents.PREFERENCES.MANAGE_ACCOUNT, this.openPreferencesAccount); amplify.subscribe(WebAppEvents.PREFERENCES.MANAGE_DEVICES, this.openPreferencesDevices); amplify.subscribe(WebAppEvents.PREFERENCES.SHOW_AV, this.openPreferencesAudioVideo); From baf9f69ac54a2ec5c07d7ffd9a0562cc3226c9fb Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 30 Oct 2023 14:33:22 +0100 Subject: [PATCH 39/86] runfix: Do not try to match mention trigger on full text (#16129) --- .../plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx | 2 +- .../RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/script/components/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx b/src/script/components/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx index aab1add81f6..5876a2d3876 100644 --- a/src/script/components/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx @@ -120,7 +120,7 @@ export function EmojiPickerPlugin({openStateRef}: Props) { return null; } - return checkForEmojis(info.textContent); + return checkForEmojis(text); }; const options: Array = useMemo(() => { diff --git a/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx b/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx index ca2fd3725ff..84cbee5b852 100644 --- a/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/MentionsPlugin/MentionsPlugin.tsx @@ -145,7 +145,7 @@ export function MentionsPlugin({onSearch, openStateRef}: MentionsPluginProps) { if (!info || (info.isTextNode && info.wordCharAfterCursor)) { return null; } - return checkForMentions(info.textContent); + return checkForMentions(text); }, []); const rootElement = editor.getRootElement(); From 2864b3e9b0da91031bcd652a300637ba3006a2a6 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 30 Oct 2023 16:04:29 +0100 Subject: [PATCH 40/86] runfix: Keep emoji inline replacement selection stable (#16130) --- .../InlineEmojiReplacementPlugin.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/script/components/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.tsx b/src/script/components/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.tsx index a0816f8d684..05dcc015a3d 100644 --- a/src/script/components/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.tsx @@ -21,7 +21,7 @@ import {useEffect} from 'react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {mergeRegister} from '@lexical/utils'; -import {COMMAND_PRIORITY_LOW, KEY_SPACE_COMMAND, TextNode} from 'lexical'; +import {$getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, KEY_SPACE_COMMAND, TextNode} from 'lexical'; import {inlineReplacements} from './inlineReplacements'; @@ -50,11 +50,6 @@ export function findAndTransformEmoji(text: string): string { return text; } -function transformEmojiNodes(textNode: TextNode): void { - const text = textNode.getTextContent(); - textNode.setTextContent(findAndTransformEmoji(text)); -} - export function ReplaceEmojiPlugin(): null { const [editor] = useLexicalComposerContext(); @@ -64,7 +59,19 @@ export function ReplaceEmojiPlugin(): null { KEY_SPACE_COMMAND, () => { const unregister = editor.registerNodeTransform(TextNode, newNode => { - transformEmojiNodes(newNode); + const text = newNode.getTextContent(); + const selection = $getSelection(); + const currentSelection = $isRangeSelection(selection) ? selection : undefined; + const updatedText = findAndTransformEmoji(text); + const sizeDiff = updatedText.length - text.length; + newNode.setTextContent(updatedText); + // After emoji replacement, the size of the text could vary. We need to reposition the selection so that it stays in place for the user + currentSelection?.setTextNodeRange( + newNode, + currentSelection.anchor.offset + sizeDiff, + newNode, + currentSelection.focus.offset + sizeDiff, + ); // We register a text transform listener for a single round when the space key is pressed (then the listener is released) unregister(); }); From a79a26e07ae4afe35a77649e238e69f74425c717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20G=C3=B3rka?= Date: Tue, 31 Oct 2023 09:44:41 +0100 Subject: [PATCH 41/86] feat: subconversations store in core (#16131) --- package.json | 2 +- src/script/main/app.ts | 10 ++++- src/script/mls/MLSConversations.ts | 28 ------------ .../view_model/CallingViewModel.test.ts | 18 +++++++- src/script/view_model/CallingViewModel.ts | 14 +++++- yarn.lock | 43 +++++++++++-------- 6 files changed, 63 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index e7b3ea2878e..b8af470d733 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@peculiar/x509": "1.9.5", "@wireapp/avs": "9.5.2", "@wireapp/commons": "5.2.2", - "@wireapp/core": "42.19.1", + "@wireapp/core": "42.19.2", "@wireapp/lru-cache": "3.8.1", "@wireapp/react-ui-kit": "9.9.12", "@wireapp/store-engine-dexie": "2.1.6", diff --git a/src/script/main/app.ts b/src/script/main/app.ts index e949a62ebd4..163341e28a7 100644 --- a/src/script/main/app.ts +++ b/src/script/main/app.ts @@ -83,7 +83,7 @@ import {IntegrationRepository} from '../integration/IntegrationRepository'; import {IntegrationService} from '../integration/IntegrationService'; import {startNewVersionPolling} from '../lifecycle/newVersionHandler'; import {MediaRepository} from '../media/MediaRepository'; -import {initMLSCallbacks, initMLSConversations, registerUninitializedSelfAndTeamConversations} from '../mls'; +import {initMLSConversations, registerUninitializedSelfAndTeamConversations} from '../mls'; import {NotificationRepository} from '../notification/NotificationRepository'; import {PreferenceNotificationRepository} from '../notification/PreferenceNotificationRepository'; import {PermissionRepository} from '../permission/PermissionRepository'; @@ -378,6 +378,13 @@ export class App { const selfUser = await this.initiateSelfUser(); + this.core.configureCoreCallbacks({ + groupIdFromConversationId: async conversationId => { + const conversation = await conversationRepository.getConversationById(conversationId); + return conversation?.groupId; + }, + }); + await initializeDataDog(this.config, selfUser.qualifiedId); // Setup all event middleware @@ -421,7 +428,6 @@ export class App { if (supportsMLS()) { //if mls is supported, we need to initialize the callbacks (they are used when decrypting messages) - await initMLSCallbacks(this.core, this.repository.conversation); conversationRepository.initMLSConversationRecoveredListener(); } diff --git a/src/script/mls/MLSConversations.ts b/src/script/mls/MLSConversations.ts index c3c1f26fd4d..93ff107495d 100644 --- a/src/script/mls/MLSConversations.ts +++ b/src/script/mls/MLSConversations.ts @@ -22,7 +22,6 @@ import {KeyPackageClaimUser} from '@wireapp/core/lib/conversation'; import {Account} from '@wireapp/core'; -import {ConversationRepository} from '../conversation/ConversationRepository'; import { isMLSConversation, isSelfConversation, @@ -32,11 +31,6 @@ import { import {Conversation} from '../entity/Conversation'; import {User} from '../entity/User'; -type MLSConversationRepository = Pick< - ConversationRepository, - 'findConversationByGroupId' | 'getConversationById' | 'conversationRoleRepository' ->; - /** * Will initialize all the MLS conversations that the user is member of but that are not yet locally established. * @@ -68,28 +62,6 @@ export async function initMLSConversations(conversations: Conversation[], core: ); } -/** - * Will initialise the MLS callbacks for the core. - * It should be called before processing messages queue as the callbacks are being used when decrypting mls messages. - * - * @param core - the instance of the core - * @param conversationRepository - conversations repository - */ -export async function initMLSCallbacks( - core: Account, - conversationRepository: MLSConversationRepository, -): Promise { - return core.configureMLSCallbacks({ - groupIdFromConversationId: async conversationId => { - const conversation = await conversationRepository.getConversationById(conversationId); - return conversation?.groupId; - }, - // These rules are enforced by backend, no need to implement them on the client side. - authorize: async () => true, - userAuthorize: async () => true, - }); -} - /** * Will register self and team MLS conversations. * The self conversation and the team conversation are special conversations created by noone and, thus, need to be manually created by the first device that detects them diff --git a/src/script/view_model/CallingViewModel.test.ts b/src/script/view_model/CallingViewModel.test.ts index a1abe71daa0..b48b4616806 100644 --- a/src/script/view_model/CallingViewModel.test.ts +++ b/src/script/view_model/CallingViewModel.test.ts @@ -18,6 +18,7 @@ */ import {ConversationProtocol} from '@wireapp/api-client/lib/conversation'; +import {QualifiedId} from '@wireapp/api-client/lib/user'; import {CALL_TYPE, CONV_TYPE, STATE} from '@wireapp/avs'; @@ -29,6 +30,12 @@ import {buildCall, buildCallingViewModel, callState, mockCallingRepository} from import {LEAVE_CALL_REASON} from '../calling/enum/LeaveCallReason'; import {Conversation} from '../entity/Conversation'; +const createMLSConversation = (conversationId: QualifiedId, groupId: string) => { + const mlsConversation = new Conversation(conversationId.id, conversationId.domain, ConversationProtocol.MLS); + mlsConversation.groupId = groupId; + return mlsConversation; +}; + describe('CallingViewModel', () => { afterEach(() => { callState.calls.removeAll(); @@ -101,7 +108,9 @@ describe('CallingViewModel', () => { it('subscribes to epoch updates after initiating a call', async () => { const [callingViewModel, {core}] = buildCallingViewModel(); const conversationId = {domain: 'example.com', id: 'conversation1'}; - const mlsConversation = new Conversation(conversationId.id, conversationId.domain, ConversationProtocol.MLS); + + const groupId = 'groupId'; + const mlsConversation = createMLSConversation(conversationId, groupId); const mockedCall = buildCall(conversationId, CONV_TYPE.CONFERENCE_MLS); jest.spyOn(mockCallingRepository, 'startCall').mockResolvedValueOnce(mockedCall); @@ -112,6 +121,7 @@ describe('CallingViewModel', () => { expect(core.service?.subconversation.subscribeToEpochUpdates).toHaveBeenCalledWith( conversationId, + groupId, expect.any(Function), expect.any(Function), ); @@ -121,6 +131,11 @@ describe('CallingViewModel', () => { const [callingViewModel, {core}] = buildCallingViewModel(); const conversationId = {domain: 'example.com', id: 'conversation2'}; + const groupId = 'groupId'; + const mlsConversation = createMLSConversation(conversationId, groupId); + + callingViewModel['conversationState'].conversations.push(mlsConversation); + const call = buildCall(conversationId, CONV_TYPE.CONFERENCE_MLS); await callingViewModel.callActions.answer(call); @@ -129,6 +144,7 @@ describe('CallingViewModel', () => { expect(core.service?.subconversation.subscribeToEpochUpdates).toHaveBeenCalledWith( conversationId, + groupId, expect.any(Function), expect.any(Function), ); diff --git a/src/script/view_model/CallingViewModel.ts b/src/script/view_model/CallingViewModel.ts index 0becaf214ad..13a86a3fc30 100644 --- a/src/script/view_model/CallingViewModel.ts +++ b/src/script/view_model/CallingViewModel.ts @@ -42,6 +42,7 @@ import {CallState} from '../calling/CallState'; import {LEAVE_CALL_REASON} from '../calling/enum/LeaveCallReason'; import {PrimaryModal} from '../components/Modals/PrimaryModal'; import {Config} from '../Config'; +import {isMLSConversation} from '../conversation/ConversationSelectors'; import {ConversationState} from '../conversation/ConversationState'; import type {Conversation} from '../entity/Conversation'; import type {User} from '../entity/User'; @@ -158,9 +159,10 @@ export class CallingViewModel { return; } - if (conversation.isUsingMLSProtocol) { + if (isMLSConversation(conversation)) { const unsubscribe = await this.subconversationService.subscribeToEpochUpdates( conversation.qualifiedId, + conversation.groupId, (groupId: string) => this.conversationState.findConversationByGroupId(groupId)?.qualifiedId, data => this.callingRepository.setEpochInfo(conversation.qualifiedId, data), ); @@ -171,8 +173,15 @@ export class CallingViewModel { }; const joinOngoingMlsConference = async (call: Call) => { + const conversation = this.getConversationById(call.conversationId); + + if (!conversation || !isMLSConversation(conversation)) { + return; + } + const unsubscribe = await this.subconversationService.subscribeToEpochUpdates( call.conversationId, + conversation.groupId, (groupId: string) => this.conversationState.findConversationByGroupId(groupId)?.qualifiedId, data => this.callingRepository.setEpochInfo(call.conversationId, data), ); @@ -207,12 +216,13 @@ export class CallingViewModel { const updateEpochInfo = async (conversationId: QualifiedId, shouldAdvanceEpoch = false) => { const conversation = this.getConversationById(conversationId); - if (!conversation?.isUsingMLSProtocol) { + if (!conversation || !isMLSConversation(conversation)) { return; } const subconversationEpochInfo = await this.subconversationService.getSubconversationEpochInfo( conversationId, + conversation.groupId, shouldAdvanceEpoch, ); diff --git a/yarn.lock b/yarn.lock index 771516b0aaa..d77b5141ba0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4584,7 +4584,14 @@ __metadata: languageName: node linkType: hard -"@types/libsodium-wrappers@npm:*, @types/libsodium-wrappers@npm:^0": +"@types/libsodium-wrappers@npm:*": + version: 0.7.12 + resolution: "@types/libsodium-wrappers@npm:0.7.12" + checksum: 8f25b4ffe6b60c36f3c59b3dea2e952b8790c9b8375ee5235e6d294c1519a578b7882d773f168005eb0f3fdb4f11e06ba27b30b89d2c3b8be3f985c7eedd0491 + languageName: node + linkType: hard + +"@types/libsodium-wrappers@npm:^0": version: 0.7.11 resolution: "@types/libsodium-wrappers@npm:0.7.11" checksum: e3c3acdfc178a466a04d81c030ba1b748abc9335b1d66421125eb55b32cbaf6a9076e32a98744fcb84ba2fa2af342203ff29054262dcc465c12c4feddddb64ac @@ -4648,7 +4655,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0": +"@types/node@npm:*": version: 20.8.6 resolution: "@types/node@npm:20.8.6" dependencies: @@ -4671,7 +4678,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.8.9": +"@types/node@npm:>=13.7.0, @types/node@npm:^20.8.9": version: 20.8.9 resolution: "@types/node@npm:20.8.9" dependencies: @@ -5295,15 +5302,15 @@ __metadata: languageName: node linkType: hard -"@wireapp/api-client@npm:^26.5.0": - version: 26.5.0 - resolution: "@wireapp/api-client@npm:26.5.0" +"@wireapp/api-client@npm:^26.5.1": + version: 26.5.1 + resolution: "@wireapp/api-client@npm:26.5.1" dependencies: "@wireapp/commons": ^5.2.2 "@wireapp/priority-queue": ^2.1.4 "@wireapp/protocol-messaging": 1.44.0 axios: 1.5.1 - axios-retry: 3.8.0 + axios-retry: 3.8.1 http-status-codes: 2.3.0 logdown: 3.3.1 pako: 2.1.0 @@ -5312,7 +5319,7 @@ __metadata: tough-cookie: 4.1.3 ws: 8.14.2 zod: 3.22.4 - checksum: 32875d4e0b6dfdde296fc912572d98d2f19fc2d2ef4a8a3c28308604e850b39ffbcefca34a17c45213ec477e7c134a42098b431ccc143e1c1efe738dbc2eb5d6 + checksum: 71c27b4215532059e1357086e0a3f400ce3bfb78ac45d04e74aec79fe6ef70b241bab29d5bf970edc9e9e4ca52790c68e02ba9ecdf390bb965a44cdb3c8f4b10 languageName: node linkType: hard @@ -5365,11 +5372,11 @@ __metadata: languageName: node linkType: hard -"@wireapp/core@npm:42.19.1": - version: 42.19.1 - resolution: "@wireapp/core@npm:42.19.1" +"@wireapp/core@npm:42.19.2": + version: 42.19.2 + resolution: "@wireapp/core@npm:42.19.2" dependencies: - "@wireapp/api-client": ^26.5.0 + "@wireapp/api-client": ^26.5.1 "@wireapp/commons": ^5.2.2 "@wireapp/core-crypto": 1.0.0-rc.16 "@wireapp/cryptobox": 12.8.0 @@ -5387,7 +5394,7 @@ __metadata: long: ^5.2.0 uuidjs: 4.2.13 zod: 3.22.4 - checksum: 4e63816a11faa1d697ff3ad16e17785205e84153e3339ee2297ded0df24975f5e187e9ab521bd9b8bc23213159990cdb3087b5a61d6321954051e71c20d56825 + checksum: 24f4b254c7571d10928088db86723350bc73f015cf0b56aafa05892f4417ca098f7a0cc56be407a89ae69f3ff89a74949805f603757f3f6c91f665c97c7d5b69 languageName: node linkType: hard @@ -6244,13 +6251,13 @@ __metadata: languageName: node linkType: hard -"axios-retry@npm:3.8.0": - version: 3.8.0 - resolution: "axios-retry@npm:3.8.0" +"axios-retry@npm:3.8.1": + version: 3.8.1 + resolution: "axios-retry@npm:3.8.1" dependencies: "@babel/runtime": ^7.15.4 is-retry-allowed: ^2.2.0 - checksum: 448d951b971ccd35eaedc0f10ff1129a6bf2b3dfe13ce57749809bd37975332ae0e906ea4e67a41c9c98215bb1bf8a554e6880f1272419c758f91e4d68ca6b55 + checksum: 9233523d34987838504b1ea9d5f90025bf9d1210e10c3851c28d1e97be9f0c2dd401ddc9f0568759c79426533795de9c54bb8429720ace641032c51fef71cb0f languageName: node linkType: hard @@ -18521,7 +18528,7 @@ __metadata: "@wireapp/avs": 9.5.2 "@wireapp/commons": 5.2.2 "@wireapp/copy-config": 2.1.10 - "@wireapp/core": 42.19.1 + "@wireapp/core": 42.19.2 "@wireapp/eslint-config": 3.0.4 "@wireapp/lru-cache": 3.8.1 "@wireapp/prettier-config": 0.6.3 From 08a58a3907543cdd5c1c815cfd46c39f911e35ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wei=C3=9F?= <77456193+aweiss-dev@users.noreply.github.com> Date: Tue, 31 Oct 2023 11:02:59 +0100 Subject: [PATCH 42/86] fix: backend sends gracePeriod in seconds instead of ms (#16132) * fix: backend sends time in seconds instead of MS * chore: change hardcoded value to use an enum * fix: tests --- src/script/E2EIdentity/E2EIdentity.test.ts | 9 +++++---- src/script/E2EIdentity/E2EIdentity.ts | 15 ++++++++------- .../Features/E2EIdentity.ts | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/script/E2EIdentity/E2EIdentity.test.ts b/src/script/E2EIdentity/E2EIdentity.test.ts index 55cf2f2fe54..d8f0853d1e2 100644 --- a/src/script/E2EIdentity/E2EIdentity.test.ts +++ b/src/script/E2EIdentity/E2EIdentity.test.ts @@ -17,6 +17,7 @@ * */ +import {TimeInMillis} from '@wireapp/commons/lib/util/TimeUtil'; import {container} from 'tsyringe'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; @@ -76,8 +77,8 @@ jest.mock('src/script/user/UserState', () => ({ })); describe('E2EIHandler', () => { - const params = {discoveryUrl: 'http://example.com', gracePeriodInMS: 30000}; - const newParams = {discoveryUrl: 'http://new-example.com', gracePeriodInMS: 60000}; + const params = {discoveryUrl: 'http://example.com', gracePeriodInSeconds: 30}; + const newParams = {discoveryUrl: 'http://new-example.com', gracePeriodInSeconds: 60}; const user = {name: () => 'John Doe', username: () => 'johndoe'}; let coreMock: Core; let userStateMock: UserState; @@ -135,11 +136,11 @@ describe('E2EIHandler', () => { // Assuming that the instance exposes getters for discoveryUrl and gracePeriodInMS for testing purposes expect(instance['discoveryUrl']).toEqual(params.discoveryUrl); - expect(instance['gracePeriodInMS']).toEqual(params.gracePeriodInMS); + expect(instance['gracePeriodInMS']).toEqual(params.gracePeriodInSeconds * TimeInMillis.SECOND); instance.updateParams(newParams); expect(instance['discoveryUrl']).toEqual(newParams.discoveryUrl); - expect(instance['gracePeriodInMS']).toEqual(newParams.gracePeriodInMS); + expect(instance['gracePeriodInMS']).toEqual(newParams.gracePeriodInSeconds * TimeInMillis.SECOND); }); it('should return true when supportsMLS returns true and ENABLE_E2EI is true', () => { diff --git a/src/script/E2EIdentity/E2EIdentity.ts b/src/script/E2EIdentity/E2EIdentity.ts index f24986e5a0b..34e1fa08a67 100644 --- a/src/script/E2EIdentity/E2EIdentity.ts +++ b/src/script/E2EIdentity/E2EIdentity.ts @@ -23,6 +23,7 @@ import {PrimaryModal, removeCurrentModal} from 'Components/Modals/PrimaryModal'; import {Config} from 'src/script/Config'; import {Core} from 'src/script/service/CoreSingleton'; import {UserState} from 'src/script/user/UserState'; +import {TIME_IN_MILLIS} from 'Util/TimeUtil'; import {removeUrlParameters} from 'Util/UrlUtil'; import {supportsMLS} from 'Util/util'; @@ -42,7 +43,7 @@ export enum E2EIHandlerStep { interface E2EIHandlerParams { discoveryUrl: string; - gracePeriodInMS: number; + gracePeriodInSeconds: number; } class E2EIHandler { @@ -54,12 +55,12 @@ class E2EIHandler { private gracePeriodInMS: number; private currentStep: E2EIHandlerStep | null = E2EIHandlerStep.UNINITIALIZED; - private constructor({discoveryUrl, gracePeriodInMS}: E2EIHandlerParams) { + private constructor({discoveryUrl, gracePeriodInSeconds}: E2EIHandlerParams) { // ToDo: Do these values need to te able to be updated? Should we use a singleton with update fn? this.discoveryUrl = discoveryUrl; - this.gracePeriodInMS = gracePeriodInMS; + this.gracePeriodInMS = gracePeriodInSeconds * TIME_IN_MILLIS.SECOND; this.timer = DelayTimerService.getInstance({ - gracePeriodInMS, + gracePeriodInMS: this.gracePeriodInMS, gracePeriodExpiredCallback: () => null, delayPeriodExpiredCallback: () => null, }); @@ -92,11 +93,11 @@ class E2EIHandler { /** * @param E2EIHandlerParams The params to create the grace period timer */ - public updateParams({gracePeriodInMS, discoveryUrl}: E2EIHandlerParams) { - this.gracePeriodInMS = gracePeriodInMS; + public updateParams({gracePeriodInSeconds, discoveryUrl}: E2EIHandlerParams) { + this.gracePeriodInMS = gracePeriodInSeconds * TIME_IN_MILLIS.SECOND; this.discoveryUrl = discoveryUrl; this.timer.updateParams({ - gracePeriodInMS, + gracePeriodInMS: this.gracePeriodInMS, gracePeriodExpiredCallback: () => null, delayPeriodExpiredCallback: () => null, }); diff --git a/src/script/page/components/FeatureConfigChange/FeatureConfigChangeHandler/Features/E2EIdentity.ts b/src/script/page/components/FeatureConfigChange/FeatureConfigChangeHandler/Features/E2EIdentity.ts index 8e5344cf84e..97794d19f4e 100644 --- a/src/script/page/components/FeatureConfigChange/FeatureConfigChangeHandler/Features/E2EIdentity.ts +++ b/src/script/page/components/FeatureConfigChange/FeatureConfigChangeHandler/Features/E2EIdentity.ts @@ -47,7 +47,7 @@ export const handleE2EIdentityFeatureChange = (logger: Logger, config: FeatureLi // Either get the current E2EIdentity handler instance or create a new one const e2eHandler = E2EIHandler.getInstance({ discoveryUrl: e2eiConfig.config.acmeDiscoveryUrl!, - gracePeriodInMS: e2eiConfig.config.verificationExpiration, + gracePeriodInSeconds: e2eiConfig.config.verificationExpiration, }); e2eHandler.initialize(); } From 82d10c1f63e01cbd369703565a3615f3ab0c0cb7 Mon Sep 17 00:00:00 2001 From: Otto the Bot Date: Tue, 31 Oct 2023 13:55:33 +0100 Subject: [PATCH 43/86] chore: Update translations (#16112) --- src/i18n/ar-SA.json | 2 +- src/i18n/bn-BD.json | 2 +- src/i18n/ca-ES.json | 2 +- src/i18n/cs-CZ.json | 2 +- src/i18n/da-DK.json | 2 +- src/i18n/de-DE.json | 2 +- src/i18n/el-GR.json | 2 +- src/i18n/es-ES.json | 2 +- src/i18n/et-EE.json | 2 +- src/i18n/fa-IR.json | 2 +- src/i18n/fi-FI.json | 2 +- src/i18n/fr-FR.json | 2 +- src/i18n/ga-IE.json | 2 +- src/i18n/he-IL.json | 2 +- src/i18n/hi-IN.json | 2 +- src/i18n/hr-HR.json | 2 +- src/i18n/hu-HU.json | 2 +- src/i18n/id-ID.json | 2 +- src/i18n/is-IS.json | 2 +- src/i18n/it-IT.json | 2 +- src/i18n/ja-JP.json | 2 +- src/i18n/lt-LT.json | 2 +- src/i18n/lv-LV.json | 2 +- src/i18n/ms-MY.json | 2 +- src/i18n/nl-NL.json | 2 +- src/i18n/no-NO.json | 2 +- src/i18n/pl-PL.json | 2 +- src/i18n/pt-BR.json | 2 +- src/i18n/pt-PT.json | 2 +- src/i18n/ro-RO.json | 2 +- src/i18n/ru-RU.json | 4 ++-- src/i18n/si-LK.json | 2 +- src/i18n/sk-SK.json | 2 +- src/i18n/sl-SI.json | 2 +- src/i18n/sr-SP.json | 2 +- src/i18n/sv-SE.json | 2 +- src/i18n/th-TH.json | 2 +- src/i18n/tr-TR.json | 2 +- src/i18n/uk-UA.json | 2 +- src/i18n/uz-UZ.json | 2 +- src/i18n/vi-VN.json | 2 +- src/i18n/zh-CN.json | 2 +- src/i18n/zh-HK.json | 2 +- src/i18n/zh-TW.json | 2 +- 44 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/i18n/ar-SA.json b/src/i18n/ar-SA.json index bc555c0f4a7..3013f2fb1e4 100644 --- a/src/i18n/ar-SA.json +++ b/src/i18n/ar-SA.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "متاح", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "حرفان على الأقل. مسموح بالحروف a—z والأرقام 0—9 والشَرطة السفلية (_) فقط.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "اسمك الكامل", "preferencesDevice": "Device", "preferencesDeviceDetails": "تفاصيل الجهاز", diff --git a/src/i18n/bn-BD.json b/src/i18n/bn-BD.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/bn-BD.json +++ b/src/i18n/bn-BD.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/ca-ES.json b/src/i18n/ca-ES.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/ca-ES.json +++ b/src/i18n/ca-ES.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/cs-CZ.json b/src/i18n/cs-CZ.json index 310fe0524d8..cfb91d36146 100644 --- a/src/i18n/cs-CZ.json +++ b/src/i18n/cs-CZ.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Dostupný", "preferencesAccountUsernameErrorTaken": "Již uděleno", - "preferencesAccountUsernameHint": "Alespoň 2 znaky. Pouze a—z, 0—9 a _", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Celé jméno", "preferencesDevice": "Device", "preferencesDeviceDetails": "Podrobnosti o přístroji", diff --git a/src/i18n/da-DK.json b/src/i18n/da-DK.json index 8c224ee8ae4..74f7a7ca80f 100644 --- a/src/i18n/da-DK.json +++ b/src/i18n/da-DK.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Ledig", "preferencesAccountUsernameErrorTaken": "Allerede i brug", - "preferencesAccountUsernameHint": "Mindst to tegn. Kun a-z, 0-9 og _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Dit fulde navn", "preferencesDevice": "Device", "preferencesDeviceDetails": "Enheds Detaljer", diff --git a/src/i18n/de-DE.json b/src/i18n/de-DE.json index e1fd7fba54b..7f64a7325ea 100644 --- a/src/i18n/de-DE.json +++ b/src/i18n/de-DE.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Benutzername", "preferencesAccountUsernameAvailable": "Verfügbar", "preferencesAccountUsernameErrorTaken": "Bereits vergeben", - "preferencesAccountUsernameHint": "Mindestens zwei Zeichen. a—z, 0—9, und _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Ihr vollständiger Name", "preferencesDevice": "Gerät", "preferencesDeviceDetails": "Gerätedetails", diff --git a/src/i18n/el-GR.json b/src/i18n/el-GR.json index 83b447cc112..91544e1fa2c 100644 --- a/src/i18n/el-GR.json +++ b/src/i18n/el-GR.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Διαθέσιμο", "preferencesAccountUsernameErrorTaken": "Χρησιμοποιείται ήδη", - "preferencesAccountUsernameHint": "Τουλάχιστον 2 χαρακτήρες. a—z, 0—9 και _ μόνο.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Ονοματεπώνυμο", "preferencesDevice": "Device", "preferencesDeviceDetails": "Λεπτομέρειες Συσκευής", diff --git a/src/i18n/es-ES.json b/src/i18n/es-ES.json index 1e519233a6f..3582e0f286d 100644 --- a/src/i18n/es-ES.json +++ b/src/i18n/es-ES.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Disponible", "preferencesAccountUsernameErrorTaken": "No disponible", - "preferencesAccountUsernameHint": "Al menos 2 caracter Sólo a–z, 0–9 y _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Tu nombre completo", "preferencesDevice": "Device", "preferencesDeviceDetails": "Detalles del dispositivo", diff --git a/src/i18n/et-EE.json b/src/i18n/et-EE.json index 7e692621d01..8ad3ca92398 100644 --- a/src/i18n/et-EE.json +++ b/src/i18n/et-EE.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Saadaval", "preferencesAccountUsernameErrorTaken": "Juba kasutusel", - "preferencesAccountUsernameHint": "Vähemalt 2 tähemärki. Ainult a-z, 0-9 ja _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Sinu täisnimi", "preferencesDevice": "Device", "preferencesDeviceDetails": "Seadme üksikasjad", diff --git a/src/i18n/fa-IR.json b/src/i18n/fa-IR.json index fb24a96edc7..69a295df33b 100644 --- a/src/i18n/fa-IR.json +++ b/src/i18n/fa-IR.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "در دسترس", "preferencesAccountUsernameErrorTaken": "در حال حاضر موجود نیست", - "preferencesAccountUsernameHint": "حداقل ۲کاراکتر. حروف a-z، ارقام 0 تا 9 و _ مورد قبول میباشد.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "نام کامل شما", "preferencesDevice": "Device", "preferencesDeviceDetails": "جزییات اطلاعات دستگاه", diff --git a/src/i18n/fi-FI.json b/src/i18n/fi-FI.json index f7ac271e6b1..269ee4d3139 100644 --- a/src/i18n/fi-FI.json +++ b/src/i18n/fi-FI.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Saatavilla", "preferencesAccountUsernameErrorTaken": "On jo käytössä", - "preferencesAccountUsernameHint": "Vähintään 2 merkkiä, vain a - z, 0 - 9 ja _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Koko nimesi", "preferencesDevice": "Device", "preferencesDeviceDetails": "Laitteen yksityiskohdat", diff --git a/src/i18n/fr-FR.json b/src/i18n/fr-FR.json index 22db9a64526..367ea4d396c 100644 --- a/src/i18n/fr-FR.json +++ b/src/i18n/fr-FR.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Disponible", "preferencesAccountUsernameErrorTaken": "Déjà pris", - "preferencesAccountUsernameHint": "Au moins 2 caractères. Uniquement a–z, 0–9 et _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Votre nom complet", "preferencesDevice": "Device", "preferencesDeviceDetails": "Informations de l’appareil", diff --git a/src/i18n/ga-IE.json b/src/i18n/ga-IE.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/ga-IE.json +++ b/src/i18n/ga-IE.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/he-IL.json b/src/i18n/he-IL.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/he-IL.json +++ b/src/i18n/he-IL.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/hi-IN.json b/src/i18n/hi-IN.json index 1a73a114438..12a04af2b8f 100644 --- a/src/i18n/hi-IN.json +++ b/src/i18n/hi-IN.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "कम से कम 2 वर्ण| केवल a—z, 0—9 और _|", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/hr-HR.json b/src/i18n/hr-HR.json index 4a566f65c29..0fce573c40e 100644 --- a/src/i18n/hr-HR.json +++ b/src/i18n/hr-HR.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Dostupno", "preferencesAccountUsernameErrorTaken": "Već uzeto", - "preferencesAccountUsernameHint": "Najmanje 2 znaka. Samo a-z, 0-9, i _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Vaše puno ime", "preferencesDevice": "Device", "preferencesDeviceDetails": "Detalji o uređaju", diff --git a/src/i18n/hu-HU.json b/src/i18n/hu-HU.json index 3a612a4b054..c9bd97f4f62 100644 --- a/src/i18n/hu-HU.json +++ b/src/i18n/hu-HU.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Elérhető", "preferencesAccountUsernameErrorTaken": "Már foglalt", - "preferencesAccountUsernameHint": "Legalább 2 karakter, és kizárólag a—z, 0—9 és _ karakterek.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Teljes neved", "preferencesDevice": "Device", "preferencesDeviceDetails": "Eszköz részletei", diff --git a/src/i18n/id-ID.json b/src/i18n/id-ID.json index 92bdfb524ee..4e21bb950d0 100644 --- a/src/i18n/id-ID.json +++ b/src/i18n/id-ID.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Tersedia", "preferencesAccountUsernameErrorTaken": "Telah diambil", - "preferencesAccountUsernameHint": "Minimal 2 karakter. a-z, 0-9 dan _ saja.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Nama lengkap Anda", "preferencesDevice": "Device", "preferencesDeviceDetails": "Detil Perangkat", diff --git a/src/i18n/is-IS.json b/src/i18n/is-IS.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/is-IS.json +++ b/src/i18n/is-IS.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/it-IT.json b/src/i18n/it-IT.json index b322efb18e8..856b581ccb2 100644 --- a/src/i18n/it-IT.json +++ b/src/i18n/it-IT.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Disponibile", "preferencesAccountUsernameErrorTaken": "E’ già stato scelto", - "preferencesAccountUsernameHint": "Almeno 2 caratteri. a-z, 0-9 e solo _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Il tuo nome e cognome", "preferencesDevice": "Device", "preferencesDeviceDetails": "Dettagli sul dispositivo", diff --git a/src/i18n/ja-JP.json b/src/i18n/ja-JP.json index 39b6c175c97..1e442e99673 100644 --- a/src/i18n/ja-JP.json +++ b/src/i18n/ja-JP.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "利用できます", "preferencesAccountUsernameErrorTaken": "すでに利用されています", - "preferencesAccountUsernameHint": "少なくとも2文字。a-z, 0-9, および _ のみが利用できます。", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "あなたの氏名", "preferencesDevice": "Device", "preferencesDeviceDetails": "デバイスの詳細", diff --git a/src/i18n/lt-LT.json b/src/i18n/lt-LT.json index de6f6ffbb5d..08bbe6cc9fe 100644 --- a/src/i18n/lt-LT.json +++ b/src/i18n/lt-LT.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Prieinamas", "preferencesAccountUsernameErrorTaken": "Jau užimtas", - "preferencesAccountUsernameHint": "Bent 2 simboliai. Tik a—z, 0—9 ir _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Jūsų vardas ir pavardė", "preferencesDevice": "Device", "preferencesDeviceDetails": "Išsamesnė įrenginio informacija", diff --git a/src/i18n/lv-LV.json b/src/i18n/lv-LV.json index fe4cf67fb2b..80ebfbf8213 100644 --- a/src/i18n/lv-LV.json +++ b/src/i18n/lv-LV.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Pieejams", "preferencesAccountUsernameErrorTaken": "Jau ir aizņemts", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Jūsu pilnais vārds", "preferencesDevice": "Device", "preferencesDeviceDetails": "Ierīces detaļas", diff --git a/src/i18n/ms-MY.json b/src/i18n/ms-MY.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/ms-MY.json +++ b/src/i18n/ms-MY.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/nl-NL.json b/src/i18n/nl-NL.json index a50f9fca030..890ce263be7 100644 --- a/src/i18n/nl-NL.json +++ b/src/i18n/nl-NL.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Beschikbaar", "preferencesAccountUsernameErrorTaken": "Al in gebruik", - "preferencesAccountUsernameHint": "Ten minste 2 tekens. a—z, 0—9, en _ alleen.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Je volledige naam", "preferencesDevice": "Device", "preferencesDeviceDetails": "Apparaat Details", diff --git a/src/i18n/no-NO.json b/src/i18n/no-NO.json index 7c247a5a81f..143e6b9092a 100644 --- a/src/i18n/no-NO.json +++ b/src/i18n/no-NO.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/pl-PL.json b/src/i18n/pl-PL.json index 0628cb9e637..0988b6a7ec2 100644 --- a/src/i18n/pl-PL.json +++ b/src/i18n/pl-PL.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "&Dostępny(a)", "preferencesAccountUsernameErrorTaken": "Jest już w użyciu", - "preferencesAccountUsernameHint": "Co najmniej 2 znaki. Tylko a-z, 0-9, _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Twoje pełne imię i nazwisko", "preferencesDevice": "Device", "preferencesDeviceDetails": "Szczegóły Urządzenia", diff --git a/src/i18n/pt-BR.json b/src/i18n/pt-BR.json index 2e4234308fa..620ae3f8abe 100644 --- a/src/i18n/pt-BR.json +++ b/src/i18n/pt-BR.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Nome de usuário", "preferencesAccountUsernameAvailable": "Disponível", "preferencesAccountUsernameErrorTaken": "Já está sendo usado", - "preferencesAccountUsernameHint": "Ao menos 2 caracteres. a—z, 0—9 e _ apenas.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Seu nome completo", "preferencesDevice": "Dispositivo", "preferencesDeviceDetails": "Detalhes do dispositivo", diff --git a/src/i18n/pt-PT.json b/src/i18n/pt-PT.json index 9ab0a5ffd6f..0c7e63cd0c7 100644 --- a/src/i18n/pt-PT.json +++ b/src/i18n/pt-PT.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Disponível", "preferencesAccountUsernameErrorTaken": "Já está ocupado", - "preferencesAccountUsernameHint": "Pelo menos 2 caracteres. a-z, 0-9 e _ apenas.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "O seu nome completo", "preferencesDevice": "Device", "preferencesDeviceDetails": "Detalhes do Dispositivo", diff --git a/src/i18n/ro-RO.json b/src/i18n/ro-RO.json index cbd9c5810ab..16f87998413 100644 --- a/src/i18n/ro-RO.json +++ b/src/i18n/ro-RO.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Disponibil", "preferencesAccountUsernameErrorTaken": "Deja folosit", - "preferencesAccountUsernameHint": "Cel puțin două caractere. Doar a—z, 0—9 și _ sunt permise.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Numele tău complet", "preferencesDevice": "Device", "preferencesDeviceDetails": "Detalii dispozitiv", diff --git a/src/i18n/ru-RU.json b/src/i18n/ru-RU.json index 9687fbfd817..c27472edab7 100644 --- a/src/i18n/ru-RU.json +++ b/src/i18n/ru-RU.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Псевдоним", "preferencesAccountUsernameAvailable": "Доступно", "preferencesAccountUsernameErrorTaken": "Уже занято", - "preferencesAccountUsernameHint": "Не менее 2 символов. Только a—z, 0—9 и _", + "preferencesAccountUsernameHint": "Не менее 2 символов. Только a—z, 0—9 and '.', '-', '_'.", "preferencesAccountUsernamePlaceholder": "Ваше полное имя", "preferencesDevice": "Устройство", "preferencesDeviceDetails": "Сведения об устройстве", @@ -1350,7 +1350,7 @@ "userListSelectedContacts": "Выбрано ({{selectedContacts}})", "userNotFoundMessage": "Возможно, у вас нет разрешения на использование этой учетной записи, либо этот человек отсутствует в {{brandName}}.", "userNotFoundTitle": "{{brandName}} не может найти этого человека.", - "userNotVerified": "Убедитесь в личности {{user}}, прежде чем подключаться.", + "userNotVerified": "Перед добавлением убедитесь в личности {{user}}.", "userProfileButtonConnect": "Связаться", "userProfileButtonIgnore": "Игнорировать", "userProfileButtonUnblock": "Разблокировать", diff --git a/src/i18n/si-LK.json b/src/i18n/si-LK.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/si-LK.json +++ b/src/i18n/si-LK.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/sk-SK.json b/src/i18n/sk-SK.json index e55784af6ba..88f71b47506 100644 --- a/src/i18n/sk-SK.json +++ b/src/i18n/sk-SK.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Dostupné", "preferencesAccountUsernameErrorTaken": "Už obsadené", - "preferencesAccountUsernameHint": "Aspoň 2 znaky. A výhradne a-z, 0-9.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Vaše celé meno", "preferencesDevice": "Device", "preferencesDeviceDetails": "Podrobnosti o zariadení", diff --git a/src/i18n/sl-SI.json b/src/i18n/sl-SI.json index 9ff238e8c3f..3f090930be3 100644 --- a/src/i18n/sl-SI.json +++ b/src/i18n/sl-SI.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Na voljo", "preferencesAccountUsernameErrorTaken": "Že zasedeno", - "preferencesAccountUsernameHint": "Vsaj 2 znaka. Le a—z, 0—9 in _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Vaše polno ime", "preferencesDevice": "Device", "preferencesDeviceDetails": "Podrobnosti naprave", diff --git a/src/i18n/sr-SP.json b/src/i18n/sr-SP.json index 26bfd3833d5..85e95b5acc9 100644 --- a/src/i18n/sr-SP.json +++ b/src/i18n/sr-SP.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "На располагању", "preferencesAccountUsernameErrorTaken": "Већ заузето", - "preferencesAccountUsernameHint": "Бар 2 знака. Само a—z, 0—9 и _", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Ваше пуно име", "preferencesDevice": "Device", "preferencesDeviceDetails": "Детаљи уређаја", diff --git a/src/i18n/sv-SE.json b/src/i18n/sv-SE.json index cc440dda7aa..0e16ac4fe87 100644 --- a/src/i18n/sv-SE.json +++ b/src/i18n/sv-SE.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Tillgängligt", "preferencesAccountUsernameErrorTaken": "Upptaget", - "preferencesAccountUsernameHint": "Minst 2 tecken. a—z, 0—9 och _ endast.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Ditt fullständiga namn", "preferencesDevice": "Device", "preferencesDeviceDetails": "Enhetsdetaljer", diff --git a/src/i18n/th-TH.json b/src/i18n/th-TH.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/th-TH.json +++ b/src/i18n/th-TH.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/tr-TR.json b/src/i18n/tr-TR.json index 900c6fe821c..eeb352fbff3 100644 --- a/src/i18n/tr-TR.json +++ b/src/i18n/tr-TR.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Alınabilir", "preferencesAccountUsernameErrorTaken": "Çoktan alınmış", - "preferencesAccountUsernameHint": "En az 2 karakter. a—z, 0—9, ve yalnızca _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Tam adınız", "preferencesDevice": "Device", "preferencesDeviceDetails": "Cihaz Detayları", diff --git a/src/i18n/uk-UA.json b/src/i18n/uk-UA.json index 07fd45cf502..a626dae4c8f 100644 --- a/src/i18n/uk-UA.json +++ b/src/i18n/uk-UA.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Доступний", "preferencesAccountUsernameErrorTaken": "Уже зарезервований", - "preferencesAccountUsernameHint": "Мінімум 2 символи з множини a—z, 0—9, та _.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Ваше повне ім’я", "preferencesDevice": "Device", "preferencesDeviceDetails": "Подробиці пристрою", diff --git a/src/i18n/uz-UZ.json b/src/i18n/uz-UZ.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/uz-UZ.json +++ b/src/i18n/uz-UZ.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/vi-VN.json b/src/i18n/vi-VN.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/vi-VN.json +++ b/src/i18n/vi-VN.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 755fe515b73..a889d289390 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "可用", "preferencesAccountUsernameErrorTaken": "已被占用", - "preferencesAccountUsernameHint": "至少2个字符,仅可使用a—z, 0—9以及下划线。", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "您的全名", "preferencesDevice": "Device", "preferencesDeviceDetails": "设备详细信息", diff --git a/src/i18n/zh-HK.json b/src/i18n/zh-HK.json index 2d912e9f5cb..7a67c62458b 100644 --- a/src/i18n/zh-HK.json +++ b/src/i18n/zh-HK.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "Available", "preferencesAccountUsernameErrorTaken": "Already taken", - "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and _ only.", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "Your full name", "preferencesDevice": "Device", "preferencesDeviceDetails": "Device Details", diff --git a/src/i18n/zh-TW.json b/src/i18n/zh-TW.json index 429e40c0bd2..3d4539f51a2 100644 --- a/src/i18n/zh-TW.json +++ b/src/i18n/zh-TW.json @@ -1121,7 +1121,7 @@ "preferencesAccountUsername": "Username", "preferencesAccountUsernameAvailable": "可用", "preferencesAccountUsernameErrorTaken": "已經被用了", - "preferencesAccountUsernameHint": "至少要兩個字元,只可使用 a 到 z、0 到 9 或者 _ 這些字元。", + "preferencesAccountUsernameHint": "At least 2 characters. a—z, 0—9 and '.', '-', '_' only.", "preferencesAccountUsernamePlaceholder": "您的全名", "preferencesDevice": "Device", "preferencesDeviceDetails": "設備詳細資訊", From 5155c346bab3c61970156da01259684876b2670c Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 31 Oct 2023 15:15:29 +0100 Subject: [PATCH 44/86] refactor: Cleanup user state (#16133) --- .../conversation/ConversationRepository.ts | 2 -- src/script/conversation/ConversationState.ts | 3 +++ src/script/conversation/MessageRepository.ts | 4 ++-- src/script/team/TeamState.ts | 6 ++--- .../tracking/EventTrackingRepository.ts | 14 +++++++---- src/script/user/UserRepository.ts | 6 +++-- src/script/user/UserState.ts | 24 +++---------------- 7 files changed, 23 insertions(+), 36 deletions(-) diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index 4b3335518bd..5197783d509 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -291,8 +291,6 @@ export class ConversationRepository { onMessageTimeout: this.handleMessageExpiration, }); - this.userState.directlyConnectedUsers = this.conversationState.connectedUsers; - this.conversationLabelRepository = new ConversationLabelRepository( this.conversationState.conversations, this.conversationState.visibleConversations, diff --git a/src/script/conversation/ConversationState.ts b/src/script/conversation/ConversationState.ts index 487d215b0d2..dddd57f785a 100644 --- a/src/script/conversation/ConversationState.ts +++ b/src/script/conversation/ConversationState.ts @@ -51,6 +51,9 @@ export class ConversationState { private readonly selfProteusConversation: ko.PureComputed; private readonly selfMLSConversation: ko.PureComputed; public readonly unreadConversations: ko.PureComputed; + /** + * All the users that are connected to the selfUser through a conversation. Those users are not necessarily **directly** connected to the selfUser (through a connection request) + */ public readonly connectedUsers: ko.PureComputed; public readonly sortedConversations: ko.PureComputed; diff --git a/src/script/conversation/MessageRepository.ts b/src/script/conversation/MessageRepository.ts index 2f3adba21f8..6ed9bed026e 100644 --- a/src/script/conversation/MessageRepository.ts +++ b/src/script/conversation/MessageRepository.ts @@ -1135,8 +1135,8 @@ export class MessageRepository { messageId: createUuid(), }); - const sortedUsers = this.userState - .directlyConnectedUsers() + const sortedUsers = this.conversationState + .connectedUsers() // For the moment, we do not want to send status in federated env // we can remove the filter when we actually want this feature in federated env (and we will need to implement federation for the core broadcastService) .filter(user => !user.isFederated) diff --git a/src/script/team/TeamState.ts b/src/script/team/TeamState.ts index d9a8d7e6d74..b5b99aaa6c0 100644 --- a/src/script/team/TeamState.ts +++ b/src/script/team/TeamState.ts @@ -51,7 +51,9 @@ export class TeamState { public readonly isAppLockEnabled: ko.PureComputed; public readonly isAppLockEnforced: ko.PureComputed; public readonly appLockInactivityTimeoutSecs: ko.PureComputed; + /** all the members of the team */ readonly teamMembers: ko.PureComputed; + /** all the members of the team + the users the selfUser is connected with */ readonly teamUsers: ko.PureComputed; readonly isTeam: ko.PureComputed; readonly team: ko.Observable; @@ -82,10 +84,6 @@ export class TeamState { this.supportsLegalHold = ko.observable(false); - this.userState.isTeam = this.isTeam; - this.userState.teamMembers = this.teamMembers; - this.userState.teamUsers = this.teamUsers; - this.isFileSharingSendingEnabled = ko.pureComputed(() => { const status = this.teamFeatures()?.fileSharing?.status; return status ? status === FeatureStatus.ENABLED : true; diff --git a/src/script/tracking/EventTrackingRepository.ts b/src/script/tracking/EventTrackingRepository.ts index d693b5e8eb8..39e9ad38fb4 100644 --- a/src/script/tracking/EventTrackingRepository.ts +++ b/src/script/tracking/EventTrackingRepository.ts @@ -39,6 +39,7 @@ import {URLParameter} from '../auth/URLParameter'; import {Config} from '../Config'; import type {ContributedSegmentations, MessageRepository} from '../conversation/MessageRepository'; import {ClientEvent} from '../event/Client'; +import {TeamState} from '../team/TeamState'; import {ROLE as TEAM_ROLE} from '../user/UserPermission'; import {UserState} from '../user/UserState'; @@ -68,6 +69,7 @@ export class EventTrackingRepository { constructor( private readonly messageRepository: MessageRepository, private readonly userState = container.resolve(UserState), + private readonly teamState = container.resolve(TeamState), ) { this.logger = getLogger('EventTrackingRepository'); @@ -78,7 +80,7 @@ export class EventTrackingRepository { readonly onUserEvent = (eventJson: any, source: EventSource) => { const type = eventJson.type; - if (type === ClientEvent.USER.DATA_TRANSFER && this.userState.isTeam()) { + if (type === ClientEvent.USER.DATA_TRANSFER && this.teamState.isTeam()) { this.migrateDeviceId(eventJson.data.trackingIdentifier); } }; @@ -159,7 +161,7 @@ export class EventTrackingRepository { } } - const isTeam = this.userState.isTeam(); + const isTeam = this.teamState.isTeam(); if (!isTeam) { return; // Countly should not be enabled for non-team users } @@ -262,10 +264,12 @@ export class EventTrackingRepository { private trackProductReportingEvent(eventName: string, customSegmentations?: ContributedSegmentations): void { if (this.isProductReportingActivated === true) { + const contacts = this.teamState.isTeam() ? this.teamState.teamUsers() : this.userState.connectedUsers(); + const nbContacts = contacts.filter(userEntity => !userEntity.isService).length; const userData = { - [UserData.IS_TEAM]: this.userState.isTeam(), - [UserData.CONTACTS]: roundLogarithmic(this.userState.numberOfContacts(), 6), - [UserData.TEAM_SIZE]: roundLogarithmic(this.userState.teamMembers().length, 6), + [UserData.IS_TEAM]: this.teamState.isTeam(), + [UserData.CONTACTS]: roundLogarithmic(nbContacts, 6), + [UserData.TEAM_SIZE]: roundLogarithmic(this.teamState.teamMembers().length, 6), [UserData.TEAM_ID]: this.userState.self().teamId, [UserData.USER_TYPE]: this.getUserType(), }; diff --git a/src/script/user/UserRepository.ts b/src/script/user/UserRepository.ts index 9509b02e060..8e52c22b753 100644 --- a/src/script/user/UserRepository.ts +++ b/src/script/user/UserRepository.ts @@ -76,6 +76,7 @@ import type {EventSource} from '../event/EventSource'; import type {PropertiesRepository} from '../properties/PropertiesRepository'; import type {SelfService} from '../self/SelfService'; import {UserRecord} from '../storage'; +import {TeamState} from '../team/TeamState'; import type {ServerTimeHandler} from '../time/serverTimeHandler'; type GetUserOptions = { @@ -126,6 +127,7 @@ export class UserRepository { serverTimeHandler: ServerTimeHandler, private readonly propertyRepository: PropertiesRepository, private readonly userState = container.resolve(UserState), + private readonly teamState = container.resolve(TeamState), ) { this.logger = getLogger('UserRepository'); @@ -561,7 +563,7 @@ export class UserRepository { userId => new User(userId.id, userId.domain), ); const mappedUsers = this.userMapper.mapUsersFromJson(found, this.userState.self().domain).concat(failedToLoad); - if (this.userState.isTeam()) { + if (this.teamState.isTeam()) { this.mapGuestStatus(mappedUsers); } return mappedUsers; @@ -790,7 +792,7 @@ export class UserRepository { // update the user in db await this.updateUser(userId, user); - if (this.userState.isTeam()) { + if (this.teamState.isTeam()) { this.mapGuestStatus([updatedUser]); } if (updatedUser && updatedUser.inTeam() && updatedUser.isDeleted) { diff --git a/src/script/user/UserState.ts b/src/script/user/UserState.ts index bf4637664aa..84dbbdc260a 100644 --- a/src/script/user/UserState.ts +++ b/src/script/user/UserState.ts @@ -27,21 +27,14 @@ import {User} from '../entity/User'; @singleton() export class UserState { - public directlyConnectedUsers: ko.PureComputed; - public isTeam: ko.Observable | ko.PureComputed; + public readonly self = ko.observable(); + /** All the users we know of (connected users, conversation users, team members, users we have searched for...) */ + public readonly users = ko.observableArray([]); /** All the users that are directly connect to the self user (do not include users that are connected through conversations) */ public readonly connectedUsers: ko.PureComputed; - public readonly users: ko.ObservableArray; - public teamMembers: ko.PureComputed; - /** Note: this does not include the self user */ - public teamUsers: ko.PureComputed; public readonly connectRequests: ko.PureComputed; - public readonly numberOfContacts: ko.PureComputed; - public readonly self = ko.observable(); constructor() { - this.users = ko.observableArray([]); - this.connectRequests = ko .pureComputed(() => this.users().filter(userEntity => userEntity.isIncomingRequest())) .extend({rateLimit: 50}); @@ -53,16 +46,5 @@ export class UserState { .sort(sortUsersByPriority); }) .extend({rateLimit: TIME_IN_MILLIS.SECOND}); - - this.isTeam = ko.observable(); - this.teamMembers = ko.pureComputed((): User[] => []); - this.teamUsers = ko.pureComputed((): User[] => []); - - this.directlyConnectedUsers = ko.pureComputed((): User[] => []); - - this.numberOfContacts = ko.pureComputed(() => { - const contacts = this.isTeam() ? this.teamUsers() : this.connectedUsers(); - return contacts.filter(userEntity => !userEntity.isService).length; - }); } } From b22fcbe02dfb919d31af3ea0a0addb3026c5ecfa Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 31 Oct 2023 16:23:47 +0100 Subject: [PATCH 45/86] test: Improve coverage of SearchRepository (#16134) --- .../LegalHoldModal/LegalHoldModal.test.tsx | 3 +- src/script/main/app.ts | 3 +- src/script/search/SearchRepository.test.ts | 237 ++++++++++++++++++ src/script/search/SearchRepository.ts | 67 +++-- src/script/search/SearchService.ts | 32 --- test/helper/TestFactory.js | 4 +- .../unit_tests/search/SearchRepositorySpec.js | 159 ------------ 7 files changed, 270 insertions(+), 235 deletions(-) create mode 100644 src/script/search/SearchRepository.test.ts delete mode 100644 src/script/search/SearchService.ts delete mode 100644 test/unit_tests/search/SearchRepositorySpec.js diff --git a/src/script/components/Modals/LegalHoldModal/LegalHoldModal.test.tsx b/src/script/components/Modals/LegalHoldModal/LegalHoldModal.test.tsx index a0f5c8e3536..1510cc64386 100644 --- a/src/script/components/Modals/LegalHoldModal/LegalHoldModal.test.tsx +++ b/src/script/components/Modals/LegalHoldModal/LegalHoldModal.test.tsx @@ -33,7 +33,6 @@ import {CryptographyRepository} from '../../../cryptography/CryptographyReposito import {Conversation} from '../../../entity/Conversation'; import {User} from '../../../entity/User'; import {SearchRepository} from '../../../search/SearchRepository'; -import {SearchService} from '../../../search/SearchService'; import {TeamRepository} from '../../../team/TeamRepository'; import {UserRepository} from '../../../user/UserRepository'; @@ -57,7 +56,7 @@ const defaultProps = () => ({ messageRepository: { updateAllClients: (conversation: Conversation, blockSystemMessage: boolean): Promise => Promise.resolve(), } as MessageRepository, - searchRepository: new SearchRepository(new SearchService(), userRepository), + searchRepository: new SearchRepository(userRepository), teamRepository: {} as TeamRepository, selfUser: new User('mocked-id'), }); diff --git a/src/script/main/app.ts b/src/script/main/app.ts index 163341e28a7..e280d013c88 100644 --- a/src/script/main/app.ts +++ b/src/script/main/app.ts @@ -90,7 +90,6 @@ import {PermissionRepository} from '../permission/PermissionRepository'; import {PropertiesRepository} from '../properties/PropertiesRepository'; import {PropertiesService} from '../properties/PropertiesService'; import {SearchRepository} from '../search/SearchRepository'; -import {SearchService} from '../search/SearchService'; import {SelfRepository} from '../self/SelfRepository'; import {SelfService} from '../self/SelfService'; import {APIClient} from '../service/APIClientSingleton'; @@ -226,7 +225,7 @@ export class App { ); repositories.connection = new ConnectionRepository(new ConnectionService(), repositories.user); repositories.event = new EventRepository(this.service.event, this.service.notification, serverTimeHandler); - repositories.search = new SearchRepository(new SearchService(), repositories.user); + repositories.search = new SearchRepository(repositories.user); repositories.team = new TeamRepository(repositories.user, repositories.asset); repositories.message = new MessageRepository( diff --git a/src/script/search/SearchRepository.test.ts b/src/script/search/SearchRepository.test.ts new file mode 100644 index 00000000000..9d131978f32 --- /dev/null +++ b/src/script/search/SearchRepository.test.ts @@ -0,0 +1,237 @@ +/* + * Wire + * Copyright (C) 2018 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 {User} from 'src/script/entity/User'; +import {generateUser} from 'test/helper/UserGenerator'; + +import {SearchRepository} from './SearchRepository'; + +import {randomInt} from '../auth/util/randomUtil'; +import {generateUsers} from '../auth/util/test/TestUtil'; +import {APIClient} from '../service/APIClientSingleton'; +import {Core} from '../service/CoreSingleton'; +import {UserRepository} from '../user/UserRepository'; + +function buildSearchRepository() { + const userRepository = {getUsersById: jest.fn(() => [])} as unknown as jest.Mocked; + const core = {backendFeatures: {isFederated: false}} as unknown as jest.Mocked; + const apiClient = {api: {user: {getSearchContacts: jest.fn()}}} as unknown as jest.Mocked; + const searchRepository = new SearchRepository(userRepository, core, apiClient); + return [searchRepository, {userRepository, core, apiClient}] as const; +} + +describe('SearchRepository', () => { + describe('searchUserInSet', () => { + const sabine = createUser('jesuissabine', 'Sabine Duchemin'); + const janina = createUser('yosoyjanina', 'Janina Felix'); + const felixa = createUser('iamfelix', 'Felix Abo'); + const felix = createUser('iamfelix', 'Felix Oulala'); + const felicien = createUser('ichbinfelicien', 'Felicien Delatour'); + const lastguy = createUser('lastfelicien', 'lastsabine lastjanina'); + const jeanpierre = createUser('jean-pierre', 'Jean-Pierre Sansbijou'); + const pierre = createUser('pierrot', 'Pierre Monsouci'); + const noMatch1 = createUser(undefined, 'yyy yyy'); + const noMatch2 = createUser('xxx', undefined); + const users = [lastguy, noMatch1, felix, felicien, sabine, janina, noMatch2, felixa, jeanpierre, pierre]; + + const tests = [ + {expected: users, term: '', testCase: 'returns the whole user list if no term is given'}, + {expected: [jeanpierre, janina, sabine, lastguy], term: 'j', testCase: 'matches multiple results'}, + { + expected: [janina, lastguy], + term: 'ja', + testCase: 'puts matches that start with the pattern on top of the list', + }, + { + expected: [felicien, felixa, felix, janina, lastguy], + term: 'fel', + testCase: 'sorts by name, handle, inside match and alphabetically', + }, + { + expected: [felixa, felix, janina], + term: 'felix', + testCase: 'sorts by firstname and lastname', + }, + { + expected: [felicien, lastguy], + term: 'felici', + testCase: 'sorts by name and inside match', + }, + { + expected: [sabine, jeanpierre, lastguy, pierre, janina], + term: 's', + testCase: 'sorts by name, handle and inside match', + }, + { + expected: [sabine, jeanpierre, lastguy], + term: 'sa', + testCase: 'puts matches that start with the pattern on top of the list', + }, + { + expected: [sabine, lastguy], + term: 'sabine', + testCase: 'puts matches that start with the pattern on top of the list', + }, + { + expected: [sabine, lastguy], + term: 'sabine', + testCase: 'puts matches that start with the pattern on top of the list', + }, + {expected: [felicien, lastguy], term: 'ic', testCase: 'matches inside the properties'}, + { + expected: [jeanpierre], + term: 'jean-pierre', + testCase: 'finds compound names', + }, + { + expected: [pierre, jeanpierre], + term: 'pierre', + testCase: 'matches compound names and prioritize matches from start', + }, + ]; + + const [searchRepository] = buildSearchRepository(); + + tests.forEach(({expected, term, testCase}) => { + it(`${testCase} term: ${term}`, () => { + const suggestions = searchRepository.searchUserInSet(term, users); + + expect(suggestions.map(serializeUser)).toEqual(expected.map(serializeUser)); + }); + }); + + it('does not replace numbers with emojis', () => { + const [searchRepository] = buildSearchRepository(); + const felix10 = createUser('simple10', 'Felix10'); + const unsortedUsers = [felix10]; + const suggestions = searchRepository.searchUserInSet('😋', unsortedUsers); + + expect(suggestions.map(serializeUser)).toEqual([]); + }); + + it('prioritize exact matches with special characters', () => { + const [searchRepository] = buildSearchRepository(); + const smilyFelix = createUser('smily', '😋Felix'); + const atFelix = createUser('at', '@Felix'); + const simplyFelix = createUser('simple', 'Felix'); + + const unsortedUsers = [atFelix, smilyFelix, simplyFelix]; + + let suggestions = searchRepository.searchUserInSet('felix', unsortedUsers); + let expected = [simplyFelix, smilyFelix, atFelix]; + + expect(suggestions.map(serializeUser)).toEqual(expected.map(serializeUser)); + suggestions = searchRepository.searchUserInSet('😋', unsortedUsers); + expected = [smilyFelix]; + + expect(suggestions.map(serializeUser)).toEqual(expected.map(serializeUser)); + }); + + it('handles sorting matching results', () => { + const [searchRepository] = buildSearchRepository(); + const first = createUser('xxx', '_surname'); + const second = createUser('xxx', 'surname _lastname'); + const third = createUser('_xxx', 'surname lastname'); + const fourth = createUser('xxx', 'sur_name lastname'); + const fifth = createUser('xxx', 'surname last_name'); + const sixth = createUser('x_xx', 'surname lastname'); + + const unsortedUsers = [sixth, fifth, third, second, first, fourth]; + const expectedUsers = [first, second, third, fourth, fifth, sixth]; + + const suggestions = searchRepository.searchUserInSet('_', unsortedUsers); + + expect(suggestions.map(serializeUser)).toEqual(expectedUsers.map(serializeUser)); + }); + }); + + describe('searchByName', () => { + it('returns empty array if no users are found', async () => { + const [searchRepository, {apiClient}] = buildSearchRepository(); + jest.spyOn(apiClient.api.user, 'getSearchContacts').mockResolvedValue({response: {documents: []}} as any); + + const suggestions = await searchRepository.searchByName('term'); + + expect(suggestions).toEqual([]); + }); + + it('matches remote results with local users', async () => { + const [searchRepository, {apiClient, userRepository}] = buildSearchRepository(); + const nbUsers = randomInt(10); + const localUsers = generateUsers(nbUsers, 'domain'); + + userRepository.getUsersById.mockResolvedValue(localUsers); + const searchResults = localUsers.map(({qualifiedId}) => qualifiedId); + jest + .spyOn(apiClient.api.user, 'getSearchContacts') + .mockResolvedValue({response: {documents: searchResults}} as any); + + const suggestions = await searchRepository.searchByName('term'); + + expect(suggestions).toHaveLength(nbUsers); + }); + + it('matches exact handle match', async () => { + const [searchRepository, {apiClient, userRepository}] = buildSearchRepository(); + const localUsers = [createUser('felix', 'felix'), createUser('notfelix', 'notfelix')]; + + userRepository.getUsersById.mockResolvedValue(localUsers); + const searchResults = localUsers.map(({qualifiedId}) => qualifiedId); + jest + .spyOn(apiClient.api.user, 'getSearchContacts') + .mockResolvedValue({response: {documents: searchResults}} as any); + + const suggestions = await searchRepository.searchByName('felix', true); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toBe(localUsers[0]); + }); + + it('filters out selfUser', async () => { + const [searchRepository, {apiClient, userRepository}] = buildSearchRepository(); + const selfUser = generateUser(); + selfUser.isMe = true; + const localUsers = [generateUser(), generateUser(), generateUser(), selfUser]; + userRepository.getUsersById.mockResolvedValue(localUsers); + + const searchResults = localUsers.map(({qualifiedId}) => qualifiedId); + jest + .spyOn(apiClient.api.user, 'getSearchContacts') + .mockResolvedValue({response: {documents: searchResults}} as any); + + const suggestions = await searchRepository.searchByName('term'); + + expect(suggestions.length).toEqual(localUsers.length - 1); + }); + }); +}); + +function createUser(handle: string | undefined, name: string | undefined) { + const user = new User(); + if (handle) { + user.username(handle); + } + if (name) { + user.name(name); + } + return user; +} +function serializeUser(userEntity: User) { + return {name: userEntity.name(), username: userEntity.username()}; +} diff --git a/src/script/search/SearchRepository.ts b/src/script/search/SearchRepository.ts index b76d91481ed..ffbf928d0cd 100644 --- a/src/script/search/SearchRepository.ts +++ b/src/script/search/SearchRepository.ts @@ -17,11 +17,10 @@ * */ -import type {QualifiedId} from '@wireapp/api-client/lib/user/'; +import type {QualifiedId, SearchResult} from '@wireapp/api-client/lib/user/'; import {container} from 'tsyringe'; import {EMOJI_RANGES} from 'Util/EmojiUtil'; -import {getLogger, Logger} from 'Util/Logger'; import { computeTransliteration, replaceAccents, @@ -30,18 +29,13 @@ import { transliterationIndex, } from 'Util/StringUtil'; -import type {SearchService} from './SearchService'; - import type {User} from '../entity/User'; +import {APIClient} from '../service/APIClientSingleton'; import {Core} from '../service/CoreSingleton'; import {validateHandle} from '../user/UserHandleGenerator'; import type {UserRepository} from '../user/UserRepository'; export class SearchRepository { - logger: Logger; - private readonly searchService: SearchService; - private readonly userRepository: UserRepository; - static get CONFIG() { return { MAX_DIRECTORY_RESULTS: 30, @@ -70,14 +64,10 @@ export class SearchRepository { * @param userRepository Repository for all user interactions */ constructor( - searchService: SearchService, - userRepository: UserRepository, + private readonly userRepository: UserRepository, private readonly core = container.resolve(Core), - ) { - this.searchService = searchService; - this.userRepository = userRepository; - this.logger = getLogger('SearchRepository'); - } + private readonly apiClient = container.resolve(APIClient), + ) {} /** * Search for a user in the given user list and given a search term. @@ -177,6 +167,11 @@ export class SearchRepository { }, 0); } + private async getContacts(query: string, numberOfRequestedUser: number, domain?: string): Promise { + const request = await this.apiClient.api.user.getSearchContacts(query, numberOfRequestedUser, domain); + return request.response; + } + /** * Search for users on the backend by name. * @note We skip a few results as connection changes need a while to reflect on the backend. @@ -194,27 +189,25 @@ export class SearchRepository { const [rawName, rawDomain] = this.core.backendFeatures.isFederated ? query.replace(/^@/, '').split('@') : [query]; const [name, domain] = validateHandle(rawName, rawDomain) ? [rawName, rawDomain] : [query]; - const matchedUserIdsFromDirectorySearch: QualifiedId[] = await this.searchService - .getContacts(name, SearchRepository.CONFIG.MAX_DIRECTORY_RESULTS, domain) - .then(({documents}) => documents.map(match => ({domain: match.qualified_id?.domain || '', id: match.id}))); - - const userIds: QualifiedId[] = [...matchedUserIdsFromDirectorySearch]; - const userEntities = await this.userRepository.getUsersById(userIds); - - return Promise.resolve(userEntities) - .then(userEntities => userEntities.filter(userEntity => !userEntity.isMe)) - .then(userEntities => { - if (isHandle) { - userEntities = userEntities.filter(userEntity => startsWith(userEntity.username(), query)); - } - - return userEntities - .sort((userA, userB) => { - return isHandle - ? sortByPriority(userA.username(), userB.username(), query) - : sortByPriority(userA.name(), userB.name(), query); - }) - .slice(0, maxResults); - }); + const userIds: QualifiedId[] = await this.getContacts( + name, + SearchRepository.CONFIG.MAX_DIRECTORY_RESULTS, + domain, + ).then(({documents}) => documents.map(match => ({domain: match.qualified_id?.domain || '', id: match.id}))); + + const users = await this.userRepository.getUsersById(userIds); + + return ( + users + // Filter out selfUser + .filter(user => !user.isMe) + .filter(user => !isHandle || startsWith(user.username(), query)) + .sort((userA, userB) => { + return isHandle + ? sortByPriority(userA.username(), userB.username(), query) + : sortByPriority(userA.name(), userB.name(), query); + }) + .slice(0, maxResults) + ); } } diff --git a/src/script/search/SearchService.ts b/src/script/search/SearchService.ts deleted file mode 100644 index 1d4b32eeb79..00000000000 --- a/src/script/search/SearchService.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Wire - * Copyright (C) 2018 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 type {SearchResult} from '@wireapp/api-client/lib/user/'; -import {container} from 'tsyringe'; - -import {APIClient} from '../service/APIClientSingleton'; - -export class SearchService { - constructor(private readonly apiClient = container.resolve(APIClient)) {} - - async getContacts(query: string, numberOfRequestedUser: number, domain?: string): Promise { - const request = await this.apiClient.api.user.getSearchContacts(query, numberOfRequestedUser, domain); - return request.response; - } -} diff --git a/test/helper/TestFactory.js b/test/helper/TestFactory.js index 41407011a88..f758ee8de8f 100644 --- a/test/helper/TestFactory.js +++ b/test/helper/TestFactory.js @@ -52,7 +52,6 @@ import {PermissionRepository} from 'src/script/permission/PermissionRepository'; import {PropertiesRepository} from 'src/script/properties/PropertiesRepository'; import {PropertiesService} from 'src/script/properties/PropertiesService'; import {SearchRepository} from 'src/script/search/SearchRepository'; -import {SearchService} from 'src/script/search/SearchService'; import {SelfService} from 'src/script/self/SelfService'; import {Core} from 'src/script/service/CoreSingleton'; import {createStorageEngine, DatabaseTypes} from 'src/script/service/StoreEngineProvider'; @@ -196,8 +195,7 @@ export class TestFactory { */ async exposeSearchActors() { await this.exposeUserActors(); - this.search_service = new SearchService(); - this.search_repository = new SearchRepository(this.search_service, this.user_repository); + this.search_repository = new SearchRepository(this.user_repository); return this.search_repository; } diff --git a/test/unit_tests/search/SearchRepositorySpec.js b/test/unit_tests/search/SearchRepositorySpec.js deleted file mode 100644 index 9a6afd0f375..00000000000 --- a/test/unit_tests/search/SearchRepositorySpec.js +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Wire - * Copyright (C) 2018 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 {User} from 'src/script/entity/User'; - -import {TestFactory} from '../../helper/TestFactory'; - -describe('SearchRepository', () => { - const testFactory = new TestFactory(); - - beforeAll(() => { - return testFactory.exposeSearchActors(); - }); - - describe('searchUserInSet', () => { - const sabine = generateUser('jesuissabine', 'Sabine Duchemin'); - const janina = generateUser('yosoyjanina', 'Janina Felix'); - const felixa = generateUser('iamfelix', 'Felix Abo'); - const felix = generateUser('iamfelix', 'Felix Oulala'); - const felicien = generateUser('ichbinfelicien', 'Felicien Delatour'); - const lastguy = generateUser('lastfelicien', 'lastsabine lastjanina'); - const jeanpierre = generateUser('jean-pierre', 'Jean-Pierre Sansbijou'); - const pierre = generateUser('pierrot', 'Pierre Monsouci'); - const noMatch1 = generateUser(undefined, 'yyy yyy'); - const noMatch2 = generateUser('xxx', undefined); - const users = [lastguy, noMatch1, felix, felicien, sabine, janina, noMatch2, felixa, jeanpierre, pierre]; - - const tests = [ - {expected: users, term: '', testCase: 'returns the whole user list if no term is given'}, - {expected: [jeanpierre, janina, sabine, lastguy], term: 'j', testCase: 'matches multiple results'}, - { - expected: [janina, lastguy], - term: 'ja', - testCase: 'puts matches that start with the pattern on top of the list', - }, - { - expected: [felicien, felixa, felix, janina, lastguy], - term: 'fel', - testCase: 'sorts by name, handle, inside match and alphabetically', - }, - { - expected: [felixa, felix, janina], - term: 'felix', - testCase: 'sorts by firstname and lastname', - }, - { - expected: [felicien, lastguy], - term: 'felici', - testCase: 'sorts by name and inside match', - }, - { - expected: [sabine, jeanpierre, lastguy, pierre, janina], - term: 's', - testCase: 'sorts by name, handle and inside match', - }, - { - expected: [sabine, jeanpierre, lastguy], - term: 'sa', - testCase: 'puts matches that start with the pattern on top of the list', - }, - { - expected: [sabine, lastguy], - term: 'sabine', - testCase: 'puts matches that start with the pattern on top of the list', - }, - { - expected: [sabine, lastguy], - term: 'sabine', - testCase: 'puts matches that start with the pattern on top of the list', - }, - {expected: [felicien, lastguy], term: 'ic', testCase: 'matches inside the properties'}, - { - expected: [jeanpierre], - term: 'jean-pierre', - testCase: 'finds compound names', - }, - { - expected: [pierre, jeanpierre], - term: 'pierre', - testCase: 'matches compound names and prioritize matches from start', - }, - ]; - - tests.forEach(({expected, term, testCase}) => { - it(`${testCase} term: ${term}`, () => { - const suggestions = testFactory.search_repository.searchUserInSet(term, users); - - expect(suggestions.map(serializeUser)).toEqual(expected.map(serializeUser)); - }); - }); - - it('does not replace numbers with emojis', () => { - const felix10 = generateUser('simple10', 'Felix10'); - const unsortedUsers = [felix10]; - const suggestions = testFactory.search_repository.searchUserInSet('😋', unsortedUsers); - - expect(suggestions.map(serializeUser)).toEqual([]); - }); - - it('prioritize exact matches with special characters', () => { - const smilyFelix = generateUser('smily', '😋Felix'); - const atFelix = generateUser('at', '@Felix'); - const simplyFelix = generateUser('simple', 'Felix'); - - const unsortedUsers = [atFelix, smilyFelix, simplyFelix]; - - let suggestions = testFactory.search_repository.searchUserInSet('felix', unsortedUsers); - let expected = [simplyFelix, smilyFelix, atFelix]; - - expect(suggestions.map(serializeUser)).toEqual(expected.map(serializeUser)); - suggestions = testFactory.search_repository.searchUserInSet('😋', unsortedUsers); - expected = [smilyFelix]; - - expect(suggestions.map(serializeUser)).toEqual(expected.map(serializeUser)); - }); - - it('handles sorting matching results', () => { - const first = generateUser('xxx', '_surname'); - const second = generateUser('xxx', 'surname _lastname'); - const third = generateUser('_xxx', 'surname lastname'); - const fourth = generateUser('xxx', 'sur_name lastname'); - const fifth = generateUser('xxx', 'surname last_name'); - const sixth = generateUser('x_xx', 'surname lastname'); - - const unsortedUsers = [sixth, fifth, third, second, first, fourth]; - const expectedUsers = [first, second, third, fourth, fifth, sixth]; - - const suggestions = testFactory.search_repository.searchUserInSet('_', unsortedUsers); - - expect(suggestions.map(serializeUser)).toEqual(expectedUsers.map(serializeUser)); - }); - }); -}); - -function generateUser(handle, name) { - const user = new User(); - user.username(handle); - user.name(name); - return user; -} -function serializeUser(userEntity) { - return {name: userEntity.name(), username: userEntity.username()}; -} From f270036157d4ad6cc25ffe130e53ec9c5fa47929 Mon Sep 17 00:00:00 2001 From: Virgile <78490891+V-Gira@users.noreply.github.com> Date: Tue, 31 Oct 2023 17:51:43 +0100 Subject: [PATCH 46/86] fix: align username to the left in replies (#16136) --- .../MessagesList/Message/ContentMessage/MessageQuote.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageQuote.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageQuote.tsx index de221aedb50..28bb80561d2 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageQuote.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageQuote.tsx @@ -168,7 +168,7 @@ const QuotedMessage: FC = ({
- {is1to1 && selfUser?.inTeam() ? ( + {is1to1 && selfUser?.teamId ? ( void; selfUser: User; user: User; + teamState?: TeamState; } function createPlaceholder1to1Conversation(user: User, selfUser: User) { @@ -96,13 +99,13 @@ const UserActions: React.FC = ({ onAction, conversationRoleRepository, selfUser, + teamState = container.resolve(TeamState), }) => { const { isAvailable, isBlocked, isCanceled, isRequest, - isTeamMember, isTemporaryGuest, isUnknown, isConnected, @@ -111,7 +114,6 @@ const UserActions: React.FC = ({ } = useKoSubscribableChildren(user, [ 'isAvailable', 'isTemporaryGuest', - 'isTeamMember', 'isBlocked', 'isOutgoingRequest', 'isIncomingRequest', @@ -120,6 +122,7 @@ const UserActions: React.FC = ({ 'isUnknown', 'isConnected', ]); + const isTeamMember = teamState.isInTeam(user); const isNotMe = !user.isMe && isSelfActivated; diff --git a/src/script/components/panel/UserDetails.tsx b/src/script/components/panel/UserDetails.tsx index 70007ca0bff..becc0b73d6a 100644 --- a/src/script/components/panel/UserDetails.tsx +++ b/src/script/components/panel/UserDetails.tsx @@ -21,6 +21,7 @@ import React, {useEffect} from 'react'; import {amplify} from 'amplify'; import {ErrorBoundary} from 'react-error-boundary'; +import {container} from 'tsyringe'; import {WebAppEvents} from '@wireapp/webapp-events'; @@ -30,6 +31,7 @@ import {ErrorFallback} from 'Components/ErrorFallback'; import {Icon} from 'Components/Icon'; import {UserClassifiedBar} from 'Components/input/ClassifiedBar'; import {UserName} from 'Components/UserName'; +import {TeamState} from 'src/script/team/TeamState'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; @@ -43,6 +45,7 @@ interface UserDetailsProps { isVerified?: boolean; participant: User; avatarStyles?: React.CSSProperties; + teamState?: TeamState; } const UserDetailsComponent: React.FC = ({ @@ -52,9 +55,9 @@ const UserDetailsComponent: React.FC = ({ isGroupAdmin, avatarStyles, classifiedDomains, + teamState = container.resolve(TeamState), }) => { const user = useKoSubscribableChildren(participant, [ - 'inTeam', 'isGuest', 'isTemporaryGuest', 'expirationText', @@ -69,13 +72,10 @@ const UserDetailsComponent: React.FC = ({ amplify.publish(WebAppEvents.USER.UPDATE, participant.qualifiedId); }, [participant]); - const isFederated = participant.isFederated; - const isGuest = !isFederated && user.isGuest; - return (
- {user.inTeam ? ( + {teamState.isInTeam(participant) ? ( = ({
)} - {isFederated && ( + {participant.isFederated && (
{t('conversationFederationIndicator')}
)} - {isGuest && user.isAvailable && !isFederated && ( + {user.isGuest && user.isAvailable && (
{t('conversationGuestIndicator')} diff --git a/test/unit_tests/conversation/ConversationCellStateSpec.js b/src/script/conversation/ConversationCellState.test.ts similarity index 79% rename from test/unit_tests/conversation/ConversationCellStateSpec.js rename to src/script/conversation/ConversationCellState.test.ts index ce94fd6ce2f..ed67ecd545a 100644 --- a/test/unit_tests/conversation/ConversationCellStateSpec.js +++ b/src/script/conversation/ConversationCellState.test.ts @@ -17,6 +17,8 @@ * */ +import {CONVERSATION_TYPE} from '@wireapp/api-client/lib/conversation'; + import {generateCellState} from 'src/script/conversation/ConversationCellState'; import {ConversationStatusIcon} from 'src/script/conversation/ConversationStatusIcon'; import {NOTIFICATION_STATE} from 'src/script/conversation/NotificationSetting'; @@ -28,13 +30,16 @@ import {User} from 'src/script/entity/User'; import {t} from 'Util/LocalizerUtil'; import {createUuid} from 'Util/uuid'; +import {CallMessage} from '../entity/message/CallMessage'; +import {CALL_MESSAGE_TYPE} from '../message/CallMessageType'; + describe('ConversationCellState', () => { describe('Notification state icon', () => { const conversationEntity = new Conversation(createUuid()); const selfUserEntity = new User(createUuid()); selfUserEntity.isMe = true; - selfUserEntity.inTeam(true); + selfUserEntity.teamId = createUuid(); conversationEntity.selfUser(selfUserEntity); it('returns empty state if notifications are set to everything', () => { @@ -57,32 +62,29 @@ describe('ConversationCellState', () => { }); describe('Second line description for conversations', () => { - const defaultUnreadState = { - allEvents: [], - allMessages: [], - calls: [], - otherMessages: [], - pings: [], - selfMentions: [], - selfReplies: [], - }; - const conversationEntity = new Conversation(createUuid()); const selfUserEntity = new User(createUuid()); selfUserEntity.isMe = true; - selfUserEntity.inTeam(true); + selfUserEntity.teamId = createUuid(); conversationEntity.selfUser(selfUserEntity); conversationEntity.mutedState(NOTIFICATION_STATE.EVERYTHING); + const sender = new User(); + sender.name('Felix'); const contentMessage = new ContentMessage(); const text = new Text('id', 'Hello there'); - contentMessage.unsafeSenderName = () => 'Felix'; + contentMessage.user(sender); contentMessage.assets([text]); const pingMessage = new PingMessage(); + const callMessage = new CallMessage(CALL_MESSAGE_TYPE.ACTIVATED, undefined, 0); + + const mention = new ContentMessage(); + jest.spyOn(mention, 'isUserMentioned').mockReturnValue(true); + const tests = [ { description: 'returns the number of missed calls', @@ -90,7 +92,7 @@ describe('ConversationCellState', () => { description: t('conversationsSecondaryLineSummaryMissedCalls', 2), icon: ConversationStatusIcon.MISSED_CALL, }, - unreadState: {...defaultUnreadState, calls: [{}, {}]}, + messages: [callMessage, callMessage], }, { description: "returns unread message's text if there is only a single text message", @@ -98,7 +100,7 @@ describe('ConversationCellState', () => { group: {description: 'Felix: Hello there', icon: ConversationStatusIcon.UNREAD_MESSAGES}, one2one: {description: 'Hello there', icon: ConversationStatusIcon.UNREAD_MESSAGES}, }, - unreadState: {...defaultUnreadState, allMessages: [contentMessage]}, + messages: [contentMessage], }, { description: 'returns the number of pings', @@ -106,7 +108,7 @@ describe('ConversationCellState', () => { description: t('conversationsSecondaryLineSummaryPings', 2), icon: ConversationStatusIcon.UNREAD_PING, }, - unreadState: {...defaultUnreadState, pings: [pingMessage, pingMessage]}, + messages: [pingMessage, pingMessage], }, { description: 'returns the number of mentions', @@ -114,7 +116,7 @@ describe('ConversationCellState', () => { description: t('conversationsSecondaryLineSummaryMentions', 2), icon: ConversationStatusIcon.UNREAD_MENTION, }, - unreadState: {...defaultUnreadState, selfMentions: [1, 2]}, + messages: [mention, mention], }, { description: 'prioritizes mentions, calls, pings and messages', @@ -125,14 +127,24 @@ describe('ConversationCellState', () => { )}, ${t('conversationsSecondaryLineSummaryPings', 2)}, ${t('conversationsSecondaryLineSummaryMessages', 2)}`, icon: ConversationStatusIcon.UNREAD_MENTION, }, - unreadState: {...defaultUnreadState, calls: [1, 2], otherMessages: [1, 2], pings: [1, 2], selfMentions: [1, 2]}, + messages: [ + contentMessage, + contentMessage, + callMessage, + callMessage, + mention, + mention, + pingMessage, + pingMessage, + ], }, ]; - conversationEntity.isGroup = () => false; - tests.forEach(({description, expected, unreadState}) => { + conversationEntity.type(CONVERSATION_TYPE.ONE_TO_ONE); + + tests.forEach(({description, expected, messages}) => { const expectedOne2One = expected.one2one || expected; - conversationEntity.unreadState = () => unreadState; + conversationEntity.messages_unordered(messages); const state = generateCellState(conversationEntity); it(`${description} (1:1)`, () => { @@ -140,10 +152,10 @@ describe('ConversationCellState', () => { }); }); - conversationEntity.isGroup = () => true; - tests.forEach(({description, expected, unreadState}) => { + conversationEntity.type(CONVERSATION_TYPE.REGULAR); + tests.forEach(({description, expected, messages}) => { const expectedGroup = expected.group || expected; - conversationEntity.unreadState = () => unreadState; + conversationEntity.messages_unordered(messages); const state = generateCellState(conversationEntity); it(`${description} (group)`, () => { diff --git a/src/script/conversation/ConversationFilter.ts b/src/script/conversation/ConversationFilter.ts index 072ed39c62b..e60f875be96 100644 --- a/src/script/conversation/ConversationFilter.ts +++ b/src/script/conversation/ConversationFilter.ts @@ -29,7 +29,7 @@ export class ConversationFilter { } static isInTeam(conversationEntity: Conversation, userEntity: User): boolean { - return userEntity.teamId === conversationEntity.team_id && conversationEntity.domain === userEntity.domain; + return userEntity.teamId === conversationEntity.teamId && conversationEntity.domain === userEntity.domain; } static showCallControls(conversationEntity: Conversation, hasCall: boolean): boolean { diff --git a/src/script/conversation/ConversationMapper.test.ts b/src/script/conversation/ConversationMapper.test.ts index e5290fbdfcb..34314249d55 100644 --- a/src/script/conversation/ConversationMapper.test.ts +++ b/src/script/conversation/ConversationMapper.test.ts @@ -93,7 +93,7 @@ describe('ConversationMapper', () => { expect(conversationEntity.isGroup()).toBeTruthy(); expect(conversationEntity.name()).toBe(conversation.name); expect(conversationEntity.mutedState()).toBe(0); - expect(conversationEntity.team_id).toEqual(conversation.team); + expect(conversationEntity.teamId).toEqual(conversation.team); expect(conversationEntity.type()).toBe(CONVERSATION_TYPE.REGULAR); const expectedMutedTimestamp = new Date(conversation.members.self.otr_muted_ref).getTime(); @@ -180,7 +180,7 @@ describe('ConversationMapper', () => { const [conversationEntity] = ConversationMapper.mapConversations([payload] as ConversationDatabaseData[]); expect(conversationEntity.name()).toBe(payload.name); - expect(conversationEntity.team_id).toBe(payload.team); + expect(conversationEntity.teamId).toBe(payload.team); }); }); @@ -737,7 +737,7 @@ describe('ConversationMapper', () => { ]; const conversationEntity = new Conversation('conversation-id', 'domain'); - conversationEntity.team_id = 'team_id'; + conversationEntity.teamId = 'team_id'; ConversationMapper.mapAccessState(conversationEntity, accessModes, accessRole, accessRoleV2); expect(conversationEntity.accessState()).toEqual(ACCESS_STATE.TEAM.GUEST_ROOM); @@ -756,7 +756,7 @@ describe('ConversationMapper', () => { const accessRoleV2: undefined = undefined; const conversationEntity = new Conversation(); - conversationEntity.team_id = 'team_id'; + conversationEntity.teamId = 'team_id'; ConversationMapper.mapAccessState(conversationEntity, accessModes, accessRole, accessRoleV2); expect(conversationEntity.accessState()).toEqual(ACCESS_STATE.TEAM.GUESTS_SERVICES); @@ -820,7 +820,7 @@ describe('ConversationMapper', () => { it.each(mockRightsLegacy)('sets correct accessState for %s', (state, {accessModes, accessRole}) => { const conversationEntity = new Conversation(); - conversationEntity.team_id = 'team_id'; + conversationEntity.teamId = 'team_id'; ConversationMapper.mapAccessState(conversationEntity, accessModes, accessRole); expect(conversationEntity.accessState()).toEqual(state); @@ -860,7 +860,7 @@ describe('ConversationMapper', () => { it.each(mockAccessRights)('sets correct accessState for %s', (state, {accessModes, accessRole}) => { const conversationEntity = new Conversation(); - conversationEntity.team_id = 'team_id'; + conversationEntity.teamId = 'team_id'; ConversationMapper.mapAccessState(conversationEntity, accessModes, accessRole); expect(conversationEntity.accessState()).toEqual(state); diff --git a/src/script/conversation/ConversationMapper.ts b/src/script/conversation/ConversationMapper.ts index 8c37364c19e..88960f4a186 100644 --- a/src/script/conversation/ConversationMapper.ts +++ b/src/script/conversation/ConversationMapper.ts @@ -271,7 +271,7 @@ export class ConversationMapper { // Team ID from database or backend payload const teamId = conversationData.team_id || conversationData.team; if (teamId) { - conversationEntity.team_id = teamId; + conversationEntity.teamId = teamId; } if (conversationData.is_guest) { @@ -464,7 +464,7 @@ export class ConversationMapper { } static mapAccessCode(conversation: Conversation, accessCode: ConversationCode): void { - const isTeamConversation = conversation && conversation.team_id; + const isTeamConversation = conversation && conversation.teamId; if (accessCode.uri && isTeamConversation) { const baseUrl = `${window.wire.env.URL.ACCOUNT_BASE}/conversation-join/?key=${accessCode.key}&code=${accessCode.code}`; @@ -479,7 +479,7 @@ export class ConversationMapper { accessRole: CONVERSATION_LEGACY_ACCESS_ROLE | CONVERSATION_ACCESS_ROLE[], accessRoleV2?: CONVERSATION_ACCESS_ROLE[], ): typeof ACCESS_STATE { - if (conversationEntity.team_id) { + if (conversationEntity.teamId) { if (conversationEntity.is1to1()) { return conversationEntity.accessState(ACCESS_STATE.TEAM.ONE2ONE); } diff --git a/src/script/conversation/ConversationRepository.test.ts b/src/script/conversation/ConversationRepository.test.ts index 313e8797c91..a5b71ace373 100644 --- a/src/script/conversation/ConversationRepository.test.ts +++ b/src/script/conversation/ConversationRepository.test.ts @@ -265,7 +265,7 @@ describe('ConversationRepository', () => { const selfUser = generateUser(); selfUser.teamId = teamId; spyOn(testFactory.conversation_repository['userState'], 'self').and.returnValue(selfUser); - userEntity.inTeam(true); + userEntity.teamId = teamId; userEntity.isTeamMember(true); userEntity.teamId = teamId; diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index 8c98637ab24..3769c568e49 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -212,7 +212,7 @@ export class ConversationRepository { Promise.all(clients.map(client => this.userRepository.removeClientFromUser(userId, client))), ), ); - const removedTeamUserIds = emptyUsers.filter(user => user.inTeam()).map(user => user.qualifiedId); + const removedTeamUserIds = emptyUsers.filter(user => teamState.isInTeam(user)).map(user => user.qualifiedId); if (removedTeamUserIds.length) { // If we have found some users that were removed from the conversation, we need to check if those users were also completely removed from the team @@ -1086,7 +1086,7 @@ export class ConversationRepository { */ async get1To1Conversation(userEntity: User): Promise { const selfUser = this.userState.self(); - const inCurrentTeam = userEntity.inTeam() && !!selfUser && userEntity.teamId === selfUser.teamId; + const inCurrentTeam = selfUser && userEntity.teamId === selfUser.teamId; if (inCurrentTeam) { return this.getOrCreateProteusTeam1to1Conversation(userEntity); @@ -1362,13 +1362,12 @@ export class ConversationRepository { .forEach(conversationEntity => this._mapGuestStatusSelf(conversationEntity)); if (this.teamState.isTeam()) { - this.userState.self().inTeam(true); - this.userState.self().isTeamMember(true); + this.userState.self()?.isTeamMember(true); } } private _mapGuestStatusSelf(conversationEntity: Conversation) { - const conversationTeamId = conversationEntity.team_id; + const conversationTeamId = conversationEntity.teamId; const selfTeamId = this.teamState.team()?.id; const isConversationGuest = !!(conversationTeamId && (!selfTeamId || selfTeamId !== conversationTeamId)); conversationEntity.isGuest(isConversationGuest); @@ -1777,7 +1776,7 @@ export class ConversationRepository { const eventInjections = this.conversationState .conversations() .filter(conversationEntity => { - const conversationInTeam = conversationEntity.team_id === teamId; + const conversationInTeam = conversationEntity.teamId === teamId; const userIsParticipant = UserFilter.isParticipant(conversationEntity, userId); return conversationInTeam && userIsParticipant && !conversationEntity.removed_from_conversation(); }) @@ -3165,7 +3164,7 @@ export class ConversationRepository { return !!this.propertyRepository.receiptMode(); } - if (conversationEntity.team_id && conversationEntity.isGroup()) { + if (conversationEntity.teamId && conversationEntity.isGroup()) { return !!conversationEntity.receiptMode(); } diff --git a/src/script/conversation/ConversationRoleRepository.ts b/src/script/conversation/ConversationRoleRepository.ts index 67a702d9453..73361c11b9e 100644 --- a/src/script/conversation/ConversationRoleRepository.ts +++ b/src/script/conversation/ConversationRoleRepository.ts @@ -125,7 +125,7 @@ export class ConversationRoleRepository { }; private readonly getConversationRoles = (conversation: Conversation): ConversationRole[] => { - if (this.teamState.isTeam() && this.teamState.team()?.id === conversation.team_id) { + if (this.teamState.isTeam() && this.teamState.team()?.id === conversation.teamId) { return this.teamRoles; } return this.teamRoles; diff --git a/src/script/conversation/MessageRepository.test.ts b/src/script/conversation/MessageRepository.test.ts index a5044802f50..a8eff14abc6 100644 --- a/src/script/conversation/MessageRepository.test.ts +++ b/src/script/conversation/MessageRepository.test.ts @@ -47,6 +47,7 @@ import {EventRepository} from '../event/EventRepository'; import {EventService} from '../event/EventService'; import {PropertiesRepository} from '../properties/PropertiesRepository'; import {ReactionMap} from '../storage'; +import {TeamState} from '../team/TeamState'; import {ServerTimeHandler, serverTimeHandler} from '../time/serverTimeHandler'; import {UserRepository} from '../user/UserRepository'; import {UserState} from '../user/UserState'; @@ -80,6 +81,8 @@ async function buildMessageRepository(): Promise<[MessageRepository, MessageRepo clientState.currentClient = new ClientEntity(true, ''); const core = new Account(); + const teamState = new TeamState(); + const conversationState = new ConversationState(userState); const selfConversation = new Conversation(selfUser.id); selfConversation.selfUser(selfUser); @@ -97,6 +100,7 @@ async function buildMessageRepository(): Promise<[MessageRepository, MessageRepo userState, clientState, conversationState, + teamState, core, }; diff --git a/src/script/conversation/MessageRepository.ts b/src/script/conversation/MessageRepository.ts index b582d4ecd7b..77e48e6e2c6 100644 --- a/src/script/conversation/MessageRepository.ts +++ b/src/script/conversation/MessageRepository.ts @@ -95,6 +95,7 @@ import {PropertiesRepository} from '../properties/PropertiesRepository'; import {PROPERTIES_TYPE} from '../properties/PropertiesType'; import {Core} from '../service/CoreSingleton'; import type {EventRecord, ReactionMap} from '../storage'; +import {TeamState} from '../team/TeamState'; import {ServerTimeHandler} from '../time/serverTimeHandler'; import {UserType} from '../tracking/attribute'; import {EventName} from '../tracking/EventName'; @@ -166,6 +167,7 @@ export class MessageRepository { private readonly userState = container.resolve(UserState), private readonly clientState = container.resolve(ClientState), private readonly conversationState = container.resolve(ConversationState), + private readonly teamState = container.resolve(TeamState), private readonly core = container.resolve(Core), ) { this.logger = getLogger('MessageRepository'); @@ -965,7 +967,7 @@ export class MessageRepository { return !!this.propertyRepository.receiptMode(); } - if (conversationEntity.team_id && conversationEntity.isGroup()) { + if (conversationEntity.teamId && conversationEntity.isGroup()) { return !!conversationEntity.receiptMode(); } @@ -1139,7 +1141,7 @@ export class MessageRepository { // we can remove the filter when we actually want this feature in federated env (and we will need to implement federation for the core broadcastService) .filter(user => !user.isFederated) .sort(({id: idA}, {id: idB}) => idA.localeCompare(idB, undefined, {sensitivity: 'base'})); - const [members, other] = partition(sortedUsers, user => user.isTeamMember()); + const [members, other] = partition(sortedUsers, user => this.teamState.isInTeam(user)); const users = [this.userState.self(), ...members, ...other].slice( 0, UserRepository.CONFIG.MAXIMUM_TEAM_SIZE_BROADCAST, @@ -1473,7 +1475,7 @@ export class MessageRepository { [Segmentation.CONVERSATION.SERVICES]: roundLogarithmic(services, 6), [Segmentation.MESSAGE.ACTION]: actionType, }; - const isTeamConversation = !!conversationEntity.team_id; + const isTeamConversation = !!conversationEntity.teamId; if (isTeamConversation) { segmentations = { ...segmentations, diff --git a/src/script/entity/Conversation.test.ts b/src/script/entity/Conversation.test.ts index 83635207caf..5c14442c59f 100644 --- a/src/script/entity/Conversation.test.ts +++ b/src/script/entity/Conversation.test.ts @@ -88,7 +88,7 @@ describe('Conversation', () => { }); it('should return the expected value for team conversations', () => { - conversation_et.team_id = createUuid(); + conversation_et.teamId = createUuid(); conversation_et.type(CONVERSATION_TYPE.CONNECT); @@ -621,7 +621,7 @@ describe('Conversation', () => { conversation_et = new Conversation(createUuid()); const selfUserEntity = new User(createUuid(), null); selfUserEntity.isMe = true; - selfUserEntity.inTeam(true); + selfUserEntity.teamId = createUuid(); conversation_et.selfUser(selfUserEntity); // Is false for conversations not containing a guest @@ -650,14 +650,14 @@ describe('Conversation', () => { expect(conversation_et.hasGuest()).toBe(true); // Is false for conversations containing a guest if the self user is a personal account - selfUserEntity.inTeam(false); + selfUserEntity.teamId = createUuid(); conversation_et.type(CONVERSATION_TYPE.ONE_TO_ONE); expect(conversation_et.hasGuest()).toBe(false); conversation_et.type(CONVERSATION_TYPE.REGULAR); - expect(conversation_et.hasGuest()).toBe(false); + expect(conversation_et.hasGuest()).toBe(true); }); }); @@ -665,7 +665,7 @@ describe('Conversation', () => { it('is considered a team conversation when teamId and domain are equal', () => { const teamId = 'team1'; const conversation = new Conversation(createUuid(), 'domain.test'); - conversation.team_id = teamId; + conversation.teamId = teamId; const selfUser = new User(createUuid(), 'domain.test'); selfUser.isMe = true; selfUser.teamId = teamId; @@ -678,7 +678,7 @@ describe('Conversation', () => { it('is not considered a team conversation when teamId are equal but domains differ', () => { const teamId = 'team1'; const conversation = new Conversation(createUuid(), 'otherdomain.test'); - conversation.team_id = teamId; + conversation.teamId = teamId; const selfUser = new User(createUuid(), 'domain.test'); selfUser.isMe = true; selfUser.teamId = teamId; @@ -1003,7 +1003,7 @@ describe('Conversation', () => { conversationEntity.mutedState(NOTIFICATION_STATES.EVERYTHING); expect(conversationEntity.notificationState()).toBe(NOTIFICATION_STATES.EVERYTHING); - selfUserEntity.inTeam(true); + selfUserEntity.teamId = createUuid(); conversationEntity.mutedState(undefined); expect(conversationEntity.notificationState()).toBe(NOTIFICATION_STATES.EVERYTHING); diff --git a/src/script/entity/Conversation.ts b/src/script/entity/Conversation.ts index 202b845b59a..a5c5e69032b 100644 --- a/src/script/entity/Conversation.ts +++ b/src/script/entity/Conversation.ts @@ -163,7 +163,7 @@ export class Conversation { public readonly showNotificationsMentionsAndReplies: ko.PureComputed; public readonly showNotificationsNothing: ko.PureComputed; public status: ko.Observable; - public team_id: string; + public teamId: string; public readonly type: ko.Observable; public readonly unreadState: ko.PureComputed; public readonly verification_state: ko.Observable; @@ -196,7 +196,7 @@ export class Conversation { this.accessCode = ko.observable(); this.creator = undefined; this.name = ko.observable(); - this.team_id = undefined; + this.teamId = undefined; this.type = ko.observable(); this.is_loaded = ko.observable(false); @@ -220,9 +220,9 @@ export class Conversation { this.isGuest = ko.observable(false); this.inTeam = ko.pureComputed(() => { - const isSameTeam = this.selfUser()?.teamId === this.team_id; + const isSameTeam = this.selfUser()?.teamId === this.teamId; const isSameDomain = this.domain === this.selfUser()?.domain; - return !!this.team_id && isSameTeam && !this.isGuest() && isSameDomain; + return !!this.teamId && isSameTeam && !this.isGuest() && isSameDomain; }); this.isGuestRoom = ko.pureComputed(() => this.accessState() === ACCESS_STATE.TEAM.GUEST_ROOM); this.isGuestAndServicesRoom = ko.pureComputed(() => this.accessState() === ACCESS_STATE.TEAM.GUESTS_SERVICES); @@ -233,7 +233,7 @@ export class Conversation { this.isTeam1to1 = ko.pureComputed(() => { const isGroupConversation = this.type() === CONVERSATION_TYPE.REGULAR; const hasOneParticipant = this.participating_user_ids().length === 1; - return isGroupConversation && hasOneParticipant && this.team_id && !this.name(); + return isGroupConversation && hasOneParticipant && this.teamId && !this.name(); }); this.isGroup = ko.pureComputed(() => { const isGroupConversation = this.type() === CONVERSATION_TYPE.REGULAR; @@ -251,11 +251,11 @@ export class Conversation { this.hasDirectGuest = ko.pureComputed(() => { const hasGuestUser = this.participating_user_ets().some(userEntity => userEntity.isDirectGuest()); - return hasGuestUser && this.isGroup() && this.selfUser()?.inTeam(); + return hasGuestUser && this.isGroup(); }); this.hasGuest = ko.pureComputed(() => { const hasGuestUser = this.participating_user_ets().some(userEntity => userEntity.isGuest()); - return hasGuestUser && this.isGroup() && this.selfUser()?.inTeam(); + return hasGuestUser && this.isGroup(); }); this.hasService = ko.pureComputed(() => this.participating_user_ets().some(userEntity => userEntity.isService)); this.hasExternal = ko.pureComputed(() => this.participating_user_ets().some(userEntity => userEntity.isExternal())); @@ -300,13 +300,13 @@ export class Conversation { const knownNotificationStates = Object.values(NOTIFICATION_STATE); if (knownNotificationStates.includes(mutedState)) { const isStateMentionsAndReplies = mutedState === NOTIFICATION_STATE.MENTIONS_AND_REPLIES; - const isInvalidState = isStateMentionsAndReplies && !this.selfUser().inTeam(); + const isInvalidState = isStateMentionsAndReplies && !this.selfUser()?.teamId; return isInvalidState ? NOTIFICATION_STATE.NOTHING : mutedState; } if (typeof mutedState === 'boolean') { - const migratedMutedState = this.selfUser().inTeam() + const migratedMutedState = !!this.selfUser()?.teamId ? NOTIFICATION_STATE.MENTIONS_AND_REPLIES : NOTIFICATION_STATE.NOTHING; return this.mutedState() ? migratedMutedState : NOTIFICATION_STATE.EVERYTHING; @@ -1020,7 +1020,7 @@ export class Conversation { receipt_mode: this.receiptMode(), roles: this.roles(), status: this.status(), - team_id: this.team_id, + team_id: this.teamId, type: this.type(), verification_state: this.verification_state(), mlsVerificationState: this.mlsVerificationState(), diff --git a/src/script/entity/User/User.ts b/src/script/entity/User/User.ts index 25512fff2d4..fcb103bd1e0 100644 --- a/src/script/entity/User/User.ts +++ b/src/script/entity/User/User.ts @@ -62,7 +62,6 @@ export class User { public readonly expirationText: ko.Observable; public readonly hasPendingLegalHold: ko.PureComputed; public readonly initials: ko.PureComputed; - public readonly inTeam: ko.Observable; public readonly is_trusted: ko.PureComputed; // Manual Proteus verification public readonly is_verified: ko.PureComputed; @@ -87,6 +86,7 @@ export class User { public readonly isOnLegalHold: ko.PureComputed; public readonly isOutgoingRequest: ko.PureComputed; public readonly isRequest: ko.PureComputed; + /** @deprecated use teamState.isInTeam method instead */ public readonly isTeamMember: ko.Observable = ko.observable(false); public readonly isTemporaryGuest: ko.Observable; public readonly isUnknown: ko.PureComputed; @@ -184,7 +184,6 @@ export class User { this.isUnknown = ko.pureComputed(() => this.connection().isUnknown()); this.isExternal = ko.pureComputed(() => this.teamRole() === TEAM_ROLE.PARTNER); - this.inTeam = ko.observable(false); this.isGuest = ko.observable(false); this.isDirectGuest = ko.pureComputed(() => { return this.isGuest() && !this.isFederated; diff --git a/src/script/notification/NotificationRepository.test.ts b/src/script/notification/NotificationRepository.test.ts index 5380a4c88ae..8cdada68c67 100644 --- a/src/script/notification/NotificationRepository.test.ts +++ b/src/script/notification/NotificationRepository.test.ts @@ -108,7 +108,7 @@ describe('NotificationRepository', () => { [conversation] = ConversationMapper.mapConversations([entities.conversation]); const selfUserEntity = new User(createUuid()); selfUserEntity.isMe = true; - selfUserEntity.inTeam(true); + selfUserEntity.teamId = createUuid(); conversation.selfUser(selfUserEntity); userState.self(selfUserEntity); @@ -796,7 +796,7 @@ describe('NotificationRepository', () => { beforeEach(() => { const selfUserEntity = new User(userId.id); selfUserEntity.isMe = true; - selfUserEntity.inTeam(true); + selfUserEntity.teamId = createUuid(); conversationEntity = new Conversation(createUuid()); conversationEntity.selfUser(selfUserEntity); diff --git a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx index 8dc822a55c1..332256cf60e 100644 --- a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx +++ b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx @@ -30,7 +30,6 @@ import {Icon} from 'Components/Icon'; import {UserList, UserlistMode} from 'Components/UserList'; import {Conversation} from 'src/script/entity/Conversation'; import {UserRepository} from 'src/script/user/UserRepository'; -import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; import {getLogger} from 'Util/Logger'; import {safeWindowOpen} from 'Util/SanitizationUtil'; @@ -101,7 +100,7 @@ export const PeopleTab = ({ const [hasFederationError, setHasFederationError] = useState(false); const currentSearchQuery = useRef(''); - const {inTeam} = useKoSubscribableChildren(selfUser, ['inTeam']); + const inTeam = teamState.isInTeam(selfUser); const getLocalUsers = (unfiltered?: boolean) => { const connectedUsers = conversationState.connectedUsers(); diff --git a/src/script/page/MainContent/MainContent.tsx b/src/script/page/MainContent/MainContent.tsx index c9f811c0f6a..e24aa64c3cd 100644 --- a/src/script/page/MainContent/MainContent.tsx +++ b/src/script/page/MainContent/MainContent.tsx @@ -150,7 +150,7 @@ const MainContent: FC = ({ className={cx('preferences-page preferences-about', incomingCssClass)} ref={removeAnimationsClass} > - +
)} diff --git a/src/script/page/MainContent/panels/preferences/AboutPreferences.tsx b/src/script/page/MainContent/panels/preferences/AboutPreferences.tsx index 4b59e66f162..e84f03cb97a 100644 --- a/src/script/page/MainContent/panels/preferences/AboutPreferences.tsx +++ b/src/script/page/MainContent/panels/preferences/AboutPreferences.tsx @@ -19,9 +19,11 @@ import React, {useMemo} from 'react'; +import {container} from 'tsyringe'; + import {Link, LinkVariant} from '@wireapp/react-ui-kit'; -import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {TeamState} from 'src/script/team/TeamState'; import {t} from 'Util/LocalizerUtil'; import {PreferencesPage} from './components/PreferencesPage'; @@ -33,10 +35,11 @@ import {getPrivacyPolicyUrl, getTermsOfUsePersonalUrl, getTermsOfUseTeamUrl, URL interface AboutPreferencesProps { selfUser: User; + teamState: TeamState; } -const AboutPreferences: React.FC = ({selfUser}) => { - const {inTeam} = useKoSubscribableChildren(selfUser, ['inTeam']); +const AboutPreferences: React.FC = ({selfUser, teamState = container.resolve(TeamState)}) => { + const inTeam = teamState.isInTeam(selfUser); const config = Config.getConfig(); const websiteUrl = URL.WEBSITE; const privacyPolicyUrl = getPrivacyPolicyUrl(); diff --git a/src/script/page/RightSidebar/ConversationDetails/ConversationDetails.tsx b/src/script/page/RightSidebar/ConversationDetails/ConversationDetails.tsx index 8ba1633d693..ab4bd729287 100644 --- a/src/script/page/RightSidebar/ConversationDetails/ConversationDetails.tsx +++ b/src/script/page/RightSidebar/ConversationDetails/ConversationDetails.tsx @@ -136,7 +136,7 @@ const ConversationDetails = forwardRef 'firstUserEntity', ]); - const teamId = activeConversation.team_id; + const teamId = activeConversation.teamId; const { isTeam, @@ -438,7 +438,7 @@ const ConversationDetails = forwardRef !!( isSingleUserMode && firstParticipant && - (firstParticipant.isConnected() || firstParticipant.inTeam()) + (firstParticipant.isConnected() || teamState.isInTeam(firstParticipant)) ) } showDevices={openParticipantDevices} diff --git a/src/script/page/RightSidebar/GuestServicesOptions/components/GuestOptions/GuestOptions.tsx b/src/script/page/RightSidebar/GuestServicesOptions/components/GuestOptions/GuestOptions.tsx index 13f5a74b9c4..4eff307ebbd 100644 --- a/src/script/page/RightSidebar/GuestServicesOptions/components/GuestOptions/GuestOptions.tsx +++ b/src/script/page/RightSidebar/GuestServicesOptions/components/GuestOptions/GuestOptions.tsx @@ -20,6 +20,7 @@ import {FC, useCallback, useEffect, useMemo, useState} from 'react'; import cx from 'classnames'; +import {container} from 'tsyringe'; import {Button, ButtonVariant} from '@wireapp/react-ui-kit'; @@ -27,6 +28,7 @@ import {CopyToClipboard} from 'Components/CopyToClipboard'; import {Icon} from 'Components/Icon'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; import {BaseToggle} from 'Components/toggle/BaseToggle'; +import {TeamState} from 'src/script/team/TeamState'; import {copyText} from 'Util/ClipboardUtil'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {t} from 'Util/LocalizerUtil'; @@ -49,6 +51,7 @@ interface GuestOptionsProps { isRequestOngoing?: boolean; isTeamStateGuestLinkEnabled?: boolean; isToggleDisabled?: boolean; + teamState?: TeamState; } const GuestOptions: FC = ({ @@ -60,14 +63,16 @@ const GuestOptions: FC = ({ isRequestOngoing = false, isTeamStateGuestLinkEnabled = false, isToggleDisabled = false, + teamState = container.resolve(TeamState), }) => { const [isLinkCopied, setIsLinkCopied] = useState(false); const [conversationHasGuestLinkEnabled, setConversationHasGuestLinkEnabled] = useState(false); - const {accessCode, hasGuest, inTeam, isGuestAndServicesRoom, isGuestRoom, isServicesRoom} = useKoSubscribableChildren( + const {accessCode, hasGuest, isGuestAndServicesRoom, isGuestRoom, isServicesRoom} = useKoSubscribableChildren( activeConversation, - ['accessCode', 'hasGuest', 'inTeam', 'isGuestAndServicesRoom', 'isGuestRoom', 'isServicesRoom'], + ['accessCode', 'hasGuest', 'isGuestAndServicesRoom', 'isGuestRoom', 'isServicesRoom'], ); + const inTeam = teamState.isInTeam(activeConversation); const isGuestEnabled = isGuestRoom || isGuestAndServicesRoom; const isGuestLinkEnabled = inTeam diff --git a/src/script/page/RightSidebar/MessageDetails/MessageDetails.test.tsx b/src/script/page/RightSidebar/MessageDetails/MessageDetails.test.tsx index 3ad0c0f2cc3..6c01fe67082 100644 --- a/src/script/page/RightSidebar/MessageDetails/MessageDetails.test.tsx +++ b/src/script/page/RightSidebar/MessageDetails/MessageDetails.test.tsx @@ -66,7 +66,7 @@ const getDefaultParams = (showReactions: boolean = false) => { describe('MessageDetails', () => { it('renders no reactions view', async () => { const conversation = new Conversation(); - conversation.team_id = 'mock-team-id'; + conversation.teamId = 'mock-team-id'; const timestamp = new Date('2022-01-21T15:08:14.225Z').getTime(); const userName = 'Jan Kowalski'; diff --git a/src/script/page/RightSidebar/MessageDetails/MessageDetails.tsx b/src/script/page/RightSidebar/MessageDetails/MessageDetails.tsx index 54fc6560730..e2edd85031e 100644 --- a/src/script/page/RightSidebar/MessageDetails/MessageDetails.tsx +++ b/src/script/page/RightSidebar/MessageDetails/MessageDetails.tsx @@ -92,7 +92,7 @@ const MessageDetails: FC = ({ } = useKoSubscribableChildren(messageEntity, ['timestamp', 'user', 'reactions', 'readReceipts', 'edited_timestamp']); const totalNbReactions = reactions.reduce((acc, [, users]) => acc + users.length, 0); - const teamId = activeConversation.team_id; + const teamId = activeConversation.teamId; const supportsReceipts = messageSender.isMe && teamId; const receiptUsers = userRepository diff --git a/src/script/properties/PropertiesRepository.ts b/src/script/properties/PropertiesRepository.ts index cf34afe4b42..79092b64530 100644 --- a/src/script/properties/PropertiesRepository.ts +++ b/src/script/properties/PropertiesRepository.ts @@ -115,7 +115,7 @@ export class PropertiesRepository { const isCheckConsentDisabled = !Config.getConfig().FEATURE.CHECK_CONSENT; const isPrivacyPreferenceSet = this.getPreference(PROPERTIES_TYPE.PRIVACY) !== undefined; const isTelemetryPreferenceSet = this.getPreference(PROPERTIES_TYPE.TELEMETRY_SHARING) !== undefined; - const isTeamAccount = this.selfUser().inTeam(); + const isTeamAccount = !!this.selfUser().teamId; const enablePrivacy = () => { this.savePreference(PROPERTIES_TYPE.PRIVACY, true); this.publishProperties(); diff --git a/src/script/team/TeamState.ts b/src/script/team/TeamState.ts index b5b99aaa6c0..e5998d53175 100644 --- a/src/script/team/TeamState.ts +++ b/src/script/team/TeamState.ts @@ -25,6 +25,7 @@ import {sortUsersByPriority} from 'Util/StringUtil'; import {TeamEntity} from './TeamEntity'; +import {Conversation} from '../entity/Conversation'; import {User} from '../entity/User'; import {ROLE} from '../user/UserPermission'; import {UserState} from '../user/UserState'; @@ -56,18 +57,16 @@ export class TeamState { /** all the members of the team + the users the selfUser is connected with */ readonly teamUsers: ko.PureComputed; readonly isTeam: ko.PureComputed; - readonly team: ko.Observable; + readonly team = ko.observable(new TeamEntity()); readonly teamDomain: ko.PureComputed; readonly teamSize: ko.PureComputed; constructor(private readonly userState = container.resolve(UserState)) { - this.team = ko.observable(); - this.isTeam = ko.pureComputed(() => !!this.team()?.id); this.isTeamDeleted = ko.observable(false); /** Note: this does not include the self user */ - this.teamMembers = ko.pureComputed(() => this.userState.users().filter(user => !user.isMe && user.isTeamMember())); + this.teamMembers = ko.pureComputed(() => this.userState.users().filter(user => !user.isMe && this.isInTeam(user))); this.memberRoles = ko.observable({}); this.memberInviters = ko.observable({}); this.teamFeatures = ko.observable(); @@ -134,6 +133,11 @@ export class TeamState { ); } + isInTeam(entity: User | Conversation): boolean { + const team = this.team(); + return !!team.id && entity.domain === this.teamDomain() && entity.teamId === team.id; + } + readonly isExternal = (userId: string): boolean => { return this.memberRoles()[userId] === ROLE.PARTNER; }; diff --git a/src/script/tracking/Helpers.ts b/src/script/tracking/Helpers.ts index 8de27259bf3..7f20d1380b6 100644 --- a/src/script/tracking/Helpers.ts +++ b/src/script/tracking/Helpers.ts @@ -42,7 +42,7 @@ export function getConversationType(conversationEntity: any): ConversationType | } } export function getGuestAttributes(conversationEntity: Conversation): GuestAttributes { - const isTeamConversation = !!conversationEntity.team_id; + const isTeamConversation = !!conversationEntity.teamId; if (isTeamConversation) { const isAllowGuests = !conversationEntity.isTeamOnly(); const _getUserType = (_conversationEntity: Conversation) => { diff --git a/src/script/user/UserMapper.test.ts b/src/script/user/UserMapper.test.ts index f4d887be017..f68ed7f25bd 100644 --- a/src/script/user/UserMapper.test.ts +++ b/src/script/user/UserMapper.test.ts @@ -81,7 +81,6 @@ describe('User Mapper', () => { ); expect(user.isFederated).toBe(true); - expect(user.inTeam()).toBe(false); }); it('can convert users with profile images marked as non public', () => { diff --git a/src/script/user/UserMapper.ts b/src/script/user/UserMapper.ts index ea047ab9ef2..3439fbd7443 100644 --- a/src/script/user/UserMapper.ts +++ b/src/script/user/UserMapper.ts @@ -17,8 +17,6 @@ * */ -import {container} from 'tsyringe'; - import {getLogger, Logger} from 'Util/Logger'; import {isSelfAPIUser} from './UserGuards'; @@ -26,7 +24,6 @@ import {isSelfAPIUser} from './UserGuards'; import {mapProfileAssets, mapProfileAssetsV1, updateUserEntityAssets} from '../assets/AssetMapper'; import {User} from '../entity/User'; import {UserRecord} from '../storage'; -import {TeamState} from '../team/TeamState'; import type {ServerTimeHandler} from '../time/serverTimeHandler'; import '../view_model/bindings/CommonBindings'; @@ -37,10 +34,7 @@ export class UserMapper { * Construct a new User Mapper. * @param serverTimeHandler Handles time shift between server and client */ - constructor( - private readonly serverTimeHandler: ServerTimeHandler, - private teamState = container.resolve(TeamState), - ) { + constructor(private readonly serverTimeHandler: ServerTimeHandler) { this.logger = getLogger('UserMapper'); } @@ -187,12 +181,8 @@ export class UserMapper { } } - const currentTeam = this.teamState.team()?.id; if (teamId) { userEntity.teamId = teamId; - if (!userEntity.isFederated && currentTeam && currentTeam === teamId) { - userEntity.inTeam(true); - } } if (deleted) { diff --git a/src/script/user/UserRepository.ts b/src/script/user/UserRepository.ts index 8e52c22b753..e911eb60cf6 100644 --- a/src/script/user/UserRepository.ts +++ b/src/script/user/UserRepository.ts @@ -795,7 +795,7 @@ export class UserRepository { if (this.teamState.isTeam()) { this.mapGuestStatus([updatedUser]); } - if (updatedUser && updatedUser.inTeam() && updatedUser.isDeleted) { + if (updatedUser && this.teamState.isInTeam(updatedUser) && updatedUser.isDeleted) { amplify.publish(WebAppEvents.TEAM.MEMBER_LEAVE, updatedUser.teamId, userId); } return updatedUser; @@ -911,8 +911,8 @@ export class UserRepository { const selfTeamId = this.userState.self().teamId; userEntities.forEach(userEntity => { if (!userEntity.isMe && selfTeamId) { - const isTeamMember = selfTeamId === userEntity.teamId; - const isGuest = !userEntity.isService && !isTeamMember && selfTeamId !== userEntity.teamId; + const isTeamMember = this.teamState.isInTeam(userEntity); + const isGuest = !userEntity.isService && !isTeamMember; userEntity.isGuest(isGuest); userEntity.isTeamMember(isTeamMember); } From d9ea9ca65ff6958ee1ec77ed2c56b7d389343e0e Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Mon, 6 Nov 2023 16:45:16 +0100 Subject: [PATCH 78/86] runfix: Make sure we are in team when creating team 1:1 conversations --- src/script/conversation/ConversationRepository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script/conversation/ConversationRepository.ts b/src/script/conversation/ConversationRepository.ts index 3769c568e49..49bd8c31ce8 100644 --- a/src/script/conversation/ConversationRepository.ts +++ b/src/script/conversation/ConversationRepository.ts @@ -1086,7 +1086,7 @@ export class ConversationRepository { */ async get1To1Conversation(userEntity: User): Promise { const selfUser = this.userState.self(); - const inCurrentTeam = selfUser && userEntity.teamId === selfUser.teamId; + const inCurrentTeam = selfUser && selfUser.teamId && userEntity.teamId === selfUser.teamId; if (inCurrentTeam) { return this.getOrCreateProteusTeam1to1Conversation(userEntity); From 40bfeaf93ba65292d105e54092cbb13458389432 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 7 Nov 2023 09:53:50 +0100 Subject: [PATCH 79/86] runfix: Fix checking self user team when call starts (#16161) --- src/script/view_model/CallingViewModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/script/view_model/CallingViewModel.ts b/src/script/view_model/CallingViewModel.ts index 60aa785e0bb..a3381901a41 100644 --- a/src/script/view_model/CallingViewModel.ts +++ b/src/script/view_model/CallingViewModel.ts @@ -101,7 +101,7 @@ export class CallingViewModel { readonly permissionRepository: PermissionRepository, readonly teamRepository: TeamRepository, readonly propertiesRepository: PropertiesRepository, - private readonly selfUser: ko.Subscribable, + private readonly selfUser: ko.Observable, readonly multitasking: Multitasking, private readonly conversationState = container.resolve(ConversationState), readonly callState = container.resolve(CallState), @@ -478,7 +478,7 @@ export class CallingViewModel { } private showRestrictedConferenceCallingModal() { - if (this.selfUser().inTeam()) { + if (this.teamState.isInTeam(this.selfUser())) { if (this.selfUser().teamRole() === ROLE.OWNER) { const replaceEnterprise = replaceLink( Config.getConfig().URL.PRICING, From fe03910df297d65e8680b76b399731fddba32519 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 7 Nov 2023 12:55:41 +0100 Subject: [PATCH 80/86] feat: Do not load all team members on app load [WPB-4553 WPB-4548 WPB-1912] (#16160) --- src/script/components/UserSearchableList.tsx | 2 +- src/script/main/app.ts | 9 +- .../LeftSidebar/panels/StartUI/PeopleTab.tsx | 2 +- src/script/search/SearchRepository.test.ts | 22 ++ src/script/search/SearchRepository.ts | 8 +- src/script/team/TeamRepository.test.ts | 19 -- src/script/team/TeamRepository.ts | 40 ++-- src/script/user/UserRepository.test.ts | 200 +++++++++--------- src/script/user/UserRepository.ts | 15 +- test/helper/UserGenerator.ts | 6 +- 10 files changed, 159 insertions(+), 164 deletions(-) diff --git a/src/script/components/UserSearchableList.tsx b/src/script/components/UserSearchableList.tsx index 40948c4fb5a..bdc1ed2ffd6 100644 --- a/src/script/components/UserSearchableList.tsx +++ b/src/script/components/UserSearchableList.tsx @@ -82,7 +82,7 @@ const UserSearchableList: React.FC = ({ */ const fetchMembersFromBackend = useCallback( debounce(async (query: string, ignoreMembers: User[]) => { - const resultUsers = await searchRepository.searchByName(query); + const resultUsers = await searchRepository.searchByName(query, selfUser.teamId); const selfTeamId = selfUser.teamId; const foundMembers = resultUsers.filter(user => user.teamId === selfTeamId); const ignoreIds = ignoreMembers.map(member => member.id); diff --git a/src/script/main/app.ts b/src/script/main/app.ts index e280d013c88..cfb48d8cf75 100644 --- a/src/script/main/app.ts +++ b/src/script/main/app.ts @@ -415,15 +415,18 @@ export class App { onProgress(10); telemetry.timeStep(AppInitTimingsStep.INITIALIZED_CRYPTOGRAPHY); - const {members: teamMembers} = await teamRepository.initTeam(selfUser.teamId); + const connections = await connectionRepository.getConnections(); + telemetry.timeStep(AppInitTimingsStep.RECEIVED_USER_DATA); - const connections = await connectionRepository.getConnections(); telemetry.addStatistic(AppInitStatisticsValue.CONNECTIONS, connections.length, 50); const conversations = await conversationRepository.loadConversations(); - await userRepository.loadUsers(selfUser, connections, conversations, teamMembers); + const contacts = await userRepository.loadUsers(selfUser, connections, conversations); + if (selfUser.teamId) { + await teamRepository.initTeam(selfUser.teamId, contacts); + } if (supportsMLS()) { //if mls is supported, we need to initialize the callbacks (they are used when decrypting messages) diff --git a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx index 332256cf60e..7b286256db9 100644 --- a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx +++ b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx @@ -195,7 +195,7 @@ export const PeopleTab = ({ onSearchResults(localSearchResults); if (canSearchUnconnectedUsers) { try { - const userEntities = await searchRepository.searchByName(searchQuery); + const userEntities = await searchRepository.searchByName(searchQuery, selfUser.teamId); const localUserIds = localSearchResults.contacts.map(({id}) => id); const onlyRemoteUsers = userEntities.filter(user => !localUserIds.includes(user.id)); const results = inTeam diff --git a/src/script/search/SearchRepository.test.ts b/src/script/search/SearchRepository.test.ts index b3cc35fae6e..5c215db18a9 100644 --- a/src/script/search/SearchRepository.test.ts +++ b/src/script/search/SearchRepository.test.ts @@ -19,6 +19,7 @@ import {User} from 'src/script/entity/User'; import {generateUser} from 'test/helper/UserGenerator'; +import {createUuid} from 'Util/uuid'; import {SearchRepository} from './SearchRepository'; @@ -233,6 +234,27 @@ describe('SearchRepository', () => { expect(suggestions.length).toEqual(localUsers.length - 1); }); + + it('returns team users first', async () => { + const [searchRepository, {apiClient, userRepository}] = buildSearchRepository(); + const teamId = createUuid(); + const teamUsers = [generateUser(undefined, {team: teamId}), generateUser(undefined, {team: teamId})]; + const otherTeamUsers = [generateUser(undefined, {team: createUuid()})]; + const localUsers = [generateUser(), generateUser(), generateUser()]; + const allUsers = [...localUsers, ...otherTeamUsers, ...teamUsers]; + userRepository.getUsersById.mockResolvedValue(allUsers); + + const searchResults = allUsers.map(({qualifiedId}) => qualifiedId); + jest + .spyOn(apiClient.api.user, 'getSearchContacts') + .mockResolvedValue({response: {documents: searchResults}} as any); + + const suggestions = await searchRepository.searchByName('term', teamId); + + expect(suggestions.length).toEqual(allUsers.length); + expect(suggestions[0].teamId).toEqual(teamId); + expect(suggestions[1].teamId).toEqual(teamId); + }); }); }); diff --git a/src/script/search/SearchRepository.ts b/src/script/search/SearchRepository.ts index 21778d89445..89424cffeb8 100644 --- a/src/script/search/SearchRepository.ts +++ b/src/script/search/SearchRepository.ts @@ -178,11 +178,11 @@ export class SearchRepository { * @note We skip a few results as connection changes need a while to reflect on the backend. * * @param query Search query - * @param isHandle Is query a user handle + * @param teamId Current team ID the selfUser is in (will help prioritize results) * @param maxResults Maximum number of results * @returns Resolves with the search results */ - async searchByName(term: string, maxResults = CONFIG.MAX_SEARCH_RESULTS): Promise { + async searchByName(term: string, teamId = '', maxResults = CONFIG.MAX_SEARCH_RESULTS): Promise { const {query, isHandleQuery} = this.normalizeQuery(term); const [rawName, rawDomain] = this.core.backendFeatures.isFederated ? query.split('@') : [query]; const [name, domain] = validateHandle(rawName, rawDomain) ? [rawName, rawDomain] : [query]; @@ -199,6 +199,10 @@ export class SearchRepository { .filter(user => !user.isMe) .filter(user => !isHandleQuery || startsWith(user.username(), query)) .sort((userA, userB) => { + if (userA.teamId === teamId && userB.teamId !== teamId) { + // put team members first + return -1; + } return isHandleQuery ? sortByPriority(userA.username(), userB.username(), query) : sortByPriority(userA.name(), userB.name(), query); diff --git a/src/script/team/TeamRepository.test.ts b/src/script/team/TeamRepository.test.ts index 42cc677b432..d4f059b3c6e 100644 --- a/src/script/team/TeamRepository.test.ts +++ b/src/script/team/TeamRepository.test.ts @@ -17,8 +17,6 @@ * */ -import {Permissions} from '@wireapp/api-client/lib/team/member'; - import {randomUUID} from 'crypto'; import {User} from 'src/script/entity/User'; @@ -69,12 +67,6 @@ describe('TeamRepository', () => { has_more: false, }; const team_metadata = teams_data.teams[0]; - const team_members = { - members: [ - {user: randomUUID(), permissions: {copy: Permissions.DEFAULT, self: Permissions.DEFAULT}}, - {user: randomUUID(), permissions: {copy: Permissions.DEFAULT, self: Permissions.DEFAULT}}, - ], - }; describe('getTeam()', () => { it('returns the team entity', async () => { @@ -91,17 +83,6 @@ describe('TeamRepository', () => { }); }); - describe('getAllTeamMembers()', () => { - it('returns team member entities', async () => { - const [teamRepo, {teamService}] = buildConnectionRepository(); - jest.spyOn(teamService, 'getAllTeamMembers').mockResolvedValue({hasMore: false, members: team_members.members}); - const entities = await teamRepo['getAllTeamMembers'](team_metadata.id); - expect(entities.length).toEqual(team_members.members.length); - expect(entities[0].userId).toEqual(team_members.members[0].user); - expect(entities[0].permissions).toEqual(team_members.members[0].permissions); - }); - }); - describe('sendAccountInfo', () => { it('does not crash when there is no team logo', async () => { const [teamRepo] = buildConnectionRepository(); diff --git a/src/script/team/TeamRepository.ts b/src/script/team/TeamRepository.ts index d6734d10c6f..c38e4cdd74b 100644 --- a/src/script/team/TeamRepository.ts +++ b/src/script/team/TeamRepository.ts @@ -31,7 +31,6 @@ import type { import {TEAM_EVENT} from '@wireapp/api-client/lib/event/TeamEvent'; import {FeatureStatus, FeatureList} from '@wireapp/api-client/lib/team/feature/'; import type {TeamData} from '@wireapp/api-client/lib/team/team/TeamData'; -import {QualifiedId} from '@wireapp/api-client/lib/user'; import {amplify} from 'amplify'; import {container} from 'tsyringe'; @@ -127,19 +126,25 @@ export class TeamRepository extends TypedEventEmitter { ); }; - initTeam = async ( - teamId?: string, - ): Promise<{team: TeamEntity; members: QualifiedId[]} | {team: undefined; members: never[]}> => { + /** + * Will init the team configuration and all the team members from the contact list. + * @param teamId the Id of the team to init + * @param contacts all the contacts the self user has, team members will be deduced from it. + */ + async initTeam(teamId: string, contacts: User[] = []): Promise { const team = await this.getTeam(); // get the fresh feature config from backend await this.updateFeatureConfig(); if (!teamId) { - return {team: undefined, members: []}; + return undefined; } - const members = await this.loadTeamMembers(team); + await this.updateTeamMembersByIds( + team, + contacts.filter(user => user.teamId === teamId).map(({id}) => id), + ); this.scheduleTeamRefresh(); - return {team, members}; - }; + return team; + } private async updateFeatureConfig(): Promise<{newFeatureList: FeatureList; prevFeatureList?: FeatureList}> { const prevFeatureList = this.teamState.teamFeatures(); @@ -189,14 +194,6 @@ export class TeamRepository extends TypedEventEmitter { return memberEntity; } - private async getAllTeamMembers(teamId: string): Promise { - const {members, hasMore} = await this.teamService.getAllTeamMembers(teamId); - if (!hasMore && members.length) { - return this.teamMapper.mapMembers(members); - } - return []; - } - async conversationHasGuestLinkEnabled(conversationId: string): Promise { return this.teamService.conversationHasGuestLink(conversationId); } @@ -351,17 +348,6 @@ export class TeamRepository extends TypedEventEmitter { this.updateMemberRoles(teamEntity, mappedMembers); } - private async loadTeamMembers(teamEntity: TeamEntity): Promise { - const teamMembers = await this.getAllTeamMembers(teamEntity.id); - this.teamState.memberRoles({}); - this.teamState.memberInviters({}); - - this.updateMemberRoles(teamEntity, teamMembers); - return teamMembers - .filter(({userId}) => userId !== this.userState.self().id) - .map(memberEntity => ({domain: this.teamState.teamDomain() ?? '', id: memberEntity.userId})); - } - private addUserToTeam(userEntity: User): void { const members = this.teamState.team().members; diff --git a/src/script/user/UserRepository.test.ts b/src/script/user/UserRepository.test.ts index fa8dd48bb2e..7f377ce195e 100644 --- a/src/script/user/UserRepository.test.ts +++ b/src/script/user/UserRepository.test.ts @@ -18,7 +18,7 @@ */ import {RECEIPT_MODE} from '@wireapp/api-client/lib/conversation/data'; -import {QualifiedId} from '@wireapp/api-client/lib/user'; +import type {User as APIClientUser} from '@wireapp/api-client/lib/user'; import {amplify} from 'amplify'; import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; @@ -32,34 +32,71 @@ import {matchQualifiedIds} from 'Util/QualifiedId'; import {ConsentValue} from './ConsentValue'; import {UserRepository} from './UserRepository'; +import {UserService} from './UserService'; import {UserState} from './UserState'; +import {AssetRepository} from '../assets/AssetRepository'; +import {ClientRepository} from '../client'; import {ClientMapper} from '../client/ClientMapper'; import {ConnectionEntity} from '../connection/ConnectionEntity'; import {User} from '../entity/User'; import {EventRepository} from '../event/EventRepository'; import {PropertiesRepository} from '../properties/PropertiesRepository'; - -describe('UserRepository', () => { - const testFactory = new TestFactory(); - let userRepository: UserRepository; - let userState: UserState; - - beforeAll(async () => { - userRepository = await testFactory.exposeUserActors(); - userState = userRepository['userState']; - }); - - afterEach(() => { - userRepository['userState'].users.removeAll(); +import {SelfService} from '../self/SelfService'; +import {TeamState} from '../team/TeamState'; +import {serverTimeHandler} from '../time/serverTimeHandler'; + +const testFactory = new TestFactory(); +async function buildUserRepository() { + const storageRepo = await testFactory.exposeStorageActors(); + + const userService = new UserService(storageRepo['storageService']); + const assetRepository = new AssetRepository(); + const selfService = new SelfService(); + const clientRepository = new ClientRepository({} as any, {} as any); + const propertyRepository = new PropertiesRepository({} as any, {} as any); + const userState = new UserState(); + const teamState = new TeamState(); + + const userRepository = new UserRepository( + userService, + assetRepository, + selfService, + clientRepository, + serverTimeHandler, + propertyRepository, + userState, + teamState, + ); + return [ + userRepository, + { + userService, + assetRepository, + selfService, + clientRepository, + serverTimeHandler, + propertyRepository, + userState, + teamState, + }, + ] as const; +} + +function createConnections(users: APIClientUser[]) { + return users.map(user => { + const connection = new ConnectionEntity(); + connection.userId = user.qualified_id; + return connection; }); +} +describe('UserRepository', () => { describe('Account preferences', () => { describe('Data usage permissions', () => { - it('syncs the "Send anonymous data" preference through WebSocket events', () => { - const setPropertyMock = jest - .spyOn(userRepository['propertyRepository'], 'setProperty') - .mockReturnValue(undefined); + it('syncs the "Send anonymous data" preference through WebSocket events', async () => { + const [, {propertyRepository}] = await buildUserRepository(); + const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); const turnOnErrorReporting = { key: 'webapp', type: 'user.properties-set', @@ -97,10 +134,9 @@ describe('UserRepository', () => { expect(setPropertyMock).toHaveBeenCalledWith(turnOffErrorReporting.key, turnOffErrorReporting.value); }); - it('syncs the "Receive newsletter" preference through WebSocket events', () => { - const setPropertyMock = jest - .spyOn(userRepository['propertyRepository'], 'setProperty') - .mockReturnValue(undefined); + it('syncs the "Receive newsletter" preference through WebSocket events', async () => { + const [userRepository, {propertyRepository}] = await buildUserRepository(); + const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); const deletePropertyMock = jest .spyOn(userRepository['propertyRepository'], 'deleteProperty') @@ -129,14 +165,11 @@ describe('UserRepository', () => { }); describe('Privacy', () => { - it('syncs the "Read receipts" preference through WebSocket events', () => { - const setPropertyMock = jest - .spyOn(userRepository['propertyRepository'], 'setProperty') - .mockReturnValue(undefined); + it('syncs the "Read receipts" preference through WebSocket events', async () => { + const [, {propertyRepository}] = await buildUserRepository(); + const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); - const deletePropertyMock = jest - .spyOn(userRepository['propertyRepository'], 'deleteProperty') - .mockReturnValue(undefined); + const deletePropertyMock = jest.spyOn(propertyRepository, 'deleteProperty').mockReturnValue(undefined); const turnOnReceiptMode = { key: PropertiesRepository.CONFIG.WIRE_RECEIPT_MODE.key, @@ -163,16 +196,14 @@ describe('UserRepository', () => { describe('User handling', () => { describe('findUserById', () => { let user: User; + let userRepository: UserRepository; - beforeEach(() => { + beforeEach(async () => { + [userRepository] = await buildUserRepository(); user = new User(entities.user.john_doe.id); return userRepository['saveUser'](user); }); - afterEach(() => { - userState.users.removeAll(); - }); - it('should find an existing user', () => { const userEntity = userRepository.findUserById({id: user.id, domain: ''}); @@ -187,7 +218,8 @@ describe('UserRepository', () => { }); describe('saveUser', () => { - it('saves a user', () => { + it('saves a user', async () => { + const [userRepository, {userState}] = await buildUserRepository(); const user = new User(entities.user.jane_roe.id); userRepository['saveUser'](user); @@ -196,7 +228,8 @@ describe('UserRepository', () => { expect(userState.users()[0]).toBe(user); }); - it('saves self user', () => { + it('saves self user', async () => { + const [userRepository, {userState}] = await buildUserRepository(); const user = new User(entities.user.jane_roe.id); userRepository['saveUser'](user, true); @@ -209,21 +242,27 @@ describe('UserRepository', () => { describe('loadUsers', () => { const localUsers = [generateAPIUser(), generateAPIUser(), generateAPIUser()]; + let userRepository: UserRepository; + let userState: UserState; + let userService: UserService; + beforeEach(async () => { + [userRepository, {userState, userService}] = await buildUserRepository(); jest.resetAllMocks(); - jest.spyOn(userRepository['userService'], 'loadUserFromDb').mockResolvedValue(localUsers); + jest.spyOn(userService, 'loadUserFromDb').mockResolvedValue(localUsers); const selfUser = new User('self'); selfUser.isMe = true; + userState.self(selfUser); userState.users([selfUser]); }); it('loads all users from backend even when they are already known locally', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; const users = [...localUsers, ...newUsers]; - const userIds = users.map(user => user.qualified_id!); - const fetchUserSpy = jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: users}); + const connections = createConnections(users); + const fetchUserSpy = jest.spyOn(userService, 'getUsers').mockResolvedValue({found: users}); - await userRepository.loadUsers(new User('self'), [], [], userIds); + await userRepository.loadUsers(new User('self'), connections, []); expect(userState.users()).toHaveLength(users.length + 1); expect(fetchUserSpy).toHaveBeenCalledWith(users.map(user => user.qualified_id!)); @@ -232,18 +271,10 @@ describe('UserRepository', () => { it('assigns connections with users', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; const users = [...localUsers, ...newUsers]; - const userIds = users.map(user => user.qualified_id!); - jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: users}); + const connections = createConnections(users); + jest.spyOn(userService, 'getUsers').mockResolvedValue({found: users}); - const createConnectionWithUser = (userId: QualifiedId) => { - const connection = new ConnectionEntity(); - connection.userId = userId; - return connection; - }; - - const connections = users.map(user => createConnectionWithUser(user.qualified_id)); - - await userRepository.loadUsers(new User('self'), connections, [], userIds); + await userRepository.loadUsers(new User('self'), connections, []); expect(userState.users()).toHaveLength(users.length + 1); users.forEach(user => { @@ -254,17 +285,16 @@ describe('UserRepository', () => { it('loads users that are partially stored in the DB and maps availability', async () => { const userIds = localUsers.map(user => user.qualified_id!); + const connections = createConnections(localUsers); const partialUsers = [ {id: userIds[0].id, availability: Availability.Type.AVAILABLE}, {id: userIds[1].id, availability: Availability.Type.BUSY}, ]; jest.spyOn(userRepository['userService'], 'loadUserFromDb').mockResolvedValue(partialUsers as any); - const fetchUserSpy = jest - .spyOn(userRepository['userService'], 'getUsers') - .mockResolvedValue({found: localUsers}); + const fetchUserSpy = jest.spyOn(userService, 'getUsers').mockResolvedValue({found: localUsers}); - await userRepository.loadUsers(new User('self'), [], [], userIds); + await userRepository.loadUsers(new User('self'), connections, []); expect(userState.users()).toHaveLength(localUsers.length + 1); expect(fetchUserSpy).toHaveBeenCalledWith(userIds); @@ -275,11 +305,11 @@ describe('UserRepository', () => { it('deletes users that are not needed', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; - const userIds = newUsers.map(user => user.qualified_id!); - const removeUserSpy = jest.spyOn(userRepository['userService'], 'removeUserFromDb').mockResolvedValue(); - jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: newUsers}); + const connections = createConnections(newUsers); + const removeUserSpy = jest.spyOn(userService, 'removeUserFromDb').mockResolvedValue(); + jest.spyOn(userService, 'getUsers').mockResolvedValue({found: newUsers}); - await userRepository.loadUsers(new User(), [], [], userIds); + await userRepository.loadUsers(new User(), connections, []); expect(userState.users()).toHaveLength(newUsers.length + 1); expect(removeUserSpy).toHaveBeenCalledTimes(localUsers.length); @@ -290,12 +320,10 @@ describe('UserRepository', () => { }); describe('assignAllClients', () => { - let userJaneRoe: User; - let userJohnDoe: User; - - beforeEach(() => { - userJaneRoe = new User(entities.user.jane_roe.id); - userJohnDoe = new User(entities.user.john_doe.id); + it('assigns all available clients to the users', async () => { + const [userRepository, {clientRepository}] = await buildUserRepository(); + const userJaneRoe = new User(entities.user.jane_roe.id); + const userJohnDoe = new User(entities.user.john_doe.id); userRepository['saveUsers']([userJaneRoe, userJohnDoe]); const permanent_client = ClientMapper.mapClient(entities.clients.john_doe.permanent, false); @@ -306,12 +334,10 @@ describe('UserRepository', () => { [entities.user.jane_roe.id]: [plain_client], }; - spyOn(testFactory.client_repository!, 'getAllClientsFromDb').and.returnValue(Promise.resolve(recipients)); - }); + jest.spyOn(clientRepository, 'getAllClientsFromDb').mockResolvedValue(recipients); - it('assigns all available clients to the users', () => { return userRepository.assignAllClients().then(() => { - expect(testFactory.client_repository!.getAllClientsFromDb).toHaveBeenCalled(); + expect(clientRepository.getAllClientsFromDb).toHaveBeenCalled(); expect(userJaneRoe.devices().length).toBe(1); expect(userJaneRoe.devices()[0].id).toBe(entities.clients.jane_roe.plain.id); expect(userJohnDoe.devices().length).toBe(2); @@ -323,41 +349,22 @@ describe('UserRepository', () => { describe('verify_username', () => { it('resolves with username when username is not taken', async () => { + const [userRepository, {userService}] = await buildUserRepository(); const expectedUsername = 'john_doe'; const notFoundError = new Error('not found') as any; notFoundError.response = {status: HTTP_STATUS.NOT_FOUND}; - const userRepo = new UserRepository( - { - checkUserHandle: jest.fn().mockImplementation(() => Promise.reject(notFoundError)), - } as any, // UserService - {} as any, // AssetRepository, - {} as any, // SelfService, - {} as any, // ClientRepository, - {} as any, // ServerTimeHandler, - {} as any, // PropertiesRepository, - {} as any, // UserState - ); - - const actualUsername = await userRepo.verifyUserHandle(expectedUsername); + jest.spyOn(userService, 'checkUserHandle').mockRejectedValue(notFoundError); + + const actualUsername = await userRepository.verifyUserHandle(expectedUsername); expect(actualUsername).toBe(expectedUsername); }); it('rejects when username is taken', async () => { + const [userRepository, {userService}] = await buildUserRepository(); const username = 'john_doe'; + jest.spyOn(userService, 'checkUserHandle').mockResolvedValue(undefined); - const userRepo = new UserRepository( - { - checkUserHandle: jest.fn().mockImplementation(() => Promise.resolve()), - } as any, // UserService - {} as any, // AssetRepository, - {} as any, // SelfService, - {} as any, // ClientRepository, - {} as any, // ServerTimeHandler, - {} as any, // PropertiesRepository, - {} as any, // UserState - ); - - await expect(userRepo.verifyUserHandle(username)).rejects.toMatchObject({ + await expect(userRepository.verifyUserHandle(username)).rejects.toMatchObject({ message: 'User related backend request failure', name: 'UserError', type: 'REQUEST_FAILURE', @@ -368,7 +375,8 @@ describe('UserRepository', () => { describe('updateUsers', () => { it('should update local users', async () => { - const userService = userRepository['userService']; + const [userRepository, {userService, userState}] = await buildUserRepository(); + userState.self(new User()); const user = new User(entities.user.jane_roe.id); user.name('initial name'); user.isMe = true; diff --git a/src/script/user/UserRepository.ts b/src/script/user/UserRepository.ts index e911eb60cf6..30d0126f570 100644 --- a/src/script/user/UserRepository.ts +++ b/src/script/user/UserRepository.ts @@ -194,19 +194,10 @@ export class UserRepository { * @param selfUser the user currently logged in (will be excluded from fetch) * @param connections the connection to other users * @param conversations the conversation the user is part of (used to compute extra users that are part of those conversations but not directly connected to the user) - * @param extraUsers other users that would need to be loaded (team users usually that are not direct connections) - */ - async loadUsers( - selfUser: User, - connections: ConnectionEntity[], - conversations: Conversation[], - extraUsers: QualifiedId[], - ): Promise { + */ + async loadUsers(selfUser: User, connections: ConnectionEntity[], conversations: Conversation[]): Promise { const conversationMembers = flatten(conversations.map(conversation => conversation.participating_user_ids())); - const allUserIds = connections - .map(connectionEntity => connectionEntity.userId) - .concat(conversationMembers) - .concat(extraUsers); + const allUserIds = connections.map(connectionEntity => connectionEntity.userId).concat(conversationMembers); const users = uniq(allUserIds, false, (userId: QualifiedId) => userId.id); // Remove all users that have non-qualified Ids in DB (there could be duplicated entries one qualified and one non-qualified) diff --git a/test/helper/UserGenerator.ts b/test/helper/UserGenerator.ts index 996617d4817..71b04123ffa 100644 --- a/test/helper/UserGenerator.ts +++ b/test/helper/UserGenerator.ts @@ -19,7 +19,7 @@ import {faker} from '@faker-js/faker'; import {QualifiedId, UserAssetType} from '@wireapp/api-client/lib/user'; -import type {User as APIClientUser} from '@wireapp/api-client/lib/user/'; +import type {User as APIClientUser} from '@wireapp/api-client/lib/user'; import {createUuid} from 'Util/uuid'; @@ -61,7 +61,7 @@ export function generateAPIUser( }; } -export function generateUser(id?: QualifiedId): User { - const apiUser = generateAPIUser(id); +export function generateUser(id?: QualifiedId, overwites?: Partial): User { + const apiUser = generateAPIUser(id, overwites); return new UserMapper(serverTimeHandler).mapUserFromJson(apiUser, ''); } From 8baf69b45f3c393fd9c5b1eccd6f0364a91849e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20G=C3=B3rka?= Date: Tue, 7 Nov 2023 15:53:03 +0100 Subject: [PATCH 81/86] runfix: enable video on mls conference calls (#16162) * runfix: enable video is all type is mls conference * runfix: add mls conference to isGroup check * test: fix tests --- src/script/calling/Call.ts | 4 ++++ src/script/calling/CallingRepository.ts | 15 +++++++-------- src/script/components/calling/CallingCell.tsx | 5 ++--- .../components/calling/FullscreenVideoCall.tsx | 5 ++--- src/script/view_model/CallingViewModel.mocks.ts | 2 ++ src/script/view_model/CallingViewModel.ts | 2 +- src/script/view_model/ListViewModel.ts | 3 +-- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/script/calling/Call.ts b/src/script/calling/Call.ts index 4ccc31c3d69..773c1c6f512 100644 --- a/src/script/calling/Call.ts +++ b/src/script/calling/Call.ts @@ -53,6 +53,8 @@ export class Call { public readonly isCbrEnabled: ko.Observable = ko.observable( Config.getConfig().FEATURE.ENFORCE_CONSTANT_BITRATE, ); + public readonly isConference: boolean; + public readonly isGroupOrConference: boolean; public readonly activeSpeakers: ko.ObservableArray = ko.observableArray([]); public blockMessages: boolean = false; public currentPage: ko.Observable = ko.observable(0); @@ -93,6 +95,8 @@ export class Call { }); this.maximizedParticipant = ko.observable(null); this.muteState(isMuted ? MuteState.SELF_MUTED : MuteState.NOT_MUTED); + this.isConference = [CONV_TYPE.CONFERENCE, CONV_TYPE.CONFERENCE_MLS].includes(this.conversationType); + this.isGroupOrConference = this.isConference || this.conversationType === CONV_TYPE.GROUP; } get hasWorkingAudioInput(): boolean { diff --git a/src/script/calling/CallingRepository.ts b/src/script/calling/CallingRepository.ts index 68ed4c9ec68..9e01b8d1db5 100644 --- a/src/script/calling/CallingRepository.ts +++ b/src/script/calling/CallingRepository.ts @@ -472,10 +472,9 @@ export class CallingRepository { private async warmupMediaStreams(call: Call, audio: boolean, camera: boolean): Promise { // if it's a video call we query the video user media in order to display the video preview - const isGroup = [CONV_TYPE.CONFERENCE, CONV_TYPE.GROUP].includes(call.conversationType); try { camera = this.teamState.isVideoCallingEnabled() ? camera : false; - const mediaStream = await this.getMediaStream({audio, camera}, isGroup); + const mediaStream = await this.getMediaStream({audio, camera}, call.isGroupOrConference); if (call.state() !== CALL_STATE.NONE) { call.getSelfParticipant().updateMediaStream(mediaStream, true); if (camera) { @@ -749,7 +748,7 @@ export class CallingRepository { ); this.storeCall(call); const loadPreviewPromise = - [CONV_TYPE.CONFERENCE, CONV_TYPE.GROUP].includes(conversationType) && callType === CALL_TYPE.VIDEO + call.isGroupOrConference && callType === CALL_TYPE.VIDEO ? this.warmupMediaStreams(call, true, true) : Promise.resolve(true); const success = await loadPreviewPromise; @@ -825,8 +824,7 @@ export class CallingRepository { ); } try { - const isGroup = [CONV_TYPE.CONFERENCE, CONV_TYPE.GROUP].includes(call.conversationType); - const mediaStream = await this.getMediaStream({audio: true, screen: true}, isGroup); + const mediaStream = await this.getMediaStream({audio: true, screen: true}, call.isGroupOrConference); // https://stackoverflow.com/a/25179198/451634 mediaStream.getVideoTracks()[0].onended = () => { this.wCall?.setVideoSendState(this.wUser, this.serializeQualifiedId(call.conversationId), VIDEO_STATE.STOPPED); @@ -1362,7 +1360,9 @@ export class CallingRepository { const canRing = !conversation.showNotificationsNothing() && shouldRing && this.isReady; const selfParticipant = new Participant(this.selfUser, this.selfClientId); const isVideoCall = hasVideo ? CALL_TYPE.VIDEO : CALL_TYPE.NORMAL; - const isMuted = Config.getConfig().FEATURE.CONFERENCE_AUTO_MUTE && conversationType === CONV_TYPE.CONFERENCE; + const isMuted = + Config.getConfig().FEATURE.CONFERENCE_AUTO_MUTE && + [CONV_TYPE.CONFERENCE, CONV_TYPE.CONFERENCE_MLS].includes(conversationType); const call = new Call( qualifiedUserId, conversation.qualifiedId, @@ -1540,13 +1540,12 @@ export class CallingRepository { window.setTimeout(() => resolve(selfParticipant.getMediaStream()), 0); }); } - const isGroup = [CONV_TYPE.CONFERENCE, CONV_TYPE.GROUP].includes(call.conversationType); this.mediaStreamQuery = (async () => { try { if (missingStreams.screen && selfParticipant.sharesScreen()) { return selfParticipant.getMediaStream(); } - const mediaStream = await this.getMediaStream(missingStreams, isGroup); + const mediaStream = await this.getMediaStream(missingStreams, call.isGroupOrConference); this.mediaStreamQuery = undefined; const newStream = selfParticipant.updateMediaStream(mediaStream, true); return newStream; diff --git a/src/script/components/calling/CallingCell.tsx b/src/script/components/calling/CallingCell.tsx index c180975679b..df644dde537 100644 --- a/src/script/components/calling/CallingCell.tsx +++ b/src/script/components/calling/CallingCell.tsx @@ -24,7 +24,7 @@ import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; import cx from 'classnames'; import {container} from 'tsyringe'; -import {CALL_TYPE, CONV_TYPE, REASON as CALL_REASON, STATE as CALL_STATE} from '@wireapp/avs'; +import {CALL_TYPE, REASON as CALL_REASON, STATE as CALL_STATE} from '@wireapp/avs'; import {Avatar, AVATAR_SIZE, GroupAvatar} from 'Components/Avatar'; import {Duration} from 'Components/calling/Duration'; @@ -169,8 +169,7 @@ const CallingCell: React.FC = ({ const {activeSpeakers} = useKoSubscribableChildren(call, ['activeSpeakers']); const isOutgoingVideoCall = isOutgoing && selfSharesCamera; - const isVideoUnsupported = - !selfSharesCamera && !conversation?.supportsVideoCall(call.conversationType === CONV_TYPE.CONFERENCE); + const isVideoUnsupported = !selfSharesCamera && !conversation?.supportsVideoCall(call.isConference); const disableVideoButton = isOutgoingVideoCall || isVideoUnsupported; const disableScreenButton = !callingRepository.supportsScreenSharing; diff --git a/src/script/components/calling/FullscreenVideoCall.tsx b/src/script/components/calling/FullscreenVideoCall.tsx index dba48391c1f..7237df723c2 100644 --- a/src/script/components/calling/FullscreenVideoCall.tsx +++ b/src/script/components/calling/FullscreenVideoCall.tsx @@ -23,7 +23,7 @@ import {css} from '@emotion/react'; import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; import {container} from 'tsyringe'; -import {CALL_TYPE, CONV_TYPE} from '@wireapp/avs'; +import {CALL_TYPE} from '@wireapp/avs'; import {IconButton, IconButtonVariant, useMatchMedia} from '@wireapp/react-ui-kit'; import {useCallAlertState} from 'Components/calling/useCallAlertState'; @@ -140,8 +140,7 @@ const FullscreenVideoCall: React.FC = ({ const showToggleVideo = isVideoCallingEnabled && - (call.initialType === CALL_TYPE.VIDEO || - conversation.supportsVideoCall(call.conversationType === CONV_TYPE.CONFERENCE)); + (call.initialType === CALL_TYPE.VIDEO || conversation.supportsVideoCall(call.isConference)); const availableCameras = useMemo( () => videoinput.map(device => (device as MediaDeviceInfo).deviceId || (device as ElectronDesktopCapturerSource).id), diff --git a/src/script/view_model/CallingViewModel.mocks.ts b/src/script/view_model/CallingViewModel.mocks.ts index 8a8b9257703..ba7ece74784 100644 --- a/src/script/view_model/CallingViewModel.mocks.ts +++ b/src/script/view_model/CallingViewModel.mocks.ts @@ -39,7 +39,9 @@ export const mockCallingRepository = { onCallParticipantChangedCallback: jest.fn(), onCallClosed: jest.fn(), leaveCall: jest.fn(), + rejectCall: jest.fn(), setEpochInfo: jest.fn(), + supportsConferenceCalling: true, } as unknown as CallingRepository; export const callState = new CallState(); diff --git a/src/script/view_model/CallingViewModel.ts b/src/script/view_model/CallingViewModel.ts index a3381901a41..978b80aeb21 100644 --- a/src/script/view_model/CallingViewModel.ts +++ b/src/script/view_model/CallingViewModel.ts @@ -325,7 +325,7 @@ export class CallingViewModel { this.callActions = { answer: async (call: Call) => { - if (call.conversationType === CONV_TYPE.CONFERENCE && !this.callingRepository.supportsConferenceCalling) { + if (call.isConference && !this.callingRepository.supportsConferenceCalling) { PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, { primaryAction: { action: () => { diff --git a/src/script/view_model/ListViewModel.ts b/src/script/view_model/ListViewModel.ts index ad42f9d61ac..6db10f91f15 100644 --- a/src/script/view_model/ListViewModel.ts +++ b/src/script/view_model/ListViewModel.ts @@ -21,7 +21,6 @@ import {amplify} from 'amplify'; import ko from 'knockout'; import {container} from 'tsyringe'; -import {CONV_TYPE} from '@wireapp/avs'; import {Runtime} from '@wireapp/commons'; import {WebAppEvents} from '@wireapp/webapp-events'; @@ -154,7 +153,7 @@ export class ListViewModel { return; } - if (call.conversationType === CONV_TYPE.CONFERENCE && !this.callingRepository.supportsConferenceCalling) { + if (call.isConference && !this.callingRepository.supportsConferenceCalling) { PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, { text: { message: `${t('modalConferenceCallNotSupportedMessage')} ${t('modalConferenceCallNotSupportedJoinMessage')}`, From dfa6dd70d517c1764cb7b6b9e2481e1a96b6f498 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 7 Nov 2023 17:34:15 +0100 Subject: [PATCH 82/86] chore: revert not loading all team members on app load [WPB-4553 WPB-4548 WPB-1912] This reverts commit fe03910df297d65e8680b76b399731fddba32519. --- src/script/components/UserSearchableList.tsx | 2 +- src/script/main/app.ts | 9 +- .../LeftSidebar/panels/StartUI/PeopleTab.tsx | 2 +- src/script/search/SearchRepository.test.ts | 22 -- src/script/search/SearchRepository.ts | 8 +- src/script/team/TeamRepository.test.ts | 19 ++ src/script/team/TeamRepository.ts | 40 ++-- src/script/user/UserRepository.test.ts | 200 +++++++++--------- src/script/user/UserRepository.ts | 15 +- test/helper/UserGenerator.ts | 6 +- 10 files changed, 164 insertions(+), 159 deletions(-) diff --git a/src/script/components/UserSearchableList.tsx b/src/script/components/UserSearchableList.tsx index bdc1ed2ffd6..40948c4fb5a 100644 --- a/src/script/components/UserSearchableList.tsx +++ b/src/script/components/UserSearchableList.tsx @@ -82,7 +82,7 @@ const UserSearchableList: React.FC = ({ */ const fetchMembersFromBackend = useCallback( debounce(async (query: string, ignoreMembers: User[]) => { - const resultUsers = await searchRepository.searchByName(query, selfUser.teamId); + const resultUsers = await searchRepository.searchByName(query); const selfTeamId = selfUser.teamId; const foundMembers = resultUsers.filter(user => user.teamId === selfTeamId); const ignoreIds = ignoreMembers.map(member => member.id); diff --git a/src/script/main/app.ts b/src/script/main/app.ts index cfb48d8cf75..e280d013c88 100644 --- a/src/script/main/app.ts +++ b/src/script/main/app.ts @@ -415,18 +415,15 @@ export class App { onProgress(10); telemetry.timeStep(AppInitTimingsStep.INITIALIZED_CRYPTOGRAPHY); - const connections = await connectionRepository.getConnections(); - + const {members: teamMembers} = await teamRepository.initTeam(selfUser.teamId); telemetry.timeStep(AppInitTimingsStep.RECEIVED_USER_DATA); + const connections = await connectionRepository.getConnections(); telemetry.addStatistic(AppInitStatisticsValue.CONNECTIONS, connections.length, 50); const conversations = await conversationRepository.loadConversations(); - const contacts = await userRepository.loadUsers(selfUser, connections, conversations); - if (selfUser.teamId) { - await teamRepository.initTeam(selfUser.teamId, contacts); - } + await userRepository.loadUsers(selfUser, connections, conversations, teamMembers); if (supportsMLS()) { //if mls is supported, we need to initialize the callbacks (they are used when decrypting messages) diff --git a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx index 7b286256db9..332256cf60e 100644 --- a/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx +++ b/src/script/page/LeftSidebar/panels/StartUI/PeopleTab.tsx @@ -195,7 +195,7 @@ export const PeopleTab = ({ onSearchResults(localSearchResults); if (canSearchUnconnectedUsers) { try { - const userEntities = await searchRepository.searchByName(searchQuery, selfUser.teamId); + const userEntities = await searchRepository.searchByName(searchQuery); const localUserIds = localSearchResults.contacts.map(({id}) => id); const onlyRemoteUsers = userEntities.filter(user => !localUserIds.includes(user.id)); const results = inTeam diff --git a/src/script/search/SearchRepository.test.ts b/src/script/search/SearchRepository.test.ts index 5c215db18a9..b3cc35fae6e 100644 --- a/src/script/search/SearchRepository.test.ts +++ b/src/script/search/SearchRepository.test.ts @@ -19,7 +19,6 @@ import {User} from 'src/script/entity/User'; import {generateUser} from 'test/helper/UserGenerator'; -import {createUuid} from 'Util/uuid'; import {SearchRepository} from './SearchRepository'; @@ -234,27 +233,6 @@ describe('SearchRepository', () => { expect(suggestions.length).toEqual(localUsers.length - 1); }); - - it('returns team users first', async () => { - const [searchRepository, {apiClient, userRepository}] = buildSearchRepository(); - const teamId = createUuid(); - const teamUsers = [generateUser(undefined, {team: teamId}), generateUser(undefined, {team: teamId})]; - const otherTeamUsers = [generateUser(undefined, {team: createUuid()})]; - const localUsers = [generateUser(), generateUser(), generateUser()]; - const allUsers = [...localUsers, ...otherTeamUsers, ...teamUsers]; - userRepository.getUsersById.mockResolvedValue(allUsers); - - const searchResults = allUsers.map(({qualifiedId}) => qualifiedId); - jest - .spyOn(apiClient.api.user, 'getSearchContacts') - .mockResolvedValue({response: {documents: searchResults}} as any); - - const suggestions = await searchRepository.searchByName('term', teamId); - - expect(suggestions.length).toEqual(allUsers.length); - expect(suggestions[0].teamId).toEqual(teamId); - expect(suggestions[1].teamId).toEqual(teamId); - }); }); }); diff --git a/src/script/search/SearchRepository.ts b/src/script/search/SearchRepository.ts index 89424cffeb8..21778d89445 100644 --- a/src/script/search/SearchRepository.ts +++ b/src/script/search/SearchRepository.ts @@ -178,11 +178,11 @@ export class SearchRepository { * @note We skip a few results as connection changes need a while to reflect on the backend. * * @param query Search query - * @param teamId Current team ID the selfUser is in (will help prioritize results) + * @param isHandle Is query a user handle * @param maxResults Maximum number of results * @returns Resolves with the search results */ - async searchByName(term: string, teamId = '', maxResults = CONFIG.MAX_SEARCH_RESULTS): Promise { + async searchByName(term: string, maxResults = CONFIG.MAX_SEARCH_RESULTS): Promise { const {query, isHandleQuery} = this.normalizeQuery(term); const [rawName, rawDomain] = this.core.backendFeatures.isFederated ? query.split('@') : [query]; const [name, domain] = validateHandle(rawName, rawDomain) ? [rawName, rawDomain] : [query]; @@ -199,10 +199,6 @@ export class SearchRepository { .filter(user => !user.isMe) .filter(user => !isHandleQuery || startsWith(user.username(), query)) .sort((userA, userB) => { - if (userA.teamId === teamId && userB.teamId !== teamId) { - // put team members first - return -1; - } return isHandleQuery ? sortByPriority(userA.username(), userB.username(), query) : sortByPriority(userA.name(), userB.name(), query); diff --git a/src/script/team/TeamRepository.test.ts b/src/script/team/TeamRepository.test.ts index d4f059b3c6e..42cc677b432 100644 --- a/src/script/team/TeamRepository.test.ts +++ b/src/script/team/TeamRepository.test.ts @@ -17,6 +17,8 @@ * */ +import {Permissions} from '@wireapp/api-client/lib/team/member'; + import {randomUUID} from 'crypto'; import {User} from 'src/script/entity/User'; @@ -67,6 +69,12 @@ describe('TeamRepository', () => { has_more: false, }; const team_metadata = teams_data.teams[0]; + const team_members = { + members: [ + {user: randomUUID(), permissions: {copy: Permissions.DEFAULT, self: Permissions.DEFAULT}}, + {user: randomUUID(), permissions: {copy: Permissions.DEFAULT, self: Permissions.DEFAULT}}, + ], + }; describe('getTeam()', () => { it('returns the team entity', async () => { @@ -83,6 +91,17 @@ describe('TeamRepository', () => { }); }); + describe('getAllTeamMembers()', () => { + it('returns team member entities', async () => { + const [teamRepo, {teamService}] = buildConnectionRepository(); + jest.spyOn(teamService, 'getAllTeamMembers').mockResolvedValue({hasMore: false, members: team_members.members}); + const entities = await teamRepo['getAllTeamMembers'](team_metadata.id); + expect(entities.length).toEqual(team_members.members.length); + expect(entities[0].userId).toEqual(team_members.members[0].user); + expect(entities[0].permissions).toEqual(team_members.members[0].permissions); + }); + }); + describe('sendAccountInfo', () => { it('does not crash when there is no team logo', async () => { const [teamRepo] = buildConnectionRepository(); diff --git a/src/script/team/TeamRepository.ts b/src/script/team/TeamRepository.ts index c38e4cdd74b..d6734d10c6f 100644 --- a/src/script/team/TeamRepository.ts +++ b/src/script/team/TeamRepository.ts @@ -31,6 +31,7 @@ import type { import {TEAM_EVENT} from '@wireapp/api-client/lib/event/TeamEvent'; import {FeatureStatus, FeatureList} from '@wireapp/api-client/lib/team/feature/'; import type {TeamData} from '@wireapp/api-client/lib/team/team/TeamData'; +import {QualifiedId} from '@wireapp/api-client/lib/user'; import {amplify} from 'amplify'; import {container} from 'tsyringe'; @@ -126,25 +127,19 @@ export class TeamRepository extends TypedEventEmitter { ); }; - /** - * Will init the team configuration and all the team members from the contact list. - * @param teamId the Id of the team to init - * @param contacts all the contacts the self user has, team members will be deduced from it. - */ - async initTeam(teamId: string, contacts: User[] = []): Promise { + initTeam = async ( + teamId?: string, + ): Promise<{team: TeamEntity; members: QualifiedId[]} | {team: undefined; members: never[]}> => { const team = await this.getTeam(); // get the fresh feature config from backend await this.updateFeatureConfig(); if (!teamId) { - return undefined; + return {team: undefined, members: []}; } - await this.updateTeamMembersByIds( - team, - contacts.filter(user => user.teamId === teamId).map(({id}) => id), - ); + const members = await this.loadTeamMembers(team); this.scheduleTeamRefresh(); - return team; - } + return {team, members}; + }; private async updateFeatureConfig(): Promise<{newFeatureList: FeatureList; prevFeatureList?: FeatureList}> { const prevFeatureList = this.teamState.teamFeatures(); @@ -194,6 +189,14 @@ export class TeamRepository extends TypedEventEmitter { return memberEntity; } + private async getAllTeamMembers(teamId: string): Promise { + const {members, hasMore} = await this.teamService.getAllTeamMembers(teamId); + if (!hasMore && members.length) { + return this.teamMapper.mapMembers(members); + } + return []; + } + async conversationHasGuestLinkEnabled(conversationId: string): Promise { return this.teamService.conversationHasGuestLink(conversationId); } @@ -348,6 +351,17 @@ export class TeamRepository extends TypedEventEmitter { this.updateMemberRoles(teamEntity, mappedMembers); } + private async loadTeamMembers(teamEntity: TeamEntity): Promise { + const teamMembers = await this.getAllTeamMembers(teamEntity.id); + this.teamState.memberRoles({}); + this.teamState.memberInviters({}); + + this.updateMemberRoles(teamEntity, teamMembers); + return teamMembers + .filter(({userId}) => userId !== this.userState.self().id) + .map(memberEntity => ({domain: this.teamState.teamDomain() ?? '', id: memberEntity.userId})); + } + private addUserToTeam(userEntity: User): void { const members = this.teamState.team().members; diff --git a/src/script/user/UserRepository.test.ts b/src/script/user/UserRepository.test.ts index 7f377ce195e..fa8dd48bb2e 100644 --- a/src/script/user/UserRepository.test.ts +++ b/src/script/user/UserRepository.test.ts @@ -18,7 +18,7 @@ */ import {RECEIPT_MODE} from '@wireapp/api-client/lib/conversation/data'; -import type {User as APIClientUser} from '@wireapp/api-client/lib/user'; +import {QualifiedId} from '@wireapp/api-client/lib/user'; import {amplify} from 'amplify'; import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; @@ -32,71 +32,34 @@ import {matchQualifiedIds} from 'Util/QualifiedId'; import {ConsentValue} from './ConsentValue'; import {UserRepository} from './UserRepository'; -import {UserService} from './UserService'; import {UserState} from './UserState'; -import {AssetRepository} from '../assets/AssetRepository'; -import {ClientRepository} from '../client'; import {ClientMapper} from '../client/ClientMapper'; import {ConnectionEntity} from '../connection/ConnectionEntity'; import {User} from '../entity/User'; import {EventRepository} from '../event/EventRepository'; import {PropertiesRepository} from '../properties/PropertiesRepository'; -import {SelfService} from '../self/SelfService'; -import {TeamState} from '../team/TeamState'; -import {serverTimeHandler} from '../time/serverTimeHandler'; - -const testFactory = new TestFactory(); -async function buildUserRepository() { - const storageRepo = await testFactory.exposeStorageActors(); - - const userService = new UserService(storageRepo['storageService']); - const assetRepository = new AssetRepository(); - const selfService = new SelfService(); - const clientRepository = new ClientRepository({} as any, {} as any); - const propertyRepository = new PropertiesRepository({} as any, {} as any); - const userState = new UserState(); - const teamState = new TeamState(); - - const userRepository = new UserRepository( - userService, - assetRepository, - selfService, - clientRepository, - serverTimeHandler, - propertyRepository, - userState, - teamState, - ); - return [ - userRepository, - { - userService, - assetRepository, - selfService, - clientRepository, - serverTimeHandler, - propertyRepository, - userState, - teamState, - }, - ] as const; -} - -function createConnections(users: APIClientUser[]) { - return users.map(user => { - const connection = new ConnectionEntity(); - connection.userId = user.qualified_id; - return connection; - }); -} describe('UserRepository', () => { + const testFactory = new TestFactory(); + let userRepository: UserRepository; + let userState: UserState; + + beforeAll(async () => { + userRepository = await testFactory.exposeUserActors(); + userState = userRepository['userState']; + }); + + afterEach(() => { + userRepository['userState'].users.removeAll(); + }); + describe('Account preferences', () => { describe('Data usage permissions', () => { - it('syncs the "Send anonymous data" preference through WebSocket events', async () => { - const [, {propertyRepository}] = await buildUserRepository(); - const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); + it('syncs the "Send anonymous data" preference through WebSocket events', () => { + const setPropertyMock = jest + .spyOn(userRepository['propertyRepository'], 'setProperty') + .mockReturnValue(undefined); const turnOnErrorReporting = { key: 'webapp', type: 'user.properties-set', @@ -134,9 +97,10 @@ describe('UserRepository', () => { expect(setPropertyMock).toHaveBeenCalledWith(turnOffErrorReporting.key, turnOffErrorReporting.value); }); - it('syncs the "Receive newsletter" preference through WebSocket events', async () => { - const [userRepository, {propertyRepository}] = await buildUserRepository(); - const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); + it('syncs the "Receive newsletter" preference through WebSocket events', () => { + const setPropertyMock = jest + .spyOn(userRepository['propertyRepository'], 'setProperty') + .mockReturnValue(undefined); const deletePropertyMock = jest .spyOn(userRepository['propertyRepository'], 'deleteProperty') @@ -165,11 +129,14 @@ describe('UserRepository', () => { }); describe('Privacy', () => { - it('syncs the "Read receipts" preference through WebSocket events', async () => { - const [, {propertyRepository}] = await buildUserRepository(); - const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); + it('syncs the "Read receipts" preference through WebSocket events', () => { + const setPropertyMock = jest + .spyOn(userRepository['propertyRepository'], 'setProperty') + .mockReturnValue(undefined); - const deletePropertyMock = jest.spyOn(propertyRepository, 'deleteProperty').mockReturnValue(undefined); + const deletePropertyMock = jest + .spyOn(userRepository['propertyRepository'], 'deleteProperty') + .mockReturnValue(undefined); const turnOnReceiptMode = { key: PropertiesRepository.CONFIG.WIRE_RECEIPT_MODE.key, @@ -196,14 +163,16 @@ describe('UserRepository', () => { describe('User handling', () => { describe('findUserById', () => { let user: User; - let userRepository: UserRepository; - beforeEach(async () => { - [userRepository] = await buildUserRepository(); + beforeEach(() => { user = new User(entities.user.john_doe.id); return userRepository['saveUser'](user); }); + afterEach(() => { + userState.users.removeAll(); + }); + it('should find an existing user', () => { const userEntity = userRepository.findUserById({id: user.id, domain: ''}); @@ -218,8 +187,7 @@ describe('UserRepository', () => { }); describe('saveUser', () => { - it('saves a user', async () => { - const [userRepository, {userState}] = await buildUserRepository(); + it('saves a user', () => { const user = new User(entities.user.jane_roe.id); userRepository['saveUser'](user); @@ -228,8 +196,7 @@ describe('UserRepository', () => { expect(userState.users()[0]).toBe(user); }); - it('saves self user', async () => { - const [userRepository, {userState}] = await buildUserRepository(); + it('saves self user', () => { const user = new User(entities.user.jane_roe.id); userRepository['saveUser'](user, true); @@ -242,27 +209,21 @@ describe('UserRepository', () => { describe('loadUsers', () => { const localUsers = [generateAPIUser(), generateAPIUser(), generateAPIUser()]; - let userRepository: UserRepository; - let userState: UserState; - let userService: UserService; - beforeEach(async () => { - [userRepository, {userState, userService}] = await buildUserRepository(); jest.resetAllMocks(); - jest.spyOn(userService, 'loadUserFromDb').mockResolvedValue(localUsers); + jest.spyOn(userRepository['userService'], 'loadUserFromDb').mockResolvedValue(localUsers); const selfUser = new User('self'); selfUser.isMe = true; - userState.self(selfUser); userState.users([selfUser]); }); it('loads all users from backend even when they are already known locally', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; const users = [...localUsers, ...newUsers]; - const connections = createConnections(users); - const fetchUserSpy = jest.spyOn(userService, 'getUsers').mockResolvedValue({found: users}); + const userIds = users.map(user => user.qualified_id!); + const fetchUserSpy = jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: users}); - await userRepository.loadUsers(new User('self'), connections, []); + await userRepository.loadUsers(new User('self'), [], [], userIds); expect(userState.users()).toHaveLength(users.length + 1); expect(fetchUserSpy).toHaveBeenCalledWith(users.map(user => user.qualified_id!)); @@ -271,10 +232,18 @@ describe('UserRepository', () => { it('assigns connections with users', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; const users = [...localUsers, ...newUsers]; - const connections = createConnections(users); - jest.spyOn(userService, 'getUsers').mockResolvedValue({found: users}); + const userIds = users.map(user => user.qualified_id!); + jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: users}); - await userRepository.loadUsers(new User('self'), connections, []); + const createConnectionWithUser = (userId: QualifiedId) => { + const connection = new ConnectionEntity(); + connection.userId = userId; + return connection; + }; + + const connections = users.map(user => createConnectionWithUser(user.qualified_id)); + + await userRepository.loadUsers(new User('self'), connections, [], userIds); expect(userState.users()).toHaveLength(users.length + 1); users.forEach(user => { @@ -285,16 +254,17 @@ describe('UserRepository', () => { it('loads users that are partially stored in the DB and maps availability', async () => { const userIds = localUsers.map(user => user.qualified_id!); - const connections = createConnections(localUsers); const partialUsers = [ {id: userIds[0].id, availability: Availability.Type.AVAILABLE}, {id: userIds[1].id, availability: Availability.Type.BUSY}, ]; jest.spyOn(userRepository['userService'], 'loadUserFromDb').mockResolvedValue(partialUsers as any); - const fetchUserSpy = jest.spyOn(userService, 'getUsers').mockResolvedValue({found: localUsers}); + const fetchUserSpy = jest + .spyOn(userRepository['userService'], 'getUsers') + .mockResolvedValue({found: localUsers}); - await userRepository.loadUsers(new User('self'), connections, []); + await userRepository.loadUsers(new User('self'), [], [], userIds); expect(userState.users()).toHaveLength(localUsers.length + 1); expect(fetchUserSpy).toHaveBeenCalledWith(userIds); @@ -305,11 +275,11 @@ describe('UserRepository', () => { it('deletes users that are not needed', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; - const connections = createConnections(newUsers); - const removeUserSpy = jest.spyOn(userService, 'removeUserFromDb').mockResolvedValue(); - jest.spyOn(userService, 'getUsers').mockResolvedValue({found: newUsers}); + const userIds = newUsers.map(user => user.qualified_id!); + const removeUserSpy = jest.spyOn(userRepository['userService'], 'removeUserFromDb').mockResolvedValue(); + jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: newUsers}); - await userRepository.loadUsers(new User(), connections, []); + await userRepository.loadUsers(new User(), [], [], userIds); expect(userState.users()).toHaveLength(newUsers.length + 1); expect(removeUserSpy).toHaveBeenCalledTimes(localUsers.length); @@ -320,10 +290,12 @@ describe('UserRepository', () => { }); describe('assignAllClients', () => { - it('assigns all available clients to the users', async () => { - const [userRepository, {clientRepository}] = await buildUserRepository(); - const userJaneRoe = new User(entities.user.jane_roe.id); - const userJohnDoe = new User(entities.user.john_doe.id); + let userJaneRoe: User; + let userJohnDoe: User; + + beforeEach(() => { + userJaneRoe = new User(entities.user.jane_roe.id); + userJohnDoe = new User(entities.user.john_doe.id); userRepository['saveUsers']([userJaneRoe, userJohnDoe]); const permanent_client = ClientMapper.mapClient(entities.clients.john_doe.permanent, false); @@ -334,10 +306,12 @@ describe('UserRepository', () => { [entities.user.jane_roe.id]: [plain_client], }; - jest.spyOn(clientRepository, 'getAllClientsFromDb').mockResolvedValue(recipients); + spyOn(testFactory.client_repository!, 'getAllClientsFromDb').and.returnValue(Promise.resolve(recipients)); + }); + it('assigns all available clients to the users', () => { return userRepository.assignAllClients().then(() => { - expect(clientRepository.getAllClientsFromDb).toHaveBeenCalled(); + expect(testFactory.client_repository!.getAllClientsFromDb).toHaveBeenCalled(); expect(userJaneRoe.devices().length).toBe(1); expect(userJaneRoe.devices()[0].id).toBe(entities.clients.jane_roe.plain.id); expect(userJohnDoe.devices().length).toBe(2); @@ -349,22 +323,41 @@ describe('UserRepository', () => { describe('verify_username', () => { it('resolves with username when username is not taken', async () => { - const [userRepository, {userService}] = await buildUserRepository(); const expectedUsername = 'john_doe'; const notFoundError = new Error('not found') as any; notFoundError.response = {status: HTTP_STATUS.NOT_FOUND}; - jest.spyOn(userService, 'checkUserHandle').mockRejectedValue(notFoundError); - - const actualUsername = await userRepository.verifyUserHandle(expectedUsername); + const userRepo = new UserRepository( + { + checkUserHandle: jest.fn().mockImplementation(() => Promise.reject(notFoundError)), + } as any, // UserService + {} as any, // AssetRepository, + {} as any, // SelfService, + {} as any, // ClientRepository, + {} as any, // ServerTimeHandler, + {} as any, // PropertiesRepository, + {} as any, // UserState + ); + + const actualUsername = await userRepo.verifyUserHandle(expectedUsername); expect(actualUsername).toBe(expectedUsername); }); it('rejects when username is taken', async () => { - const [userRepository, {userService}] = await buildUserRepository(); const username = 'john_doe'; - jest.spyOn(userService, 'checkUserHandle').mockResolvedValue(undefined); - await expect(userRepository.verifyUserHandle(username)).rejects.toMatchObject({ + const userRepo = new UserRepository( + { + checkUserHandle: jest.fn().mockImplementation(() => Promise.resolve()), + } as any, // UserService + {} as any, // AssetRepository, + {} as any, // SelfService, + {} as any, // ClientRepository, + {} as any, // ServerTimeHandler, + {} as any, // PropertiesRepository, + {} as any, // UserState + ); + + await expect(userRepo.verifyUserHandle(username)).rejects.toMatchObject({ message: 'User related backend request failure', name: 'UserError', type: 'REQUEST_FAILURE', @@ -375,8 +368,7 @@ describe('UserRepository', () => { describe('updateUsers', () => { it('should update local users', async () => { - const [userRepository, {userService, userState}] = await buildUserRepository(); - userState.self(new User()); + const userService = userRepository['userService']; const user = new User(entities.user.jane_roe.id); user.name('initial name'); user.isMe = true; diff --git a/src/script/user/UserRepository.ts b/src/script/user/UserRepository.ts index 30d0126f570..e911eb60cf6 100644 --- a/src/script/user/UserRepository.ts +++ b/src/script/user/UserRepository.ts @@ -194,10 +194,19 @@ export class UserRepository { * @param selfUser the user currently logged in (will be excluded from fetch) * @param connections the connection to other users * @param conversations the conversation the user is part of (used to compute extra users that are part of those conversations but not directly connected to the user) - */ - async loadUsers(selfUser: User, connections: ConnectionEntity[], conversations: Conversation[]): Promise { + * @param extraUsers other users that would need to be loaded (team users usually that are not direct connections) + */ + async loadUsers( + selfUser: User, + connections: ConnectionEntity[], + conversations: Conversation[], + extraUsers: QualifiedId[], + ): Promise { const conversationMembers = flatten(conversations.map(conversation => conversation.participating_user_ids())); - const allUserIds = connections.map(connectionEntity => connectionEntity.userId).concat(conversationMembers); + const allUserIds = connections + .map(connectionEntity => connectionEntity.userId) + .concat(conversationMembers) + .concat(extraUsers); const users = uniq(allUserIds, false, (userId: QualifiedId) => userId.id); // Remove all users that have non-qualified Ids in DB (there could be duplicated entries one qualified and one non-qualified) diff --git a/test/helper/UserGenerator.ts b/test/helper/UserGenerator.ts index 71b04123ffa..996617d4817 100644 --- a/test/helper/UserGenerator.ts +++ b/test/helper/UserGenerator.ts @@ -19,7 +19,7 @@ import {faker} from '@faker-js/faker'; import {QualifiedId, UserAssetType} from '@wireapp/api-client/lib/user'; -import type {User as APIClientUser} from '@wireapp/api-client/lib/user'; +import type {User as APIClientUser} from '@wireapp/api-client/lib/user/'; import {createUuid} from 'Util/uuid'; @@ -61,7 +61,7 @@ export function generateAPIUser( }; } -export function generateUser(id?: QualifiedId, overwites?: Partial): User { - const apiUser = generateAPIUser(id, overwites); +export function generateUser(id?: QualifiedId): User { + const apiUser = generateAPIUser(id); return new UserMapper(serverTimeHandler).mapUserFromJson(apiUser, ''); } From 09475d2bbfe947ae989981e91af7df576ade521c Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Wed, 8 Nov 2023 15:45:35 +0100 Subject: [PATCH 83/86] fix: automatically map user roles when team user list changes [WPB-1912] (#16166) --- src/script/main/app.ts | 2 +- src/script/team/TeamEntity.ts | 5 +- src/script/team/TeamMapper.ts | 11 ---- src/script/team/TeamRepository.ts | 101 +++++++++++++----------------- src/script/team/TeamState.ts | 8 +-- 5 files changed, 48 insertions(+), 79 deletions(-) diff --git a/src/script/main/app.ts b/src/script/main/app.ts index e280d013c88..b84c9c6aa7c 100644 --- a/src/script/main/app.ts +++ b/src/script/main/app.ts @@ -415,7 +415,7 @@ export class App { onProgress(10); telemetry.timeStep(AppInitTimingsStep.INITIALIZED_CRYPTOGRAPHY); - const {members: teamMembers} = await teamRepository.initTeam(selfUser.teamId); + const teamMembers = await teamRepository.initTeam(selfUser.teamId); telemetry.timeStep(AppInitTimingsStep.RECEIVED_USER_DATA); const connections = await connectionRepository.getConnections(); diff --git a/src/script/team/TeamEntity.ts b/src/script/team/TeamEntity.ts index dc5a0dd28ae..43ad1c5bcaa 100644 --- a/src/script/team/TeamEntity.ts +++ b/src/script/team/TeamEntity.ts @@ -20,7 +20,6 @@ import ko from 'knockout'; import {AssetRemoteData} from '../assets/AssetRemoteData'; -import type {User} from '../entity/User'; import {assetV3} from '../util/ValidationUtil'; export class TeamEntity { @@ -30,14 +29,12 @@ export class TeamEntity { /** Team icon (asset key) */ iconKey?: string; id?: string; - members: ko.ObservableArray; name: ko.Observable; constructor(id?: string) { this.creator = undefined; this.icon = ''; this.iconKey = undefined; - this.members = ko.observableArray([]); this.id = id; this.name = ko.observable(''); } @@ -46,7 +43,7 @@ export class TeamEntity { let hasIcon = false; try { - hasIcon = this.icon && assetV3(this.icon); + hasIcon = !!this.icon && assetV3(this.icon); } catch (error) {} if (hasIcon) { diff --git a/src/script/team/TeamMapper.ts b/src/script/team/TeamMapper.ts index 7080095b688..d8439a9c29f 100644 --- a/src/script/team/TeamMapper.ts +++ b/src/script/team/TeamMapper.ts @@ -19,14 +19,10 @@ import type {MemberData, TeamData} from '@wireapp/api-client/lib/team/'; import type {TeamUpdateData} from '@wireapp/api-client/lib/team/data/'; -import type {PermissionsData} from '@wireapp/api-client/lib/team/member/PermissionsData'; import {TeamEntity} from './TeamEntity'; import {TeamMemberEntity} from './TeamMemberEntity'; -import type {User} from '../entity/User'; -import {roleFromTeamPermissions} from '../user/UserPermission'; - export class TeamMapper { mapTeamFromObject(data: TeamData, teamEntity?: TeamEntity): TeamEntity { return this.updateTeamFromObject(data, teamEntity); @@ -81,11 +77,4 @@ export class TeamMapper { return member; } - - mapRole(userEntity: User, permissions?: PermissionsData): void { - if (permissions) { - const teamRole = roleFromTeamPermissions(permissions); - userEntity.teamRole(teamRole); - } - } } diff --git a/src/script/team/TeamRepository.ts b/src/script/team/TeamRepository.ts index d6734d10c6f..776a315756b 100644 --- a/src/script/team/TeamRepository.ts +++ b/src/script/team/TeamRepository.ts @@ -30,6 +30,7 @@ import type { } from '@wireapp/api-client/lib/event'; import {TEAM_EVENT} from '@wireapp/api-client/lib/event/TeamEvent'; import {FeatureStatus, FeatureList} from '@wireapp/api-client/lib/team/feature/'; +import type {PermissionsData} from '@wireapp/api-client/lib/team/member/PermissionsData'; import type {TeamData} from '@wireapp/api-client/lib/team/team/TeamData'; import {QualifiedId} from '@wireapp/api-client/lib/user'; import {amplify} from 'amplify'; @@ -102,44 +103,44 @@ export class TeamRepository extends TypedEventEmitter { this.userRepository = userRepository; this.userRepository.getTeamMembersFromUsers = this.getTeamMembersFromUsers; - this.teamState.teamMembers.subscribe(() => this.userRepository.mapGuestStatus()); - - this.isSelfConnectedTo = userId => { - return ( - this.teamState.memberRoles()[userId] !== ROLE.PARTNER || - this.teamState.memberInviters()[userId] === this.userState.self().id - ); - }; amplify.subscribe(WebAppEvents.TEAM.EVENT_FROM_BACKEND, this.onTeamEvent); amplify.subscribe(WebAppEvents.EVENT.NOTIFICATION_HANDLING_STATE, this.updateTeamConfig); amplify.subscribe(WebAppEvents.TEAM.UPDATE_INFO, this.sendAccountInfo.bind(this)); } - readonly getRoleBadge = (userId: string): string => { + getRoleBadge(userId: string): string { return this.teamState.isExternal(userId) ? t('rolePartner') : ''; - }; + } - readonly isSelfConnectedTo = (userId: string): boolean => { + isSelfConnectedTo(userId: string): boolean { return ( this.teamState.memberRoles()[userId] !== ROLE.PARTNER || this.teamState.memberInviters()[userId] === this.userState.self().id ); - }; + } - initTeam = async ( - teamId?: string, - ): Promise<{team: TeamEntity; members: QualifiedId[]} | {team: undefined; members: never[]}> => { + async initTeam(teamId?: string): Promise { const team = await this.getTeam(); // get the fresh feature config from backend await this.updateFeatureConfig(); if (!teamId) { - return {team: undefined, members: []}; + return []; } + this.teamState.teamMembers.subscribe(members => { + // Subscribe to team members change and update the user role and guest status + this.userRepository.mapGuestStatus(members); + const roles = this.teamState.memberRoles(); + members.forEach(user => { + if (roles[user.id]) { + user.teamRole(roles[user.id]); + } + }); + }); const members = await this.loadTeamMembers(team); this.scheduleTeamRefresh(); - return {team, members}; - }; + return members; + } private async updateFeatureConfig(): Promise<{newFeatureList: FeatureList; prevFeatureList?: FeatureList}> { const prevFeatureList = this.teamState.teamFeatures(); @@ -185,7 +186,7 @@ export class TeamRepository extends TypedEventEmitter { async getSelfMember(teamId: string): Promise { const memberEntity = await this.getTeamMember(teamId, this.userState.self().id); - this.teamMapper.mapRole(this.userState.self(), memberEntity.permissions); + this.updateUserRole(this.userState.self(), memberEntity.permissions); return memberEntity; } @@ -201,7 +202,7 @@ export class TeamRepository extends TypedEventEmitter { return this.teamService.conversationHasGuestLink(conversationId); } - getTeamMembersFromUsers = async (users: User[]): Promise => { + private getTeamMembersFromUsers = async (users: User[]): Promise => { const selfTeamId = this.userState.self().teamId; if (!selfTeamId) { return; @@ -337,18 +338,8 @@ export class TeamRepository extends TypedEventEmitter { this.teamState.memberRoles({}); this.teamState.memberInviters({}); } - const userEntities = await this.userRepository.getUsersById( - memberIds.map(memberId => ({domain: this.teamState.teamDomain(), id: memberId})), - ); - if (append) { - const knownUserIds = teamEntity.members().map(({id}) => id); - const newUserEntities = userEntities.filter(({id}) => !knownUserIds.includes(id)); - teamEntity.members.push(...newUserEntities); - } else { - teamEntity.members(userEntities); - } - this.updateMemberRoles(teamEntity, mappedMembers); + this.updateMemberRoles(mappedMembers); } private async loadTeamMembers(teamEntity: TeamEntity): Promise { @@ -356,20 +347,12 @@ export class TeamRepository extends TypedEventEmitter { this.teamState.memberRoles({}); this.teamState.memberInviters({}); - this.updateMemberRoles(teamEntity, teamMembers); + this.updateMemberRoles(teamMembers); return teamMembers .filter(({userId}) => userId !== this.userState.self().id) .map(memberEntity => ({domain: this.teamState.teamDomain() ?? '', id: memberEntity.userId})); } - private addUserToTeam(userEntity: User): void { - const members = this.teamState.team().members; - - if (!members().find(member => member.id === userEntity.id)) { - members.push(userEntity); - } - } - private getTeamById(teamId: string): Promise { return this.teamService.getTeamById(teamId); } @@ -391,7 +374,7 @@ export class TeamRepository extends TypedEventEmitter { amplify.publish(WebAppEvents.CONVERSATION.DELETE, {domain: '', id: conversationId}); } - private _onMemberJoin(eventJson: TeamMemberJoinEvent): void { + private async _onMemberJoin(eventJson: TeamMemberJoinEvent) { const { data: {user: userId}, team: teamId, @@ -400,10 +383,9 @@ export class TeamRepository extends TypedEventEmitter { const isOtherUser = this.userState.self().id !== userId; if (isLocalTeam && isOtherUser) { - this.userRepository - .getUserById({domain: this.userState.self().domain, id: userId}) - .then(userEntity => this.addUserToTeam(userEntity)); - this.getTeamMember(teamId, userId).then(member => this.updateMemberRoles(this.teamState.team(), [member])); + await this.userRepository.getUserById({domain: this.userState.self().domain, id: userId}); + const member = await this.getTeamMember(teamId, userId); + this.updateMemberRoles([member]); } } @@ -443,7 +425,6 @@ export class TeamRepository extends TypedEventEmitter { return this.onDelete(eventJson); } - this.teamState.team().members.remove(member => member.id === userId); amplify.publish(WebAppEvents.TEAM.MEMBER_LEAVE, teamId, {domain: '', id: userId}, new Date(time).toISOString()); } } @@ -454,34 +435,36 @@ export class TeamRepository extends TypedEventEmitter { team: teamId, } = eventJson; const isLocalTeam = this.teamState.team().id === teamId; + if (!isLocalTeam) { + return; + } + const isSelfUser = this.userState.self().id === userId; - if (isLocalTeam && isSelfUser) { + if (isSelfUser) { const memberEntity = permissions ? {permissions} : await this.getTeamMember(teamId, userId); - this.teamMapper.mapRole(this.userState.self(), memberEntity.permissions); + this.updateUserRole(this.userState.self(), memberEntity.permissions); await this.sendAccountInfo(); - } - if (isLocalTeam && !isSelfUser) { + } else { const member = await this.getTeamMember(teamId, userId); - this.updateMemberRoles(this.teamState.team(), [member]); + this.updateMemberRoles([member]); } } - private updateMemberRoles(team: TeamEntity, members: TeamMemberEntity[] = []): void { - members.forEach(member => { - const user = team.members().find(({id}) => member.userId === id); - if (user) { - this.teamMapper.mapRole(user, member.permissions); - } - }); + private updateUserRole(user: User, permissions: PermissionsData): void { + user.teamRole(roleFromTeamPermissions(permissions)); + } + private updateMemberRoles(members: TeamMemberEntity[] = []): void { const memberRoles = members.reduce((accumulator, member) => { accumulator[member.userId] = member.permissions ? roleFromTeamPermissions(member.permissions) : ROLE.INVALID; return accumulator; }, this.teamState.memberRoles()); const memberInvites = members.reduce((accumulator, member) => { - accumulator[member.userId] = member.invitedBy; + if (member.invitedBy) { + accumulator[member.userId] = member.invitedBy; + } return accumulator; }, this.teamState.memberInviters()); diff --git a/src/script/team/TeamState.ts b/src/script/team/TeamState.ts index e5998d53175..26f76802467 100644 --- a/src/script/team/TeamState.ts +++ b/src/script/team/TeamState.ts @@ -33,8 +33,8 @@ import {UserState} from '../user/UserState'; @singleton() export class TeamState { public readonly isTeamDeleted: ko.Observable; - public readonly memberInviters: ko.Observable; - public readonly memberRoles: ko.Observable; + public readonly memberInviters: ko.Observable>; + public readonly memberRoles: ko.Observable>; public readonly supportsLegalHold: ko.Observable; public readonly teamName: ko.PureComputed; public readonly teamFeatures: ko.Observable; @@ -138,7 +138,7 @@ export class TeamState { return !!team.id && entity.domain === this.teamDomain() && entity.teamId === team.id; } - readonly isExternal = (userId: string): boolean => { + isExternal(userId: string): boolean { return this.memberRoles()[userId] === ROLE.PARTNER; - }; + } } From 77f9525f520daa49fec85da4c055caef7bbdfaac Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Wed, 8 Nov 2023 17:47:54 +0100 Subject: [PATCH 84/86] runfix: Handle mention deletion edge cases (#16167) --- .../RichTextEditor/nodes/Mention.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/script/components/RichTextEditor/nodes/Mention.tsx b/src/script/components/RichTextEditor/nodes/Mention.tsx index 49a46b8f604..b6f1a5227ae 100644 --- a/src/script/components/RichTextEditor/nodes/Mention.tsx +++ b/src/script/components/RichTextEditor/nodes/Mention.tsx @@ -42,6 +42,7 @@ import { NodeSelection, RangeSelection, $isRangeSelection, + $createRangeSelection, } from 'lexical'; import {KEY} from 'Util/KeyboardUtil'; @@ -81,12 +82,21 @@ export const Mention = (props: MentionComponentProps) => { const rangeSelection = $isRangeSelection(currentSelection) ? currentSelection : null; let shouldSelectNode = false; - if (event.key === 'Backspace') { - shouldSelectNode = nodeKey === rangeSelection?.getNodes()[0]?.getKey(); - } else if (event.key === 'Delete') { - const currentNode = rangeSelection?.getNodes()[0]; - const isOnTheEdgeOfNode = currentNode?.getTextContent().length === rangeSelection?.focus.offset; - shouldSelectNode = currentNode?.getNextSibling()?.getKey() === nodeKey && isOnTheEdgeOfNode; + const selectedNode = rangeSelection?.getNodes().length === 1 && rangeSelection?.getNodes()[0]; + if (selectedNode) { + const isCurrentNode = nodeKey === selectedNode?.getKey(); + if (event.key === 'Backspace') { + // When backspace is hit, we want to select the mention if the cursor is right after it + const isNextNode = + selectedNode?.getPreviousSibling()?.getKey() === nodeKey && rangeSelection?.focus.offset === 0; + shouldSelectNode = isCurrentNode || isNextNode; + } else if (event.key === 'Delete') { + // When backspace is hit, we want to select the mention if the cursor is right before it + const isNextNode = + selectedNode?.getNextSibling()?.getKey() === nodeKey && + rangeSelection?.focus.offset === selectedNode?.getTextContent().length; + shouldSelectNode = isCurrentNode || isNextNode; + } } // If the cursor is right before the mention, we first select the mention before deleting it if (shouldSelectNode) { @@ -101,9 +111,16 @@ export const Mention = (props: MentionComponentProps) => { const node = $getNodeByKey(nodeKey); if ($isMentionNode(node)) { + const previousNode = node.getPreviousSibling(); + if ($isTextNode(previousNode)) { + const newSelection = $createRangeSelection(); + const contentLength = previousNode.getTextContent().length; + newSelection.setTextNodeRange(previousNode, contentLength, previousNode, contentLength); + $setSelection(newSelection); + } node.remove(); + return true; } - setSelected(false); } return false; From 106cde3afa1ef4436d9ce322993213abc38dee63 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Thu, 9 Nov 2023 09:35:58 +0100 Subject: [PATCH 85/86] test: Improve UserRepository tests (#16168) --- src/script/user/UserRepository.test.ts | 200 +++++++++++++------------ 1 file changed, 104 insertions(+), 96 deletions(-) diff --git a/src/script/user/UserRepository.test.ts b/src/script/user/UserRepository.test.ts index fa8dd48bb2e..d0fa3853573 100644 --- a/src/script/user/UserRepository.test.ts +++ b/src/script/user/UserRepository.test.ts @@ -18,7 +18,7 @@ */ import {RECEIPT_MODE} from '@wireapp/api-client/lib/conversation/data'; -import {QualifiedId} from '@wireapp/api-client/lib/user'; +import type {User as APIClientUser} from '@wireapp/api-client/lib/user'; import {amplify} from 'amplify'; import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; @@ -32,34 +32,71 @@ import {matchQualifiedIds} from 'Util/QualifiedId'; import {ConsentValue} from './ConsentValue'; import {UserRepository} from './UserRepository'; +import {UserService} from './UserService'; import {UserState} from './UserState'; +import {AssetRepository} from '../assets/AssetRepository'; +import {ClientRepository} from '../client'; import {ClientMapper} from '../client/ClientMapper'; import {ConnectionEntity} from '../connection/ConnectionEntity'; import {User} from '../entity/User'; import {EventRepository} from '../event/EventRepository'; import {PropertiesRepository} from '../properties/PropertiesRepository'; - -describe('UserRepository', () => { - const testFactory = new TestFactory(); - let userRepository: UserRepository; - let userState: UserState; - - beforeAll(async () => { - userRepository = await testFactory.exposeUserActors(); - userState = userRepository['userState']; - }); - - afterEach(() => { - userRepository['userState'].users.removeAll(); +import {SelfService} from '../self/SelfService'; +import {TeamState} from '../team/TeamState'; +import {serverTimeHandler} from '../time/serverTimeHandler'; + +const testFactory = new TestFactory(); +async function buildUserRepository() { + const storageRepo = await testFactory.exposeStorageActors(); + + const userService = new UserService(storageRepo['storageService']); + const assetRepository = new AssetRepository(); + const selfService = new SelfService(); + const clientRepository = new ClientRepository({} as any, {} as any); + const propertyRepository = new PropertiesRepository({} as any, {} as any); + const userState = new UserState(); + const teamState = new TeamState(); + + const userRepository = new UserRepository( + userService, + assetRepository, + selfService, + clientRepository, + serverTimeHandler, + propertyRepository, + userState, + teamState, + ); + return [ + userRepository, + { + userService, + assetRepository, + selfService, + clientRepository, + serverTimeHandler, + propertyRepository, + userState, + teamState, + }, + ] as const; +} + +function createConnections(users: APIClientUser[]) { + return users.map(user => { + const connection = new ConnectionEntity(); + connection.userId = user.qualified_id; + return connection; }); +} +describe('UserRepository', () => { describe('Account preferences', () => { describe('Data usage permissions', () => { - it('syncs the "Send anonymous data" preference through WebSocket events', () => { - const setPropertyMock = jest - .spyOn(userRepository['propertyRepository'], 'setProperty') - .mockReturnValue(undefined); + it('syncs the "Send anonymous data" preference through WebSocket events', async () => { + const [, {propertyRepository}] = await buildUserRepository(); + const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); const turnOnErrorReporting = { key: 'webapp', type: 'user.properties-set', @@ -97,10 +134,9 @@ describe('UserRepository', () => { expect(setPropertyMock).toHaveBeenCalledWith(turnOffErrorReporting.key, turnOffErrorReporting.value); }); - it('syncs the "Receive newsletter" preference through WebSocket events', () => { - const setPropertyMock = jest - .spyOn(userRepository['propertyRepository'], 'setProperty') - .mockReturnValue(undefined); + it('syncs the "Receive newsletter" preference through WebSocket events', async () => { + const [userRepository, {propertyRepository}] = await buildUserRepository(); + const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); const deletePropertyMock = jest .spyOn(userRepository['propertyRepository'], 'deleteProperty') @@ -129,14 +165,11 @@ describe('UserRepository', () => { }); describe('Privacy', () => { - it('syncs the "Read receipts" preference through WebSocket events', () => { - const setPropertyMock = jest - .spyOn(userRepository['propertyRepository'], 'setProperty') - .mockReturnValue(undefined); + it('syncs the "Read receipts" preference through WebSocket events', async () => { + const [, {propertyRepository}] = await buildUserRepository(); + const setPropertyMock = jest.spyOn(propertyRepository, 'setProperty').mockReturnValue(undefined); - const deletePropertyMock = jest - .spyOn(userRepository['propertyRepository'], 'deleteProperty') - .mockReturnValue(undefined); + const deletePropertyMock = jest.spyOn(propertyRepository, 'deleteProperty').mockReturnValue(undefined); const turnOnReceiptMode = { key: PropertiesRepository.CONFIG.WIRE_RECEIPT_MODE.key, @@ -163,16 +196,14 @@ describe('UserRepository', () => { describe('User handling', () => { describe('findUserById', () => { let user: User; + let userRepository: UserRepository; - beforeEach(() => { + beforeEach(async () => { + [userRepository] = await buildUserRepository(); user = new User(entities.user.john_doe.id); return userRepository['saveUser'](user); }); - afterEach(() => { - userState.users.removeAll(); - }); - it('should find an existing user', () => { const userEntity = userRepository.findUserById({id: user.id, domain: ''}); @@ -187,7 +218,8 @@ describe('UserRepository', () => { }); describe('saveUser', () => { - it('saves a user', () => { + it('saves a user', async () => { + const [userRepository, {userState}] = await buildUserRepository(); const user = new User(entities.user.jane_roe.id); userRepository['saveUser'](user); @@ -196,7 +228,8 @@ describe('UserRepository', () => { expect(userState.users()[0]).toBe(user); }); - it('saves self user', () => { + it('saves self user', async () => { + const [userRepository, {userState}] = await buildUserRepository(); const user = new User(entities.user.jane_roe.id); userRepository['saveUser'](user, true); @@ -209,21 +242,27 @@ describe('UserRepository', () => { describe('loadUsers', () => { const localUsers = [generateAPIUser(), generateAPIUser(), generateAPIUser()]; + let userRepository: UserRepository; + let userState: UserState; + let userService: UserService; + beforeEach(async () => { + [userRepository, {userState, userService}] = await buildUserRepository(); jest.resetAllMocks(); - jest.spyOn(userRepository['userService'], 'loadUserFromDb').mockResolvedValue(localUsers); + jest.spyOn(userService, 'loadUserFromDb').mockResolvedValue(localUsers); const selfUser = new User('self'); selfUser.isMe = true; + userState.self(selfUser); userState.users([selfUser]); }); it('loads all users from backend even when they are already known locally', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; const users = [...localUsers, ...newUsers]; - const userIds = users.map(user => user.qualified_id!); - const fetchUserSpy = jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: users}); + const connections = createConnections(users); + const fetchUserSpy = jest.spyOn(userService, 'getUsers').mockResolvedValue({found: users}); - await userRepository.loadUsers(new User('self'), [], [], userIds); + await userRepository.loadUsers(new User('self'), connections, [], []); expect(userState.users()).toHaveLength(users.length + 1); expect(fetchUserSpy).toHaveBeenCalledWith(users.map(user => user.qualified_id!)); @@ -232,18 +271,10 @@ describe('UserRepository', () => { it('assigns connections with users', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; const users = [...localUsers, ...newUsers]; - const userIds = users.map(user => user.qualified_id!); - jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: users}); + const connections = createConnections(users); + jest.spyOn(userService, 'getUsers').mockResolvedValue({found: users}); - const createConnectionWithUser = (userId: QualifiedId) => { - const connection = new ConnectionEntity(); - connection.userId = userId; - return connection; - }; - - const connections = users.map(user => createConnectionWithUser(user.qualified_id)); - - await userRepository.loadUsers(new User('self'), connections, [], userIds); + await userRepository.loadUsers(new User('self'), connections, [], []); expect(userState.users()).toHaveLength(users.length + 1); users.forEach(user => { @@ -254,17 +285,16 @@ describe('UserRepository', () => { it('loads users that are partially stored in the DB and maps availability', async () => { const userIds = localUsers.map(user => user.qualified_id!); + const connections = createConnections(localUsers); const partialUsers = [ {id: userIds[0].id, availability: Availability.Type.AVAILABLE}, {id: userIds[1].id, availability: Availability.Type.BUSY}, ]; jest.spyOn(userRepository['userService'], 'loadUserFromDb').mockResolvedValue(partialUsers as any); - const fetchUserSpy = jest - .spyOn(userRepository['userService'], 'getUsers') - .mockResolvedValue({found: localUsers}); + const fetchUserSpy = jest.spyOn(userService, 'getUsers').mockResolvedValue({found: localUsers}); - await userRepository.loadUsers(new User('self'), [], [], userIds); + await userRepository.loadUsers(new User('self'), connections, [], []); expect(userState.users()).toHaveLength(localUsers.length + 1); expect(fetchUserSpy).toHaveBeenCalledWith(userIds); @@ -275,11 +305,11 @@ describe('UserRepository', () => { it('deletes users that are not needed', async () => { const newUsers = [generateAPIUser(), generateAPIUser()]; - const userIds = newUsers.map(user => user.qualified_id!); - const removeUserSpy = jest.spyOn(userRepository['userService'], 'removeUserFromDb').mockResolvedValue(); - jest.spyOn(userRepository['userService'], 'getUsers').mockResolvedValue({found: newUsers}); + const connections = createConnections(newUsers); + const removeUserSpy = jest.spyOn(userService, 'removeUserFromDb').mockResolvedValue(); + jest.spyOn(userService, 'getUsers').mockResolvedValue({found: newUsers}); - await userRepository.loadUsers(new User(), [], [], userIds); + await userRepository.loadUsers(new User(), connections, [], []); expect(userState.users()).toHaveLength(newUsers.length + 1); expect(removeUserSpy).toHaveBeenCalledTimes(localUsers.length); @@ -290,12 +320,10 @@ describe('UserRepository', () => { }); describe('assignAllClients', () => { - let userJaneRoe: User; - let userJohnDoe: User; - - beforeEach(() => { - userJaneRoe = new User(entities.user.jane_roe.id); - userJohnDoe = new User(entities.user.john_doe.id); + it('assigns all available clients to the users', async () => { + const [userRepository, {clientRepository}] = await buildUserRepository(); + const userJaneRoe = new User(entities.user.jane_roe.id); + const userJohnDoe = new User(entities.user.john_doe.id); userRepository['saveUsers']([userJaneRoe, userJohnDoe]); const permanent_client = ClientMapper.mapClient(entities.clients.john_doe.permanent, false); @@ -306,12 +334,10 @@ describe('UserRepository', () => { [entities.user.jane_roe.id]: [plain_client], }; - spyOn(testFactory.client_repository!, 'getAllClientsFromDb').and.returnValue(Promise.resolve(recipients)); - }); + jest.spyOn(clientRepository, 'getAllClientsFromDb').mockResolvedValue(recipients); - it('assigns all available clients to the users', () => { return userRepository.assignAllClients().then(() => { - expect(testFactory.client_repository!.getAllClientsFromDb).toHaveBeenCalled(); + expect(clientRepository.getAllClientsFromDb).toHaveBeenCalled(); expect(userJaneRoe.devices().length).toBe(1); expect(userJaneRoe.devices()[0].id).toBe(entities.clients.jane_roe.plain.id); expect(userJohnDoe.devices().length).toBe(2); @@ -323,41 +349,22 @@ describe('UserRepository', () => { describe('verify_username', () => { it('resolves with username when username is not taken', async () => { + const [userRepository, {userService}] = await buildUserRepository(); const expectedUsername = 'john_doe'; const notFoundError = new Error('not found') as any; notFoundError.response = {status: HTTP_STATUS.NOT_FOUND}; - const userRepo = new UserRepository( - { - checkUserHandle: jest.fn().mockImplementation(() => Promise.reject(notFoundError)), - } as any, // UserService - {} as any, // AssetRepository, - {} as any, // SelfService, - {} as any, // ClientRepository, - {} as any, // ServerTimeHandler, - {} as any, // PropertiesRepository, - {} as any, // UserState - ); - - const actualUsername = await userRepo.verifyUserHandle(expectedUsername); + jest.spyOn(userService, 'checkUserHandle').mockRejectedValue(notFoundError); + + const actualUsername = await userRepository.verifyUserHandle(expectedUsername); expect(actualUsername).toBe(expectedUsername); }); it('rejects when username is taken', async () => { + const [userRepository, {userService}] = await buildUserRepository(); const username = 'john_doe'; + jest.spyOn(userService, 'checkUserHandle').mockResolvedValue(undefined); - const userRepo = new UserRepository( - { - checkUserHandle: jest.fn().mockImplementation(() => Promise.resolve()), - } as any, // UserService - {} as any, // AssetRepository, - {} as any, // SelfService, - {} as any, // ClientRepository, - {} as any, // ServerTimeHandler, - {} as any, // PropertiesRepository, - {} as any, // UserState - ); - - await expect(userRepo.verifyUserHandle(username)).rejects.toMatchObject({ + await expect(userRepository.verifyUserHandle(username)).rejects.toMatchObject({ message: 'User related backend request failure', name: 'UserError', type: 'REQUEST_FAILURE', @@ -368,7 +375,8 @@ describe('UserRepository', () => { describe('updateUsers', () => { it('should update local users', async () => { - const userService = userRepository['userService']; + const [userRepository, {userService, userState}] = await buildUserRepository(); + userState.self(new User()); const user = new User(entities.user.jane_roe.id); user.name('initial name'); user.isMe = true; From afe4bb2e132f22bcf16dde71d9130c2d6ced3200 Mon Sep 17 00:00:00 2001 From: Arjita Date: Thu, 9 Nov 2023 14:31:16 +0100 Subject: [PATCH 86/86] fix: Wrong focus when first clicking on emoji search bar and prevent emoji selection while selecting text message (#16169) * fix: Wrong focus when first clicking on emoji search bar * fix: prevent emoji pills or floating action menu selection while selecting a text message * fix: refactor code * fix: revert change * refactor: add comment --- .../ContentMessage/MessageActions/MessageActions.styles.ts | 1 + .../MessageActions/MessageReactions/EmojiPicker.tsx | 6 ++++++ .../MessageReactions/MessageReactions.styles.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.styles.ts b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.styles.ts index 2f408f149db..9be4af5c601 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.styles.ts +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.styles.ts @@ -33,6 +33,7 @@ export const messageBodyActions: CSSObject = { position: 'absolute', right: '16px', top: '-20px', + userSelect: 'none', '@media (max-width: @screen-md-min)': { height: '45px', flexDirection: 'column', diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiPicker.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiPicker.tsx index d27e54ebf74..b8265d58b3e 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiPicker.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiPicker.tsx @@ -117,11 +117,17 @@ const EmojiPickerContainer: FC = ({ } }} > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}
{ + event.stopPropagation(); + }} >