From 5973888757105a6ca8d0f549bf6a99a8dfcacb72 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Fri, 17 Apr 2026 15:23:28 +0200 Subject: [PATCH 1/4] fix: recognize async moderated shadow blocked messages as blocked (#3131) https://linear.app/stream/issue/REACT-945/handle-shadow-blocked-images-in-react-chat-sdk `isMessageBlocked` method decides if we should display the `MessageBlocked` placeholder for a message. This method didn't categorize `shadowed` messages as blocked (probably because these messages are not displayed in message list, except when they change from `shadowed: false` to `shadowed: true`) _Provide a description of the implementation_ _Add relevant screenshots_ --- package.json | 2 +- .../Message/__tests__/utils.test.js | 38 +++++++++++++++++++ src/components/Message/utils.tsx | 9 +++-- yarn.lock | 8 ++-- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 888050b04c..8121dbc444 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "semantic-release": "^25.0.2", - "stream-chat": "^9.38.0", + "stream-chat": "^9.41.1", "ts-jest": "^29.2.5", "typescript": "^5.4.5", "typescript-eslint": "^8.17.0" diff --git a/src/components/Message/__tests__/utils.test.js b/src/components/Message/__tests__/utils.test.js index e42898475b..01b7bd2f5e 100644 --- a/src/components/Message/__tests__/utils.test.js +++ b/src/components/Message/__tests__/utils.test.js @@ -1,4 +1,5 @@ import { generateMessage, generateReaction, generateUser } from 'mock-builders'; +import { fromPartial } from '@total-typescript/shoehorn'; import { countReactions, getTestClientWithUser, @@ -12,6 +13,7 @@ import { getMessageActions, getNonImageAttachments, getReadByTooltipText, + isMessageBlocked, isUserMuted, mapToUserNameOrId, MESSAGE_ACTIONS, @@ -514,4 +516,40 @@ describe('Message utils', () => { ); }); }); + + describe('isMessageBlocked function', () => { + it('returns true when message.shadowed is true', () => { + const message = + fromPartial < + LocalMessage > + { + shadowed: true, + type: 'regular', + }; + expect(isMessageBlocked(message)).toBe(true); + }); + + it('returns true for moderation remove error messages when not shadowed', () => { + const message = + fromPartial < + LocalMessage > + { + moderation: { action: 'remove' }, + shadowed: false, + type: 'error', + }; + expect(isMessageBlocked(message)).toBe(true); + }); + + it('returns false when message is not shadowed and not a moderation remove error', () => { + const message = + fromPartial < + LocalMessage > + { + shadowed: false, + type: 'regular', + }; + expect(isMessageBlocked(message)).toBe(false); + }); + }); }); diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 5a1974d6be..8b998b79ab 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -497,11 +497,12 @@ export const isMessageBounced = ( message.moderation?.action === 'bounce'); export const isMessageBlocked = ( - message: Pick, + message: Pick, ) => - message.type === 'error' && - (message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_REMOVE' || - message.moderation?.action === 'remove'); + message.shadowed || + (message.type === 'error' && + (message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_REMOVE' || + message.moderation?.action === 'remove')); export const isMessageEdited = (message: Pick) => !!message.message_text_updated_at; diff --git a/yarn.lock b/yarn.lock index 2e1732b2a2..cb29f90a08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11936,10 +11936,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.38.0: - version "9.38.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.38.0.tgz#5c13eb8bbc2fa4adb774687b0c9c51f129d1b458" - integrity sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg== +stream-chat@^9.41.1: + version "9.41.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.41.1.tgz#a877c8aa800d78b497eec2fad636345d4422309c" + integrity sha512-W8zjfINYol2UtdRMz2t/NN2GyjDrvb4pJgKmhtuRYzCY1u0Cjezcsu5OCNgyAM0QsenlY6tRqnvAU8Qam5R49Q== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" From 659d39eccff6c079698b0d021ddfc863e9612d41 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Fri, 17 Apr 2026 15:34:54 +0200 Subject: [PATCH 2/4] chore: fix tests --- package.json | 1 + yarn.lock | 36 ++++++++---------------------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 8121dbc444..9e3e534fb2 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@total-typescript/shoehorn": "^0.1.2", "@types/deep-equal": "^1.0.1", "@types/dotenv": "^8.2.0", "@types/hast": "^2.3.4", diff --git a/yarn.lock b/yarn.lock index cb29f90a08..4f13e4087e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2831,6 +2831,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@total-typescript/shoehorn@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@total-typescript/shoehorn/-/shoehorn-0.1.2.tgz#a0c095ce8cb9b4ae3556bcff42702ddb072e9d18" + integrity sha512-p7nNZbOZIofpDNyP0u1BctFbjxD44Qc+oO5jufgQdFdGIXJLc33QRloJpq7k5T59CTgLWfQSUxsuqLcmeurYRw== + "@tufjs/canonical-json@2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz#a52f61a3d7374833fca945b2549bc30a2dd40d0a" @@ -11984,16 +11989,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12118,7 +12114,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12132,13 +12128,6 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -13227,7 +13216,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -13245,15 +13234,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 4ba971d4f060c31a3ef13bdf5af26cc717ad0265 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Fri, 17 Apr 2026 15:42:54 +0200 Subject: [PATCH 3/4] chore: remove TS tests --- package.json | 1 - .../Message/__tests__/utils.test.js | 38 ------------------- yarn.lock | 5 --- 3 files changed, 44 deletions(-) diff --git a/package.json b/package.json index 9e3e534fb2..8121dbc444 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,6 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", - "@total-typescript/shoehorn": "^0.1.2", "@types/deep-equal": "^1.0.1", "@types/dotenv": "^8.2.0", "@types/hast": "^2.3.4", diff --git a/src/components/Message/__tests__/utils.test.js b/src/components/Message/__tests__/utils.test.js index 01b7bd2f5e..e42898475b 100644 --- a/src/components/Message/__tests__/utils.test.js +++ b/src/components/Message/__tests__/utils.test.js @@ -1,5 +1,4 @@ import { generateMessage, generateReaction, generateUser } from 'mock-builders'; -import { fromPartial } from '@total-typescript/shoehorn'; import { countReactions, getTestClientWithUser, @@ -13,7 +12,6 @@ import { getMessageActions, getNonImageAttachments, getReadByTooltipText, - isMessageBlocked, isUserMuted, mapToUserNameOrId, MESSAGE_ACTIONS, @@ -516,40 +514,4 @@ describe('Message utils', () => { ); }); }); - - describe('isMessageBlocked function', () => { - it('returns true when message.shadowed is true', () => { - const message = - fromPartial < - LocalMessage > - { - shadowed: true, - type: 'regular', - }; - expect(isMessageBlocked(message)).toBe(true); - }); - - it('returns true for moderation remove error messages when not shadowed', () => { - const message = - fromPartial < - LocalMessage > - { - moderation: { action: 'remove' }, - shadowed: false, - type: 'error', - }; - expect(isMessageBlocked(message)).toBe(true); - }); - - it('returns false when message is not shadowed and not a moderation remove error', () => { - const message = - fromPartial < - LocalMessage > - { - shadowed: false, - type: 'regular', - }; - expect(isMessageBlocked(message)).toBe(false); - }); - }); }); diff --git a/yarn.lock b/yarn.lock index 4f13e4087e..4687ef2fd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2831,11 +2831,6 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== -"@total-typescript/shoehorn@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@total-typescript/shoehorn/-/shoehorn-0.1.2.tgz#a0c095ce8cb9b4ae3556bcff42702ddb072e9d18" - integrity sha512-p7nNZbOZIofpDNyP0u1BctFbjxD44Qc+oO5jufgQdFdGIXJLc33QRloJpq7k5T59CTgLWfQSUxsuqLcmeurYRw== - "@tufjs/canonical-json@2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz#a52f61a3d7374833fca945b2549bc30a2dd40d0a" From bd719eeb45e170c23df31eed81cdb86bbdc0c684 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Fri, 17 Apr 2026 16:07:38 +0200 Subject: [PATCH 4/4] chore: update failing tests --- .../__tests__/EditMessageForm.test.js | 31 ++++++++++++++++--- .../__tests__/MessageInput.test.js | 27 ++++++++++++++-- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/components/MessageInput/__tests__/EditMessageForm.test.js b/src/components/MessageInput/__tests__/EditMessageForm.test.js index e2c2daeb84..7626b7185b 100644 --- a/src/components/MessageInput/__tests__/EditMessageForm.test.js +++ b/src/components/MessageInput/__tests__/EditMessageForm.test.js @@ -91,6 +91,27 @@ const fileUploadUrl = 'http://www.getstream.io'; // real url, because ImageAttac const getImage = () => new File(['content'], filename, { type: 'image/png' }); const getFile = (name = filename) => new File(['content'], name, { type: 'text/plain' }); +/** Matches Channel.sendFile / sendImage (uri, name?, contentType?, user?, axiosRequestConfig?). */ +const expectChannelUploadSendFile = (spy, file) => { + expect(spy).toHaveBeenCalledWith( + file, + undefined, + undefined, + undefined, + expect.objectContaining({ onUploadProgress: expect.any(Function) }), + ); +}; + +const expectChannelUploadSendImage = (spy, image) => { + expect(spy).toHaveBeenCalledWith( + image, + undefined, + undefined, + undefined, + expect.objectContaining({ onUploadProgress: expect.any(Function) }), + ); +}; + const sendMessageMock = jest.fn(); const editMock = jest.fn(); const mockAddNotification = jest.fn(); @@ -440,8 +461,8 @@ describe(`EditMessageForm`, () => { }); const filenameTexts = await screen.findAllByTitle(filename); await waitFor(() => { - expect(sendFileSpy).toHaveBeenCalledWith(file); - expect(sendImageSpy).toHaveBeenCalledWith(image); + expectChannelUploadSendFile(sendFileSpy, file); + expectChannelUploadSendImage(sendImageSpy, image); expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument(); expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument(); filenameTexts.forEach((filenameText) => expect(filenameText).toBeInTheDocument()); @@ -512,7 +533,7 @@ describe(`EditMessageForm`, () => { dropFile(file, formElement); }); await waitFor(() => { - expect(sendImageSpy).toHaveBeenCalledWith(file); + expectChannelUploadSendImage(sendImageSpy, file); }); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -579,7 +600,7 @@ describe(`EditMessageForm`, () => { act(() => dropFile(file, formElement)); await waitFor(() => { - expect(sendFileSpy).toHaveBeenCalledWith(file); + expectChannelUploadSendFile(sendFileSpy, file); }); sendFileSpy.mockImplementationOnce(() => Promise.resolve({ file })); @@ -590,7 +611,7 @@ describe(`EditMessageForm`, () => { await waitFor(() => { expect(sendFileSpy).toHaveBeenCalledTimes(2); - expect(sendFileSpy).toHaveBeenCalledWith(file); + expectChannelUploadSendFile(sendFileSpy, file); }); await axeNoViolations(container); }); diff --git a/src/components/MessageInput/__tests__/MessageInput.test.js b/src/components/MessageInput/__tests__/MessageInput.test.js index 0ce72535ec..ddd97cdad9 100644 --- a/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/src/components/MessageInput/__tests__/MessageInput.test.js @@ -79,6 +79,27 @@ const fileUploadUrl = 'http://www.getstream.io'; // real url, because ImageAttac const getImage = () => new File(['content'], filename, { type: 'image/png' }); const getFile = (name = filename) => new File(['content'], name, { type: 'text/plain' }); +/** Matches Channel.sendFile / sendImage (uri, name?, contentType?, user?, axiosRequestConfig?). */ +const expectChannelUploadSendFile = (spy, file) => { + expect(spy).toHaveBeenCalledWith( + file, + undefined, + undefined, + undefined, + expect.objectContaining({ onUploadProgress: expect.any(Function) }), + ); +}; + +const expectChannelUploadSendImage = (spy, image) => { + expect(spy).toHaveBeenCalledWith( + image, + undefined, + undefined, + undefined, + expect.objectContaining({ onUploadProgress: expect.any(Function) }), + ); +}; + const sendMessageMock = jest.fn(); const mockAddNotification = jest.fn(); @@ -411,8 +432,8 @@ describe(`MessageInputFlat`, () => { }); const filenameTexts = await screen.findAllByTitle(filename); await waitFor(() => { - expect(sendFileSpy).toHaveBeenCalledWith(file); - expect(sendImageSpy).toHaveBeenCalledWith(image); + expectChannelUploadSendFile(sendFileSpy, file); + expectChannelUploadSendImage(sendImageSpy, image); expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument(); expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument(); filenameTexts.forEach((filenameText) => expect(filenameText).toBeInTheDocument()); @@ -483,7 +504,7 @@ describe(`MessageInputFlat`, () => { dropFile(file, formElement); }); await waitFor(() => { - expect(sendImageSpy).toHaveBeenCalledWith(file); + expectChannelUploadSendImage(sendImageSpy, file); }); const results = await axe(container); expect(results).toHaveNoViolations();