Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion examples/vite/src/CustomMessageUi/variants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,16 @@ const CustomMessageUiActions = () => {
return (
<div className='custom-message-ui__actions'>
<div className='custom-message-ui__actions-group'>
<button onClick={handlePin} title={message.pinned ? 'Unpin' : 'Pin'}>
<button
onClick={async (event) => {
try {
await handlePin(event);
} catch (error) {
console.error('Failed to update the message pin', error);
}
}}
title={message.pinned ? 'Unpin' : 'Pin'}
>
{message.pinned ? '📍' : '📌'}
</button>
<button onClick={() => handleDelete()} title='Delete'>
Expand Down
188 changes: 188 additions & 0 deletions src/components/Message/hooks/__tests__/usePinHandler.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';

import { usePinHandler } from '../usePinHandler';

import { ChannelActionProvider } from '../../../../context/ChannelActionContext';
import { ChannelStateProvider } from '../../../../context/ChannelStateContext';
import { ChatProvider } from '../../../../context/ChatContext';
import {
generateMessage,
generateUser,
getTestClientWithUser,
mockChannelActionContext,
mockChannelStateContext,
mockChatContext,
} from '../../../../mock-builders';
import type { Channel, LocalMessage, StreamChat } from 'stream-chat';

// `usePinHandler` updates the message optimistically through `updateMessage`, rolls back only
// the pin fields when the request fails, and rethrows so callers (the Pin message action) can
// show an error notification instead of a success one.

const alice = generateUser({ name: 'alice' });
let client: StreamChat;
let channel: Channel;
// Writes to the channel state like `Channel`'s own `updateMessage`, so tests can read it back.
const updateMessage = vi.fn((message: LocalMessage) =>
channel.state.addMessageSorted(message, true),
);
const mouseEventMock = fromPartial<React.BaseSyntheticEvent>({
preventDefault: vi.fn(() => {}),
});

function renderUsePinHandlerHook(message: LocalMessage) {
const wrapper = ({ children }: { children?: React.ReactNode }) => (
<ChatProvider value={mockChatContext({ client })}>
<ChannelActionProvider value={mockChannelActionContext({ updateMessage })}>
<ChannelStateProvider
value={mockChannelStateContext({
channel,
channelCapabilities: { 'pin-message': true },
})}
>
{children}
</ChannelStateProvider>
</ChannelActionProvider>
</ChatProvider>
);

const { result } = renderHook(() => usePinHandler(message), { wrapper });
return result.current;
}

// Adds a message to the channel state and returns it as stored, like the message a
// `Message` component renders with.
const seedMessage = (overrides: Parameters<typeof generateMessage>[0]) => {
const message = generateMessage(overrides);
channel.state.addMessageSorted(message, true);
return getStoredMessage(message.id)!;
};

const getStoredMessage = (id: string) => channel.state.findMessage(id);

// Keeps the pin/unpin request pending; the returned function rejects it.
const mockPendingRequest = (method: 'pinMessage' | 'unpinMessage') => {
let rejectRequest: (error: Error) => void = () => {};
vi.spyOn(client, method).mockReturnValue(
new Promise((_, reject) => {
rejectRequest = reject;
}),
);
return rejectRequest;
};

// Starts `handlePin` without awaiting it, so assertions can run while the request is pending.
const startHandlePin = (handlePin: ReturnType<typeof usePinHandler>['handlePin']) => {
let pending: ReturnType<typeof handlePin> = undefined;
act(() => {
pending = handlePin(mouseEventMock);
});
return pending;
};

describe('usePinHandler custom hook', () => {
beforeAll(async () => {
client = await getTestClientWithUser(alice);
channel = client.channel('messaging', 'pin-handler-test');
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it.each([
{ method: 'pinMessage', pinned: false },
{ method: 'unpinMessage', pinned: true },
] as const)(
'should update the message optimistically when $method succeeds',
async ({ method, pinned }) => {
const requestSpy = vi.spyOn(client, method).mockResolvedValue(fromPartial({}));
const message = seedMessage({ pinned });
const { handlePin } = renderUsePinHandlerHook(message);
await handlePin(mouseEventMock);
expect(requestSpy).toHaveBeenCalledWith(message);
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({ pinned: !pinned }),
);
},
);

it.each([
{ method: 'pinMessage', pinned: false },
{ method: 'unpinMessage', pinned: true },
] as const)(
'should roll back and throw when $method fails',
async ({ method, pinned }) => {
const rejectRequest = mockPendingRequest(method);
const message = seedMessage({ pinned });
const { handlePin } = renderUsePinHandlerHook(message);
const pending = startHandlePin(handlePin);
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({ pinned: !pinned }),
);
rejectRequest(new Error('request failed'));
await expect(pending).rejects.toThrow('request failed');
expect(getStoredMessage(message.id)).toEqual(expect.objectContaining({ pinned }));
},
);

it('should keep updates that arrived while the request was pending when rolling back', async () => {
const rejectRequest = mockPendingRequest('pinMessage');
const message = seedMessage({ pinned: false, text: 'original' });
const { handlePin } = renderUsePinHandlerHook(message);
const pending = startHandlePin(handlePin);
channel.state.addMessageSorted(
{ ...getStoredMessage(message.id)!, text: 'edited while pinning' },
true,
);
rejectRequest(new Error('pin failed'));
await expect(pending).rejects.toThrow('pin failed');
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({ pinned: false, text: 'edited while pinning' }),
);
});

it('should keep a pin state that changed while the request was pending', async () => {
const rejectRequest = mockPendingRequest('unpinMessage');
const newerPinner = generateUser();
const message = seedMessage({ pinned: true, pinned_by: generateUser() });
const { handlePin } = renderUsePinHandlerHook(message);
const pending = startHandlePin(handlePin);
channel.state.addMessageSorted(
{ ...getStoredMessage(message.id)!, pinned: true, pinned_by: newerPinner },
true,
);
rejectRequest(new Error('unpin failed'));
await expect(pending).rejects.toThrow('unpin failed');
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 = new Date(Date.now() + 60_000);
const message = seedMessage({ pinned: false });
const { handlePin } = renderUsePinHandlerHook(message);
const pending = startHandlePin(handlePin);
channel.state.addMessageSorted(
{
...getStoredMessage(message.id)!,
pinned: true,
pinned_at: newerPinnedAt,
pinned_by: newerPinner,
},
true,
);
rejectRequest(new Error('pin failed'));
await expect(pending).rejects.toThrow('pin failed');
expect(getStoredMessage(message.id)).toEqual(
expect.objectContaining({ pinned: true, pinned_by: newerPinner }),
);
expect(getStoredMessage(message.id)?.pinned_at?.getTime()).toBe(
newerPinnedAt.getTime(),
);
});
});
75 changes: 55 additions & 20 deletions src/components/Message/hooks/usePinHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,83 @@ import { useChatContext } from '../../../context/ChatContext';
import type { LocalMessage } from 'stream-chat';
import type { ReactEventHandler } from '../types';

// `pinned_at` is a `Date` here, and a copy of the message from an event carries its own instance,
// so pins are compared by time rather than by reference.
const getPinnedAtTime = (pinnedAt: LocalMessage['pinned_at']) =>
pinnedAt ? new Date(pinnedAt).getTime() : undefined;

export const usePinHandler = (message: LocalMessage) => {
const { updateMessage } = useChannelActionContext('usePinHandler');
const { channelCapabilities = {} } = useChannelStateContext('usePinHandler');
const { channel, channelCapabilities = {} } = useChannelStateContext('usePinHandler');
const { client } = useChatContext('usePinHandler');

const canPin = !!channelCapabilities['pin-message'];

// The stored message, which may be newer than the `message` this hook rendered with.
const getLatestMessage = () =>
channel.state.findMessage(message.id, message.parent_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 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 = channel.state.findMessage(message.id, message.parent_id);
if (!latestMessage) return;

const isOptimisticPinStored =
latestMessage.pinned === optimisticMessage.pinned &&
getPinnedAtTime(latestMessage.pinned_at) ===
getPinnedAtTime(optimisticMessage.pinned_at) &&
latestMessage.pinned_by?.id === optimisticMessage.pinned_by?.id;
if (!isOptimisticPinStored) return;

updateMessage({
...latestMessage,
pin_expires: message.pin_expires,
pinned: message.pinned,
pinned_at: message.pinned_at,
pinned_by: message.pinned_by,
});
};

const handlePin: ReactEventHandler = async (event) => {
event.preventDefault();

if (!message) return;

if (!message.pinned) {
try {
const optimisticMessage: LocalMessage = {
...message,
pinned: true,
pinned_at: new Date(),
pinned_by: client.user,
};
const optimisticMessage: LocalMessage = {
...getLatestMessage(),
pinned: true,
pinned_at: new Date(),
pinned_by: client.user,
};

try {
updateMessage(optimisticMessage);

await client.pinMessage(message);
} catch (e) {
updateMessage(message);
} catch (error) {
rollBackPin(optimisticMessage);
throw error;
}
} else {
try {
const optimisticMessage = {
...message,
pin_expires: null,
pinned: false,
pinned_at: null,
pinned_by: null,
};
const optimisticMessage: LocalMessage = {
...getLatestMessage(),
pin_expires: null,
pinned: false,
pinned_at: null,
pinned_by: null,
};

try {
updateMessage(optimisticMessage);

await client.unpinMessage(message);
} catch (e) {
updateMessage(message);
} catch (error) {
rollBackPin(optimisticMessage);
throw error;
}
}
};
Expand Down
2 changes: 1 addition & 1 deletion src/context/MessageContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export type MessageContextValue = {
handleMute: ReactEventHandler;
/** Function to open a Thread on a Message */
handleOpenThread: ReactEventHandler;
/** Function to pin a Message in a Channel */
/** Function to pin or unpin a Message in a Channel. Rejects with the request's error when it fails, after rolling back. */
handlePin: ReactEventHandler;
/** Function to post a reaction on a Message */
handleReaction: (
Expand Down
Loading