From 45e8fb62a10b500ce45d468da6655a3ea5075631 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 11:59:13 +0100 Subject: [PATCH 1/6] fix(Message): show error notification when pinning or unpinning fails --- .../hooks/__tests__/usePinHandler.test.tsx | 122 ++++++++++++++++++ src/components/Message/hooks/usePinHandler.ts | 2 + 2 files changed, 124 insertions(+) create mode 100644 src/components/Message/hooks/__tests__/usePinHandler.test.tsx diff --git a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx new file mode 100644 index 0000000000..48bebf4ce9 --- /dev/null +++ b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { act, renderHook, type RenderHookResult } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat'; + +import { usePinHandler } from '../usePinHandler'; +import { + generateChannel, + generateMessage, + generateUser, + getOrCreateChannelApi, + getTestClientWithUser, + useMockedApis, +} from '../../../../mock-builders'; +import { Channel } from '../../../Channel'; +import { Chat } from '../../../Chat'; + +// `usePinHandler` updates the message optimistically through `messagePaginator.ingestItem`, +// rolls it back when the request fails, and rethrows so callers (the Pin message action) can +// show an error notification instead of a success one. + +let channel: ChannelType; +let client: StreamChat; +const mouseEventMock = fromPartial({ + preventDefault: vi.fn(() => {}), +}); + +async function renderUsePinHandler( + message: LocalMessage, + notifications?: Parameters[1], +) { + const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + + ); + let rendered: RenderHookResult, unknown>; + await act(async () => { + rendered = await renderHook(() => usePinHandler(message, notifications), { + wrapper, + }); + }); + + return rendered!.result.current; +} + +describe('usePinHandler custom hook', () => { + beforeAll(async () => { + client = await getTestClientWithUser(generateUser()); + const channelData = generateChannel(); + useMockedApis(client, [getOrCreateChannelApi(channelData)]); + channel = client.channel('messaging', channelData.channel.id); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should pin the message and update it optimistically', async () => { + const pinSpy = vi.spyOn(client, 'pinMessage').mockResolvedValue(fromPartial({})); + const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); + const message = generateMessage({ pinned: false }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + await act(async () => { + await handlePin(mouseEventMock); + }); + expect(pinSpy).toHaveBeenCalledWith(message); + expect(ingestSpy).toHaveBeenCalledTimes(1); + expect(ingestSpy).toHaveBeenCalledWith(expect.objectContaining({ pinned: true })); + }); + + it('should roll back and throw when pinning fails', async () => { + vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); + const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); + const message = generateMessage({ pinned: false }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + await act(async () => { + await expect(handlePin(mouseEventMock)).rejects.toThrow('pin failed'); + }); + expect(ingestSpy).toHaveBeenCalledTimes(2); + expect(ingestSpy).toHaveBeenLastCalledWith(message); + }); + + it('should unpin the message and update it optimistically', async () => { + const unpinSpy = vi.spyOn(client, 'unpinMessage').mockResolvedValue(fromPartial({})); + const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); + const message = generateMessage({ pinned: true }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + await act(async () => { + await handlePin(mouseEventMock); + }); + expect(unpinSpy).toHaveBeenCalledWith(message); + expect(ingestSpy).toHaveBeenCalledTimes(1); + expect(ingestSpy).toHaveBeenCalledWith(expect.objectContaining({ pinned: false })); + }); + + it('should roll back and throw when unpinning fails', async () => { + vi.spyOn(client, 'unpinMessage').mockRejectedValue(new Error('unpin failed')); + const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); + const message = generateMessage({ pinned: true }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + await act(async () => { + await expect(handlePin(mouseEventMock)).rejects.toThrow('unpin failed'); + }); + expect(ingestSpy).toHaveBeenCalledTimes(2); + expect(ingestSpy).toHaveBeenLastCalledWith(message); + }); + + it('should notify and throw when pinning fails with notifications configured', async () => { + vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); + const notify = vi.fn(); + const message = generateMessage({ pinned: false }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message, { + getErrorNotification: () => 'custom pin error', + notify, + }); + await act(async () => { + await expect(handlePin(mouseEventMock)).rejects.toThrow('pin failed'); + }); + expect(notify).toHaveBeenCalledWith('custom pin error', 'error'); + }); +}); diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index ff8bce29e7..f56d417dbd 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -78,6 +78,7 @@ export const usePinHandler = ( 'error', ); messagePaginator.ingestItem(message); + throw e; } } else { try { @@ -103,6 +104,7 @@ export const usePinHandler = ( 'error', ); messagePaginator.ingestItem(message); + throw e; } } }; From 72e7798825c5ef357c615a60ec36bf4397a265c1 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 12:10:21 +0100 Subject: [PATCH 2/6] test(Message): assert paginator state in usePinHandler tests --- .../hooks/__tests__/usePinHandler.test.tsx | 67 +++++++++++++------ 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx index 48bebf4ce9..f31a21620f 100644 --- a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx @@ -25,6 +25,11 @@ const mouseEventMock = fromPartial({ preventDefault: vi.fn(() => {}), }); +// Reads the message back from the channel's paginator, so assertions see the stored state +// rather than only the calls made to it. +const getHeldMessage = (id: string) => + channel.messagePaginator.items?.find((item) => item.id === id); + async function renderUsePinHandler( message: LocalMessage, notifications?: Parameters[1], @@ -58,58 +63,78 @@ describe('usePinHandler custom hook', () => { it('should pin the message and update it optimistically', async () => { const pinSpy = vi.spyOn(client, 'pinMessage').mockResolvedValue(fromPartial({})); - const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); - const message = generateMessage({ pinned: false }) as LocalMessage; + const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message); await act(async () => { await handlePin(mouseEventMock); }); expect(pinSpy).toHaveBeenCalledWith(message); - expect(ingestSpy).toHaveBeenCalledTimes(1); - expect(ingestSpy).toHaveBeenCalledWith(expect.objectContaining({ pinned: true })); + expect(getHeldMessage(message.id)).toEqual(expect.objectContaining({ pinned: true })); }); it('should roll back and throw when pinning fails', async () => { - vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); - const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); - const message = generateMessage({ pinned: false }) as LocalMessage; + let rejectRequest: (error: Error) => void = () => {}; + vi.spyOn(client, 'pinMessage').mockReturnValue( + new Promise((_, reject) => { + rejectRequest = reject; + }), + ); + const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message); + let pending: ReturnType; + act(() => { + pending = handlePin(mouseEventMock); + }); + expect(getHeldMessage(message.id)).toEqual(expect.objectContaining({ pinned: true })); await act(async () => { - await expect(handlePin(mouseEventMock)).rejects.toThrow('pin failed'); + rejectRequest(new Error('pin failed')); + await expect(pending).rejects.toThrow('pin failed'); }); - expect(ingestSpy).toHaveBeenCalledTimes(2); - expect(ingestSpy).toHaveBeenLastCalledWith(message); + expect(getHeldMessage(message.id)).toEqual( + expect.objectContaining({ pinned: false }), + ); }); it('should unpin the message and update it optimistically', async () => { const unpinSpy = vi.spyOn(client, 'unpinMessage').mockResolvedValue(fromPartial({})); - const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); - const message = generateMessage({ pinned: true }) as LocalMessage; + const message = generateMessage({ cid: channel.cid, pinned: true }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message); await act(async () => { await handlePin(mouseEventMock); }); expect(unpinSpy).toHaveBeenCalledWith(message); - expect(ingestSpy).toHaveBeenCalledTimes(1); - expect(ingestSpy).toHaveBeenCalledWith(expect.objectContaining({ pinned: false })); + expect(getHeldMessage(message.id)).toEqual( + expect.objectContaining({ pinned: false }), + ); }); it('should roll back and throw when unpinning fails', async () => { - vi.spyOn(client, 'unpinMessage').mockRejectedValue(new Error('unpin failed')); - const ingestSpy = vi.spyOn(channel.messagePaginator, 'ingestItem'); - const message = generateMessage({ pinned: true }) as LocalMessage; + let rejectRequest: (error: Error) => void = () => {}; + vi.spyOn(client, 'unpinMessage').mockReturnValue( + new Promise((_, reject) => { + rejectRequest = reject; + }), + ); + const message = generateMessage({ cid: channel.cid, pinned: true }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message); + let pending: ReturnType; + act(() => { + pending = handlePin(mouseEventMock); + }); + expect(getHeldMessage(message.id)).toEqual( + expect.objectContaining({ pinned: false }), + ); await act(async () => { - await expect(handlePin(mouseEventMock)).rejects.toThrow('unpin failed'); + rejectRequest(new Error('unpin failed')); + await expect(pending).rejects.toThrow('unpin failed'); }); - expect(ingestSpy).toHaveBeenCalledTimes(2); - expect(ingestSpy).toHaveBeenLastCalledWith(message); + expect(getHeldMessage(message.id)).toEqual(expect.objectContaining({ pinned: true })); }); it('should notify and throw when pinning fails with notifications configured', async () => { vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); const notify = vi.fn(); - const message = generateMessage({ pinned: false }) as LocalMessage; + const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage; const { handlePin } = await renderUsePinHandler(message, { getErrorNotification: () => 'custom pin error', notify, From dd0f13f632296260d006d327447c99b17b1cd14d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 12:19:59 +0100 Subject: [PATCH 3/6] fix(Message): keep concurrent updates when rolling back a failed pin --- .../vite/src/CustomMessageUi/variants.tsx | 11 +- .../hooks/__tests__/usePinHandler.test.tsx | 166 ++++++++++++------ src/components/Message/hooks/usePinHandler.ts | 57 ++++-- src/context/MessageContext.tsx | 1 + 4 files changed, 160 insertions(+), 75 deletions(-) diff --git a/examples/vite/src/CustomMessageUi/variants.tsx b/examples/vite/src/CustomMessageUi/variants.tsx index 64b9bbf898..bfc01324d6 100644 --- a/examples/vite/src/CustomMessageUi/variants.tsx +++ b/examples/vite/src/CustomMessageUi/variants.tsx @@ -247,7 +247,16 @@ const CustomMessageUiActions = () => { return (
- - - diff --git a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx index 25efba9fa3..01b9e1cfba 100644 --- a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx @@ -59,6 +59,19 @@ describe('useDeleteHandler custom hook', () => { expect(typeof handleDelete).toBe('function'); }); + it('should throw without deleting when the message has no id', async () => { + const deleteSpy = vi.spyOn(channel.messageOperations, 'delete'); + const handleDelete = await renderUseDeleteHandler( + generateMessage({ id: undefined }) as typeof testMessage, + ); + await act(async () => { + await expect(handleDelete()).rejects.toThrow( + 'useDeleteHandler was called but it is missing one or more necessary parameters.', + ); + }); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + it('should delete a message without options via the channel local-update path', async () => { const deleteSpy = vi .spyOn(channel.messageOperations, 'delete') diff --git a/src/components/Message/hooks/__tests__/useFlagHandler.test.tsx b/src/components/Message/hooks/__tests__/useFlagHandler.test.tsx index 2e3edab5cf..5547055f4c 100644 --- a/src/components/Message/hooks/__tests__/useFlagHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useFlagHandler.test.tsx @@ -50,11 +50,13 @@ describe('useHandleFlag custom hook', () => { expect(typeof handleFlag).toBe('function'); }); - it('should throw a warning when there are missing parameters and the handler is called', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementationOnce(() => null); + it('should throw when there are missing parameters and the handler is called', async () => { + const flagSpy = vi.spyOn(client.moderation, 'flagMessage'); const handleFlag = await renderUseHandleFlagHook(undefined); - await handleFlag(mouseEventMock); - expect(consoleWarnSpy).toHaveBeenCalledWith(missingUseFlagHandlerParameterWarning); + await expect(handleFlag(mouseEventMock)).rejects.toThrow( + missingUseFlagHandlerParameterWarning, + ); + expect(flagSpy).not.toHaveBeenCalled(); }); it('should allow to flag a message when it is successful', async () => { diff --git a/src/components/Message/hooks/__tests__/useMuteHandler.test.tsx b/src/components/Message/hooks/__tests__/useMuteHandler.test.tsx index 1a2b04e1a6..128f60ee2d 100644 --- a/src/components/Message/hooks/__tests__/useMuteHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useMuteHandler.test.tsx @@ -55,11 +55,13 @@ describe('useHandleMute custom hook', () => { expect(typeof handleMute).toBe('function'); }); - it('should throw a warning when there are missing parameters and the handler is called', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementationOnce(() => null); + it('should throw when there are missing parameters and the handler is called', async () => { const handleMute = await renderUseHandleMuteHook(undefined); - await handleMute(mouseEventMock); - expect(consoleWarnSpy).toHaveBeenCalledWith(missingUseMuteHandlerParamsWarning); + await expect(handleMute(mouseEventMock)).rejects.toThrow( + missingUseMuteHandlerParamsWarning, + ); + expect(mute).not.toHaveBeenCalled(); + expect(unmute).not.toHaveBeenCalled(); }); it('should allow to mute a user when it is successful', async () => { diff --git a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx index 5d33e66a12..8c6469fb08 100644 --- a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx @@ -167,6 +167,17 @@ describe('usePinHandler custom hook', () => { expect(getStoredMessage(message.id)?.pinned_by).toEqual(newerPinner); }); + it('should throw without pinning when the message is missing', async () => { + const pinSpy = vi.spyOn(client, 'pinMessage'); + const { handlePin } = await renderUsePinHandler(undefined as unknown as LocalMessage); + await act(async () => { + await expect(handlePin(mouseEventMock)).rejects.toThrow( + 'usePinHandler was called but it is missing the message.', + ); + }); + expect(pinSpy).not.toHaveBeenCalled(); + }); + it('should notify and throw when pinning fails with notifications configured', async () => { vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); const notify = vi.fn(); diff --git a/src/components/Message/hooks/__tests__/userMarkUnreadHandler.test.tsx b/src/components/Message/hooks/__tests__/userMarkUnreadHandler.test.tsx index 6ecaefcdfd..330024e97d 100644 --- a/src/components/Message/hooks/__tests__/userMarkUnreadHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/userMarkUnreadHandler.test.tsx @@ -10,9 +10,8 @@ import { generateMessage, initClientWithChannels } from '../../../../mock-builde import { Channel } from '../../../Channel'; import { Chat } from '../../../Chat'; -vi.spyOn(console, 'warn').mockImplementation(() => null); - const event = fromPartial({ preventDefault: vi.fn() }); +const missingMessageIdError = 'Mark unread handler does not have access to message id'; const message = generateMessage() as unknown as LocalMessage; let channel: ChannelType; @@ -47,22 +46,22 @@ describe('useMarkUnreadHandler', () => { vi.restoreAllMocks(); }); - it('does not call channel.markUnread if no message is provided', async () => { + it('throws without calling channel.markUnread if no message is provided', async () => { const markUnreadSpy = vi.spyOn(channel, 'markUnread'); const handleMarkUnread = renderUseMarkUnreadHandlerHook(); await act(async () => { - await handleMarkUnread(event); + await expect(handleMarkUnread(event)).rejects.toThrow(missingMessageIdError); }); expect(markUnreadSpy).not.toHaveBeenCalled(); }); - it('does not call channel.markUnread if message is missing id', async () => { + it('throws without calling channel.markUnread if message is missing id', async () => { const markUnreadSpy = vi.spyOn(channel, 'markUnread'); const handleMarkUnread = renderUseMarkUnreadHandlerHook({ message: generateMessage({ id: undefined }) as unknown as LocalMessage, }); await act(async () => { - await handleMarkUnread(event); + await expect(handleMarkUnread(event)).rejects.toThrow(missingMessageIdError); }); expect(markUnreadSpy).not.toHaveBeenCalled(); }); diff --git a/src/components/Message/hooks/useDeleteHandler.ts b/src/components/Message/hooks/useDeleteHandler.ts index 1101645bac..17f8b7c109 100644 --- a/src/components/Message/hooks/useDeleteHandler.ts +++ b/src/components/Message/hooks/useDeleteHandler.ts @@ -27,7 +27,9 @@ export const useDeleteHandler = ( return async (options?: DeleteMessageOptions) => { if (!message?.id || !client) { - return; + throw new Error( + 'useDeleteHandler was called but it is missing one or more necessary parameters.', + ); } try { diff --git a/src/components/Message/hooks/useFlagHandler.ts b/src/components/Message/hooks/useFlagHandler.ts index 5bd54a4dd0..cd07db975b 100644 --- a/src/components/Message/hooks/useFlagHandler.ts +++ b/src/components/Message/hooks/useFlagHandler.ts @@ -15,8 +15,7 @@ export const useFlagHandler = (message?: LocalMessage): ReactEventHandler => { event.preventDefault(); if (!client || !t || !message?.id) { - console.warn(missingUseFlagHandlerParameterWarning); - return; + throw new Error(missingUseFlagHandlerParameterWarning); } await client.moderation.flagMessage({ message_id: message.id }); diff --git a/src/components/Message/hooks/useMarkUnreadHandler.ts b/src/components/Message/hooks/useMarkUnreadHandler.ts index b9ea49bf75..3a1bf69bd0 100644 --- a/src/components/Message/hooks/useMarkUnreadHandler.ts +++ b/src/components/Message/hooks/useMarkUnreadHandler.ts @@ -21,8 +21,7 @@ export const useMarkUnreadHandler = ( return async (event) => { event.preventDefault(); if (!message?.id) { - console.warn('Mark unread handler does not have access to message id'); - return; + throw new Error('Mark unread handler does not have access to message id'); } // Request failures propagate so the caller can report them; `notify` only reports success. diff --git a/src/components/Message/hooks/useMuteHandler.ts b/src/components/Message/hooks/useMuteHandler.ts index 9d86aa7785..026c8945c2 100644 --- a/src/components/Message/hooks/useMuteHandler.ts +++ b/src/components/Message/hooks/useMuteHandler.ts @@ -30,8 +30,7 @@ export const useMuteHandler = ( // The action runs even without `notify` (`Message` passes none); it only reports the outcome. if (!message?.user || !client) { - console.warn(missingUseMuteHandlerParamsWarning); - return; + throw new Error(missingUseMuteHandlerParamsWarning); } const mutes = client.mutedUsersStore.getLatestValue().mutedUsers ?? []; diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index b22bfde932..68aee7f630 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -72,7 +72,9 @@ export const usePinHandler = ( const handlePin: ReactEventHandler = async (event) => { event.preventDefault(); - if (!message) return; + if (!message) { + throw new Error('usePinHandler was called but it is missing the message.'); + } if (!message.pinned) { const optimisticMessage: LocalMessage = { From 8ee1084c6474147c626809cbcd2e8e0addfbebc3 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 14:27:34 +0100 Subject: [PATCH 6/6] fix(Message): skip pin rollback when a newer pin arrived --- .../hooks/__tests__/usePinHandler.test.tsx | 29 +++++++++++++++++++ src/components/Message/hooks/usePinHandler.ts | 11 +++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx index 5d33e66a12..2d61bd2425 100644 --- a/src/components/Message/hooks/__tests__/usePinHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/usePinHandler.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { act, renderHook, type RenderHookResult } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; +import { nowNs } from 'stream-chat'; import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat'; import { usePinHandler } from '../usePinHandler'; @@ -167,6 +168,34 @@ describe('usePinHandler custom hook', () => { expect(getStoredMessage(message.id)?.pinned_by).toEqual(newerPinner); }); + it('should keep a newer pin with the same pinned value that arrived while pinning', async () => { + const rejectRequest = mockPendingRequest('pinMessage'); + const newerPinner = generateUser(); + const newerPinnedAt = nowNs(); + const message = generateMessage({ cid: channel.cid, pinned: false }) as LocalMessage; + const { handlePin } = await renderUsePinHandler(message); + const pending = startHandlePin(handlePin); + act(() => { + channel.messagePaginator.ingestItem({ + ...getStoredMessage(message.id)!, + pinned: true, + pinned_at: newerPinnedAt, + pinned_by: newerPinner, + }); + }); + await act(async () => { + rejectRequest(new Error('pin failed')); + await expect(pending).rejects.toThrow('pin failed'); + }); + expect(getStoredMessage(message.id)).toEqual( + expect.objectContaining({ + pinned: true, + pinned_at: newerPinnedAt, + pinned_by: newerPinner, + }), + ); + }); + it('should notify and throw when pinning fails with notifications configured', async () => { vi.spyOn(client, 'pinMessage').mockRejectedValue(new Error('pin failed')); const notify = vi.fn(); diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index b22bfde932..8e86712b5e 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -54,11 +54,16 @@ export const usePinHandler = ( const getLatestMessage = () => messagePaginator.getItem(message.id) ?? message; // Restores only the pin fields from before the click, so updates that arrived while the request - // was in flight (an edit, a reaction) are kept. Skipped when the pin state already changed - // again, since that change is newer than the snapshot. + // was in flight (an edit, a reaction) are kept. Skipped unless the stored pin is still the + // optimistic one: a pin or unpin that arrived meanwhile is newer than the snapshot, even when + // `pinned` has the same value. const rollBackPin = (optimisticMessage: LocalMessage) => { const latestMessage = messagePaginator.getItem(message.id); - if (!latestMessage || latestMessage.pinned !== optimisticMessage.pinned) return; + const isOptimisticPinStored = + latestMessage?.pinned === optimisticMessage.pinned && + latestMessage.pinned_at === optimisticMessage.pinned_at && + latestMessage.pinned_by?.id === optimisticMessage.pinned_by?.id; + if (!latestMessage || !isOptimisticPinStored) return; messagePaginator.ingestItem({ ...latestMessage,