Skip to content

Conversation

@thisisamir98
Copy link
Contributor

@thisisamir98 thisisamir98 commented Dec 29, 2025

TaskWPB-20940 [Web] Support edit for multipart message

Pull Request

Summary

  • What did I change and why?
    Added support for editing multipart messages.
    Added e2e test for editing multipart message.
  • Risks and how to roll out / roll back (e.g. feature flags):
    none.

Security Checklist (required)

  • External inputs are validated & sanitized on client and/or server where applicable.
  • API responses are validated; unexpected shapes are handled safely (fallbacks or errors).
  • No unsafe HTML is rendered; if unavoidable, sanitization is applied and documented where it happens.
  • Injection risks (XSS/SQL/command) are prevented via safe APIs and/or escaping.

Accessibility (required)

Standards Acknowledgement (required)

Copilot AI review requested due to automatic review settings December 29, 2025 09:18
@thisisamir98 thisisamir98 requested review from a team and otto-the-bot as code owners December 29, 2025 09:18
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request adds support for editing multipart messages (messages that contain both text and attachments like cells/tables). The implementation extends the existing message edit functionality to handle the more complex multipart message structure.

Key changes:

  • Extended event handling to support editing multipart messages in addition to regular text messages
  • Added logic to preserve attachments when editing multipart messages
  • Updated type definitions to include multipart message events in editable event types
  • Modified the cryptography mapper to handle edited multipart messages

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
yarn.lock Dependency updates for @wireapp packages and AWS SDK (3.940.0 → 3.958.0) - routine maintenance
package.json Core library update (@wireapp/core: 46.46.11 → 46.47.0)
getCommonMessageUpdates.ts Added multipart message handling logic to preserve attachments and common properties during edits
editedEventHandler.ts Extended edit validation to accept MULTIPART_MESSAGE_ADD events alongside MESSAGE_ADD
Message.ts Added helper methods to identify and retrieve multipart assets from messages
CryptographyMapper.ts Added multipart message mapping for edited messages
MessageRepository.ts Implemented sendEditMultiPart method and routing logic to choose between text/multipart edit paths
EventMapper.ts Enhanced updateMessageEvent to handle multipart message updates and preserve attachments
EventBuilder.ts Extended MultipartMessageAddEvent type definition to include edit-related fields
Comments suppressed due to low confidence (1)

src/script/repositories/cryptography/CryptographyMapper.ts:640

  • The multipart message mapping doesn't apply the same mention limit validation that regular text messages receive. In _mapText, there's a check limiting mentions to MAX_MENTIONS_PER_MESSAGE, but when _mapMultipart calls _mapText, this validation only applies to the text portion. However, since multipart messages can have attachments that might reference user data, consider whether additional validation is needed for the attachments array to prevent potential resource exhaustion or abuse.
  private _mapMultipart(text: Text, attachments: MultiPartContent['attachments']): MappedMultipart {
    const mappedText = this._mapText(text);
    return {
      data: {
        text: mappedText.data,
        attachments,
      },
      type: ClientEvent.CONVERSATION.MULTIPART_MESSAGE_ADD,
    };
  }

@codecov
Copy link

codecov bot commented Dec 29, 2025

Codecov Report

❌ Patch coverage is 34.09091% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 44.64%. Comparing base (5e51801) to head (4827388).
⚠️ Report is 28 commits behind head on dev.

Additional details and impacted files
@@            Coverage Diff             @@
##              dev   #19950      +/-   ##
==========================================
+ Coverage   44.62%   44.64%   +0.01%     
==========================================
  Files        1311     1311              
  Lines       33029    33064      +35     
  Branches     7315     7330      +15     
==========================================
+ Hits        14739    14761      +22     
- Misses      16539    16547       +8     
- Partials     1751     1756       +5     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Contributor

github-actions bot commented Dec 29, 2025

🔗 Download Full Report Artifact

🧪 Playwright Test Summary

  • Passed: 104
  • Failed: 10
  • Skipped: 11
  • 🔁 Flaky: 5
  • 📊 Total: 130
  • Total Runtime: 339.6s (~ 5 min 40 sec)
specs/Accessibility/Accessibility.spec.ts (❌ 1 failed, ⚠️ 1 flaky)
  • ❌ Accessibility > I should not lose a drafted message when switching between conversations in collapsed view (tags: TC-51, regression)
  • ⚠️ Accessibility > I want to see collapsed view when app is narrow (tags: TC-48, regression)
specs/AccountSettingsSpecs/accountSettings.spec.ts (❌ 1 failed, ⚠️ 1 flaky)
  • ❌ account settings > I should not be able to change email of user managed by SCIM (tags: TC-60, regression)
  • ⚠️ account settings > Edit Profile (tags: TC-8770, regression)
specs/AppLock/AppLock.spec.ts (❌ 1 failed, ⚠️ 0 flaky)
  • ❌ AppLock > Web: App should not lock if I switch back to webapp tab in time (during inactivity timeout) (tags: TC-2752, TC-2753, regression)
specs/ArchiveSpecs/archive.spec.ts (❌ 1 failed, ⚠️ 1 flaky)
  • ❌ Accessibility > I want to archive the 1on1 conversation from conversation details (tags: TC-105, regression)
  • ⚠️ Accessibility > Verify the conversation is not unarchived when there are new messages in this conversation (tags: TC-99, regression)
specs/CriticalFlow/accountManagement-TC-8639.spec.ts (❌ 1 failed, ⚠️ 0 flaky)
  • ❌ Account Management (tags: TC-8639, crit-flow-web)
specs/CriticalFlow/Cells/editMultipartMessage-TC-8786.spec.ts (❌ 1 failed, ⚠️ 0 flaky)
  • ❌ Edit multipart message in a group conversation (tags: crit-flow-cells, regression, TC-8786)
specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts (❌ 1 failed, ⚠️ 0 flaky)
  • ❌ Uploading an file in a group conversation (tags: crit-flow-cells, regression)
specs/CriticalFlow/oneOnOneCall-TC-8754.spec.ts (❌ 1 failed, ⚠️ 0 flaky)
  • ❌ 1:1 Video call with device switch and screenshare (tags: TC-8754, crit-flow-web)
specs/CriticalFlow/personalAccountLifecycle-TC-8638.spec.ts (❌ 0 failed, ⚠️ 1 flaky)
  • ⚠️ Personal Account Lifecycle (tags: TC-8638, crit-flow-web)
specs/LoginSpecs/login.spec.ts (❌ 1 failed, ⚠️ 0 flaky)
  • ❌ Verify you can sign in by email (tags: TC-3461, regression)
specs/RegressionSpecs/block-messages.spec.ts (❌ 0 failed, ⚠️ 1 flaky)
  • ⚠️ Block specs (tags: TC-141, regression)
specs/Reply/reply.spec.ts (❌ 1 failed, ⚠️ 0 flaky)
  • ❌ Reply > I should not be able to send a reply after I got removed from the conversation (tags: TC-3014, regression)

Copilot AI review requested due to automatic review settings December 30, 2025 12:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

…are/eventHandlers/editedEventHandler.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings December 30, 2025 14:07
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.


hasMultipartAsset(): boolean {
hasMultipartAsset(): this is ContentMessage {
return this.isContent() ? this.assets().some(assetEntity => assetEntity.type === AssetType.MULTIPART) : false;
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

There's an inconsistency in how multipart assets are checked. The hasMultipartAsset() method uses assetEntity.type === AssetType.MULTIPART while getMultipartAssets() uses assetEntity.isMultipart(). While both work, it's better to use the type guard method consistently for clarity and maintainability.

Consider updating line 201 to:

return this.isContent() ? this.assets().some(assetEntity => assetEntity.isMultipart()) : false;
Suggested change
return this.isContent() ? this.assets().some(assetEntity => assetEntity.type === AssetType.MULTIPART) : false;
return this.isContent() ? this.assets().some(assetEntity => assetEntity.isMultipart()) : false;

Copilot uses AI. Check for mistakes.
…C-8786.spec.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings December 30, 2025 15:06
…C-8786.spec.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
15.7% Duplication on New Code (required ≤ 5%)

See analysis details on SonarQube Cloud

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (3)

src/script/repositories/event/preprocessor/EventStorageMiddleware/eventHandlers/editedEventHandler.ts:90

  • The editedEventHandler.ts file has been modified to support multipart message editing, including new validation logic that prevents cross-type edits (line 51-53). However, there are no test files for this handler. Given that other event handlers in this directory have test coverage (e.g., reactionEventHandler.test.ts), tests should be added to verify:
  1. Multipart messages can be edited correctly
  2. Cross-type edits are prevented (editing text message as multipart or vice versa)
  3. Validation properly rejects edits from different users
  4. The handler correctly identifies and processes multipart edit events
export type EditableEvent = MessageAddEvent | MultipartMessageAddEvent;

function validateEditEvent(
  originalEvent: HandledEvents | undefined,
  editEvent: EditableEvent,
): originalEvent is StoredEvent<EditableEvent> {
  if (!originalEvent) {
    throwValidationError('Edit event without original event');
  }

  if (
    originalEvent.type !== ClientEvent.CONVERSATION.MESSAGE_ADD &&
    originalEvent.type !== ClientEvent.CONVERSATION.MULTIPART_MESSAGE_ADD
  ) {
    throwValidationError('Edit event for non-text message');
  }

  // do not allow invalid cross-type edits
  if (originalEvent.type !== editEvent.type) {
    throwValidationError('Edit event type does not match original event type');
  }

  if (originalEvent.from !== editEvent.from) {
    throwValidationError('ID reused by other user');
  }

  return true;
}

function getUpdatesForEditMessage(originalEvent: StoredEvent<EditableEvent>, newEvent: EditableEvent): EditableEvent {
  // Remove reactions, so that likes (hearts) don't stay when a message's text gets edited
  const commonUpdates = getCommonMessageUpdates(originalEvent, newEvent);

  return {...newEvent, ...commonUpdates, edited_time: newEvent.time, reactions: {}};
}

function computeEventUpdates(originalEvent: StoredEvent<EditableEvent>, newEvent: EditableEvent) {
  const primaryKeyUpdate = {primary_key: originalEvent.primary_key};
  const updates = getUpdatesForEditMessage(originalEvent, newEvent);

  return {...primaryKeyUpdate, ...updates};
}

export const handleEditEvent: EventHandler = async (event, {findEvent}) => {
  if (event.type !== CONVERSATION.MESSAGE_ADD && event.type !== CONVERSATION.MULTIPART_MESSAGE_ADD) {
    return undefined;
  }
  const editedEventId = event.data.replacing_message_id;
  if (!editedEventId) {
    return undefined;
  }
  const originalEvent = await findEvent(editedEventId);
  if (validateEditEvent(originalEvent, event)) {
    const updatedEvent = computeEventUpdates(originalEvent, event);
    return {type: 'update', event: updatedEvent, updates: updatedEvent};
  }
  return undefined;
};

src/script/repositories/conversation/EventMapper.ts:690

  • The EventMapper has been modified to handle editing of multipart messages in the updateMessageEvent method (lines 171-174) and _mapEventMultipartAdd (lines 680-682). However, the test file EventMapper.test.ts does not include tests for multipart message editing scenarios. Tests should be added to verify:
  1. Multipart messages can be updated when edited (ID changes case at line 171-174)
  2. The quote data is correctly extracted from multipart messages (line 164)
  3. Edited multipart messages preserve their attachments
  4. The edited_time is correctly set for edited multipart messages
  updateMessageEvent(originalEntity: ContentMessage, event: LegacyEventRecord): ContentMessage {
    const {id, data: eventData, edited_time: editedTime, qualified_conversation} = event;

    // Handle quote for both regular text messages and multipart messages
    const quoteData = eventData.quote || eventData.text?.quote;
    if (quoteData) {
      const {hash, message_id: messageId, user_id: userId, error} = quoteData;
      originalEntity.quote(new QuoteEntity({error, hash, messageId, userId}));
    }

    // Handle multipart messages when ID changes (edit case)
    if (id !== originalEntity.id && originalEntity.hasMultipartAsset()) {
      originalEntity.assets.removeAll();
      const multipartAsset = this._mapAssetMultipart(eventData as MultipartMessageAddEvent['data']);
      originalEntity.assets.push(multipartAsset);
    } else if (id !== originalEntity.id && originalEntity.hasAssetText()) {
      // Handle regular text messages when ID changes (edit case)
      originalEntity.assets.removeAll();
      const textAsset = this._mapAssetText(eventData);
      originalEntity.assets.push(textAsset);
    } else if (originalEntity.getFirstAsset) {
      const asset = originalEntity.getFirstAsset();
      if (eventData.status && (asset as FileAsset).status) {
        const assetEntity = this._mapAsset(event);
        originalEntity.assets([assetEntity]);
      }
      if (eventData.previews) {
        if ((asset as TextAsset).previews().length !== eventData.previews.length) {
          const previews = this._mapAssetLinkPreviews(eventData.previews);
          (asset as TextAsset).previews(previews as LinkPreviewEntity[]);
        }
      }

      const {
        preview_key,
        preview_domain = qualified_conversation?.domain || this.fallbackDomain,
        preview_otr_key,
        preview_sha256,
        preview_token,
      } = eventData as AssetData;
      if (preview_otr_key && preview_key && preview_domain) {
        const assetRemoteData = new AssetRemoteData({
          assetKey: preview_key,
          assetDomain: preview_domain,
          otrKey: preview_otr_key,
          sha256: preview_sha256,
          assetToken: preview_token,
          forceCaching: true,
        });
        (asset as FileAsset).preview_resource(assetRemoteData);
      }
    }

    if (event.reactions) {
      originalEntity.reactions(userReactionMapToReactionMap(event.reactions));
      originalEntity.version = event.version;
    }

    if (event.failedToSend) {
      originalEntity.failedToSend(event.failedToSend);
    }

    if (event.fileData) {
      originalEntity.fileData(event.fileData);
    }

    if (event.selected_button_id) {
      originalEntity.version = event.version;
    }

    originalEntity.id = id;

    if (originalEntity.isContent() || (originalEntity as Message).isPing()) {
      originalEntity.status(event.status ?? StatusType.SENT);
    }

    originalEntity.replacing_message_id = eventData.replacing_message_id;
    if (editedTime || eventData.edited_time) {
      originalEntity.edited_timestamp(new Date(editedTime || eventData.edited_time).getTime());
    }

    return addMetadata(originalEntity, event);
  }

  /**
   * Convert JSON event into a message entity.
   *
   * @param event Event data
   * @param conversationEntity Conversation entity the event belong to
   * @returns Mapped message entity
   */
  _mapJsonEvent(event: ConversationEvent | ClientConversationEvent, conversationEntity: Conversation) {
    let messageEntity;

    switch (event.type) {
      case CONVERSATION_EVENT.MEMBER_JOIN: {
        /* FIXME: the 'as any' is needed here because we need data that comes from the ServiceMiddleware.
         * We would need to create a super type that represents an event that has been decorated by middlewares...
         */
        messageEntity = this._mapEventMemberJoin(event as any, conversationEntity);
        break;
      }

      case CONVERSATION_EVENT.MEMBER_LEAVE: {
        messageEntity = this._mapEventMemberLeave(event);
        break;
      }

      case CONVERSATION_EVENT.RECEIPT_MODE_UPDATE: {
        messageEntity = this._mapEventReceiptModeUpdate(event);
        break;
      }

      case CONVERSATION_EVENT.MESSAGE_TIMER_UPDATE: {
        messageEntity = this._mapEventMessageTimerUpdate(event);
        break;
      }

      case CONVERSATION_EVENT.RENAME: {
        messageEntity = this._mapEventRename(event);
        break;
      }

      case CONVERSATION_EVENT.PROTOCOL_UPDATE: {
        messageEntity = this._mapEventProtocolUpdate(event);
        break;
      }

      case ClientEvent.CONVERSATION.ASSET_ADD: {
        messageEntity = addMetadata(this._mapEventAssetAdd(event), event);
        break;
      }

      case ClientEvent.CONVERSATION.COMPOSITE_MESSAGE_ADD: {
        const addMessage = this._mapEventCompositeMessageAdd(event);
        messageEntity = addMetadata(addMessage, event);
        break;
      }

      case ClientEvent.CONVERSATION.DELETE_EVERYWHERE: {
        messageEntity = this._mapEventDeleteEverywhere(event);
        break;
      }

      case ClientEvent.CONVERSATION.GROUP_CREATION: {
        messageEntity = this._mapEventGroupCreation(event);
        break;
      }

      case ClientEvent.CONVERSATION.INCOMING_MESSAGE_TOO_BIG:
      case ClientEvent.CONVERSATION.UNABLE_TO_DECRYPT: {
        messageEntity = this._mapEventUnableToDecrypt(event as ErrorEvent);
        break;
      }

      case ClientEvent.CONVERSATION.KNOCK: {
        messageEntity = addMetadata(this._mapEventPing(), event);
        break;
      }

      case ClientEvent.CONVERSATION.CALL_TIME_OUT: {
        messageEntity = this._mapEventCallingTimeout(event);
        break;
      }

      case ClientEvent.CONVERSATION.FAILED_TO_ADD_USERS: {
        messageEntity = this._mapEventFailedToAddUsers(event);
        break;
      }

      case ClientEvent.CONVERSATION.FEDERATION_STOP: {
        messageEntity = this._mapEventFederationStop(event);
        break;
      }

      case ClientEvent.CONVERSATION.LEGAL_HOLD_UPDATE: {
        messageEntity = this._mapEventLegalHoldUpdate(event);
        break;
      }

      case ClientEvent.CONVERSATION.LOCATION: {
        messageEntity = addMetadata(this._mapEventLocation(event), event);
        break;
      }

      case ClientEvent.CONVERSATION.MESSAGE_ADD: {
        const addMessage = this._mapEventMessageAdd(event);
        messageEntity = addMetadata(addMessage, event);
        break;
      }

      case ClientEvent.CONVERSATION.MULTIPART_MESSAGE_ADD: {
        const addMessage = this._mapEventMultipartAdd(event);
        messageEntity = addMetadata(addMessage, event);
        break;
      }

      case ClientEvent.CONVERSATION.MISSED_MESSAGES: {
        messageEntity = this._mapEventMissedMessages();
        break;
      }

      case ClientEvent.CONVERSATION.JOINED_AFTER_MLS_MIGRATION: {
        messageEntity = this._mapEventJoinedAfterMLSMigrationFinalisation();
        break;
      }

      case ClientEvent.CONVERSATION.MLS_MIGRATION_ONGOING_CALL: {
        messageEntity = this._mapEventMLSMigrationFinalisationOngoingCall();
        break;
      }

      case ClientEvent.CONVERSATION.MLS_CONVERSATION_RECOVERED: {
        messageEntity = this._mapEventMLSConversationRecovered();
        break;
      }

      case ClientEvent.CONVERSATION.ONE2ONE_CREATION: {
        messageEntity = this._mapEvent1to1Creation(event);
        break;
      }

      case ClientEvent.CONVERSATION.ONE2ONE_MIGRATED_TO_MLS: {
        messageEntity = this._mapEventOneToOneMigratedToMls();
        break;
      }

      case ClientEvent.CONVERSATION.TEAM_MEMBER_LEAVE: {
        messageEntity = this._mapEventTeamMemberLeave(event);
        break;
      }

      case ClientEvent.CONVERSATION.VERIFICATION: {
        messageEntity = this._mapEventVerification(event);
        break;
      }

      case ClientEvent.CONVERSATION.E2EI_VERIFICATION: {
        messageEntity = this._mapEventE2EIVerificationMessage(event);
        break;
      }

      case ClientEvent.CONVERSATION.VOICE_CHANNEL_ACTIVATE: {
        messageEntity = this._mapEventVoiceChannelActivate();
        break;
      }

      case ClientEvent.CONVERSATION.VOICE_CHANNEL_DEACTIVATE: {
        messageEntity = this._mapEventVoiceChannelDeactivate(event);
        break;
      }

      case ClientEvent.CONVERSATION.FILE_TYPE_RESTRICTED: {
        messageEntity = this._mapFileTypeRestricted(event);
        break;
      }

      default: {
        const {type, id} = event as LegacyEventRecord;
        this.logger.warn(`Ignored unhandled '${type}' event ${id ? `'${id}' ` : ''}`);
        throw new ConversationError(
          ConversationError.TYPE.MESSAGE_NOT_FOUND,
          ConversationError.MESSAGE.MESSAGE_NOT_FOUND,
        );
      }
    }

    const {
      category,
      data,
      from,
      qualified_from,
      id,
      primary_key,
      time,
      type,
      version,
      from_client_id,
      ephemeral_expires,
      ephemeral_started,
    } = event as LegacyEventRecord;

    messageEntity.category = category;
    messageEntity.conversation_id = conversationEntity.id;
    messageEntity.from = from;
    messageEntity.fromDomain = qualified_from?.domain;
    messageEntity.fromClientId = from_client_id;
    messageEntity.id = id;
    messageEntity.primary_key = primary_key;
    messageEntity.timestamp(new Date(time).getTime());
    messageEntity.type = type;
    messageEntity.version = version || 1;

    if (data) {
      messageEntity.legalHoldStatus = data.legal_hold_status;
    }

    if (messageEntity.isContent() || messageEntity.isPing()) {
      messageEntity.status((event as EventRecord).status ?? StatusType.SENT);
    }

    if (messageEntity.isComposite()) {
      const {selected_button_id, waiting_button_id} = event as LegacyEventRecord;
      messageEntity.selectedButtonId(selected_button_id);
      messageEntity.waitingButtonId(waiting_button_id);
    }
    if (messageEntity.isReactable()) {
      (messageEntity as ContentMessage).reactions(
        userReactionMapToReactionMap((event as LegacyEventRecord).reactions ?? {}),
      );
    }

    if (ephemeral_expires) {
      messageEntity.ephemeral_expires(ephemeral_expires);
      messageEntity.ephemeral_started(Number(ephemeral_started) || 0);
    }

    if (isNaN(messageEntity.timestamp())) {
      this.logger.warn(`Could not get timestamp for message '${messageEntity.id}'. Skipping it.`);
      messageEntity = undefined;
    }

    return isContentMessage(messageEntity)
      ? this.updateMessageEvent(messageEntity, event as EventRecord)
      : messageEntity;
  }

  //##############################################################################
  // Event mappers
  //##############################################################################

  /**
   * Maps JSON data of conversation.one2one-creation message into message entity.
   *
   * @param eventData Message data
   * @returns Member message entity
   */
  private _mapEvent1to1Creation({data: eventData}: LegacyEventRecord) {
    const {has_service: hasService, userIds} = eventData;
    const messageEntity = new MemberMessage();
    messageEntity.memberMessageType = SystemMessageType.CONNECTION_ACCEPTED;
    messageEntity.userIds(userIds);

    if (hasService) {
      messageEntity.showServicesWarning = true;
    }

    return messageEntity;
  }

  /**
   * Maps JSON data of conversation.asset_add message into message entity.
   *
   * @param event Message data
   * @returns Content message entity
   */
  private _mapEventAssetAdd(event: LegacyEventRecord) {
    const messageEntity = new ContentMessage();

    const assetEntity = this._mapAsset(event);
    messageEntity.assets.push(assetEntity);

    return messageEntity;
  }

  /**
   * Maps JSON data of delete everywhere event to message entity.
   *
   * @param eventData Message data
   * @returns Delete message entity
   */
  private _mapEventDeleteEverywhere({data: eventData}: LegacyEventRecord) {
    const messageEntity = new DeleteMessage();
    messageEntity.deleted_timestamp = new Date(eventData.deleted_time).getTime();
    return messageEntity;
  }

  /**
   * Map JSON ata of group creation event to message entity.
   *
   * @param eventData Message data
   * @returns Member message entity
   */
  private _mapEventGroupCreation({data: eventData}: LegacyEventRecord) {
    const messageEntity = new MemberMessage();
    messageEntity.memberMessageType = SystemMessageType.CONVERSATION_CREATE;
    messageEntity.name(eventData.name || '');
    messageEntity.userIds(eventData.userIds);
    messageEntity.allTeamMembers = eventData.allTeamMembers;
    return messageEntity;
  }

  _mapEventCallingTimeout({data, time}: LegacyEventRecord) {
    return new CallingTimeoutMessage(data.reason, parseInt(time, 10));
  }

  _mapEventFailedToAddUsers({data, time}: FailedToAddUsersMessageEvent) {
    return new FailedToAddUsersMessage(data, parseInt(time, 10));
  }

  _mapEventFederationStop({data, time}: FederationStopEvent) {
    return new FederationStopMessage(data.domains, parseInt(time, 10));
  }

  _mapEventLegalHoldUpdate({data, timestamp}: LegacyEventRecord) {
    return new LegalHoldMessage(data.legal_hold_status, timestamp);
  }

  /**
   * Maps JSON data of conversation.location message into message entity.
   *
   * @param eventData Message data
   * @returns Location message entity
   */
  private _mapEventLocation({data: eventData}: LegacyEventRecord) {
    const location = eventData.location;
    const messageEntity = new ContentMessage();
    const assetEntity = new Location();

    assetEntity.longitude = location.longitude;
    assetEntity.latitude = location.latitude;
    assetEntity.name = location.name;
    assetEntity.zoom = location.zoom;

    messageEntity.assets.push(assetEntity);

    return messageEntity;
  }

  /**
   * Maps JSON data of conversation.member_join message into message entity.
   *
   * @param event Message data
   * @param conversationEntity Conversation entity the event belong to
   * @returns Member message entity
   */
  private _mapEventMemberJoin(
    event: MemberJoinEvent & {data?: {has_service?: boolean}},
    conversationEntity: Conversation,
  ) {
    const {data: eventData, from: sender} = event;
    const {has_service: hasService} = eventData;
    const userIds = eventData.qualified_user_ids || eventData.user_ids.map(id => ({domain: '', id}));

    const messageEntity = new MemberMessage();

    const isSingleModeConversation = conversationEntity.is1to1() || conversationEntity.isRequest();
    messageEntity.visible(!isSingleModeConversation);

    if (conversationEntity.isGroupOrChannel()) {
      const messageFromCreator = sender === conversationEntity.creator;
      const creatorIndex = userIds.findIndex(user => user.id === sender);
      const creatorIsJoiningMember = messageFromCreator && creatorIndex !== -1;

      if (creatorIsJoiningMember) {
        userIds.splice(creatorIndex, 1);
        messageEntity.memberMessageType = SystemMessageType.CONVERSATION_CREATE;
      }

      if (hasService) {
        messageEntity.showServicesWarning = true;
      }

      messageEntity.userIds(userIds);
    }

    return messageEntity;
  }

  /**
   * Maps JSON data of conversation.member_leave message into message entity.
   *
   * @param eventData Message data
   * @returns Member message entity
   */
  private _mapEventMemberLeave({data: eventData}: MemberLeaveEvent | TeamMemberLeaveEvent) {
    const messageEntity = new MemberMessage();
    const userIds = eventData.qualified_user_ids || eventData.user_ids.map(id => ({domain: '', id}));
    messageEntity.userIds(userIds);
    messageEntity.reason = eventData.reason;
    return messageEntity;
  }

  /**
   * Maps JSON data of conversation.message_add message into message entity.
   *
   * @param event Message data
   * @returns Content message entity
   */
  private _mapEventMessageAdd(event: MessageAddEvent) {
    const {data: eventData, edited_time: editedTime} = event;
    const messageEntity = new ContentMessage();

    const assets = this._mapAssetText(eventData);
    messageEntity.assets.push(assets);
    messageEntity.replacing_message_id = eventData.replacing_message_id;
    if (editedTime) {
      messageEntity.edited_timestamp(new Date(editedTime).getTime());
    }

    if (eventData.quote) {
      const {message_id: messageId, user_id: userId, error} = eventData.quote as any;
      messageEntity.quote(new QuoteEntity({error, messageId, userId}));
    }

    return messageEntity;
  }

  /**
   * Maps JSON data of conversation.message_add message into message entity.
   *
   * @param event Message data
   * @returns Content message entity
   */
  private _mapEventMultipartAdd(event: MultipartMessageAddEvent) {
    const {data: eventData, edited_time: editedTime} = event;
    const messageEntity = new ContentMessage();

    const assets = this._mapAssetMultipart(eventData);
    messageEntity.assets.push(assets);
    messageEntity.replacing_message_id = eventData.replacing_message_id;
    if (editedTime) {
      messageEntity.edited_timestamp(new Date(editedTime).getTime());
    }

    if (eventData.text?.quote) {
      const {message_id: messageId, user_id: userId, error} = eventData.text.quote as any;
      messageEntity.quote(new QuoteEntity({error, messageId, userId}));
    }

    return messageEntity;

src/script/repositories/cryptography/CryptographyMapper.ts:513

  • The _mapEdited method in CryptographyMapper has been modified to handle multipart message edits (lines 500-508), including validation that edited multipart messages must have text content. However, the test file CryptographyMapper.test.ts does not include tests for the edited message mapping functionality. Tests should be added to verify:
  1. Multipart message edits are correctly mapped
  2. The validation error is thrown when multipart.text is missing
  3. The replacing_message_id is correctly set for edited multipart messages
  4. Both text and multipart edits work correctly
  private _mapEdited(edited: MessageEdit) {
    if (edited.multipart) {
      if (!edited.multipart.text) {
        const message = 'Edited multipart message is missing required text content.';
        throw new CryptographyError(CryptographyError.TYPE.UNHANDLED_TYPE, message);
      }
      const mappedMultipart = this._mapMultipart(edited.multipart.text as Text, edited.multipart.attachments);
      mappedMultipart.data.replacing_message_id = edited.replacingMessageId;
      return mappedMultipart;
    }

    const mappedMessage = this._mapText(edited.text as Text);
    mappedMessage.data.replacing_message_id = edited.replacingMessageId;
    return mappedMessage;
  }

Comment on lines +36 to +39
await textLocator.waitFor({state: 'visible', timeout: 5000});
await imageLocator.waitFor({state: 'visible', timeout: 5000});

return (await textLocator.isVisible()) && (await imageLocator.isVisible());
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

The isMultipartMessageVisible method performs separate waitFor operations on the text and image locators, but then checks their visibility again. If either element becomes hidden between the waitFor and the final isVisible checks, this could lead to flaky test behavior. Consider checking visibility immediately after waiting, or combine the checks into a single operation that verifies both elements are visible.

Suggested change
await textLocator.waitFor({state: 'visible', timeout: 5000});
await imageLocator.waitFor({state: 'visible', timeout: 5000});
return (await textLocator.isVisible()) && (await imageLocator.isVisible());
await Promise.all([
textLocator.waitFor({state: 'visible', timeout: 5000}),
imageLocator.waitFor({state: 'visible', timeout: 5000}),
]);
return true;

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,240 @@
/*
* Wire
* Copyright (C) 2018 Wire Swiss GmbH
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

The copyright year in the header is 2018, but this is a new file created in 2025. The copyright year should be updated to 2025 to accurately reflect when this file was created.

Copilot uses AI. Check for mistakes.
};
}

throw new Error('Incompatible event types for computing common message updates');
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

The error thrown at line 69 may occur if the original and new event types are mismatched (e.g., trying to edit a text message as a multipart message or vice versa). While the validation in editedEventHandler.ts should prevent this, it would be helpful to include the event types in the error message for better debugging. Consider changing the error message to include information about what types were encountered.

Suggested change
throw new Error('Incompatible event types for computing common message updates');
const originalType = (originalEvent as any)?.type ?? 'unknown';
const newType = (newEvent as any)?.type ?? 'unknown';
throw new Error(
`Incompatible event types for computing common message updates (original: ${originalType}, new: ${newType})`,
);

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +95
// Wait for some time before uploading the file to make sure the cell is ready
await new Promise(resolve => setTimeout(resolve, 5000));
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

The hardcoded sleep of 5000ms (5 seconds) is a brittle test practice. Instead of using an arbitrary timeout, consider waiting for a specific condition or UI element to be ready. This makes the test more reliable and potentially faster, as it won't wait longer than necessary.

Suggested change
// Wait for some time before uploading the file to make sure the cell is ready
await new Promise(resolve => setTimeout(resolve, 5000));
// Wait until the cells conversation is actually ready before continuing
await userBPages.cellsConversation().waitForConversationToBeReady();

Copilot uses AI. Check for mistakes.
Comment on lines +23 to 70
import {EditableEvent} from './editedEventHandler';

function isMultipartEvent(event: EditableEvent): event is MultipartMessageAddEvent {
return 'attachments' in event.data && Array.isArray(event.data.attachments);
}

function isMessageAddEvent(event: EditableEvent): event is MessageAddEvent {
return !isMultipartEvent(event);
}

export function getCommonMessageUpdates(
originalEvent: StoredEvent<EditableEvent>,
newEvent: EditableEvent,
): EditableEvent {
const commonProps = {
edited_time: originalEvent.edited_time,
read_receipts: !newEvent.read_receipts ? originalEvent.read_receipts : newEvent.read_receipts,
status: !newEvent.status || newEvent.status < originalEvent.status ? originalEvent.status : newEvent.status,
time: originalEvent.time,
};

// Handle multipart messages
if (isMultipartEvent(newEvent) && isMultipartEvent(originalEvent)) {
return {
...newEvent,
...commonProps,
data: {
...newEvent.data,
attachments: newEvent.data.attachments ?? originalEvent.data.attachments,
expects_read_confirmation: originalEvent.data.expects_read_confirmation,
},
};
}

// Handle regular text messages
if (isMessageAddEvent(newEvent) && isMessageAddEvent(originalEvent)) {
return {
...newEvent,
...commonProps,
data: {
...newEvent.data,
expects_read_confirmation: originalEvent.data.expects_read_confirmation,
},
};
}

throw new Error('Incompatible event types for computing common message updates');
}
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

The test file for getCommonMessageUpdates does not include test cases for the new multipart message editing functionality. Since the function now handles both MessageAddEvent and MultipartMessageAddEvent with different logic paths, tests should be added to ensure the multipart case preserves attachments and handles the expects_read_confirmation field correctly. Consider adding test cases for:

  1. Editing a multipart message preserves its attachments
  2. The function throws an error when trying to edit a text message as multipart or vice versa
  3. Common properties are correctly merged for multipart messages

Copilot uses AI. Check for mistakes.
const userBPage = await userBContext.newPage();
const userBPageManager = new PageManager(userBPage);

const {pages: userBPages, modals: userBModals} = userBPageManager.webapp;
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

Unused variable userBComponents.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants