Skip to content

Commit e297ffe

Browse files
authored
feat: support jump to message (#1478)
1 parent f9a0081 commit e297ffe

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+738
-395
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ typings/
6060

6161
# dotenv environment variables file
6262
.env
63+
/.env.local
6364
/.env.development
6465
/.env.production
6566

@@ -70,4 +71,3 @@ client/dist
7071
/.virtualgo
7172
.vscode/
7273
coverage.out
73-
.env.local

docusaurus/docs/React/contexts/channel-action-context.mdx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,59 @@ A function that takes a message to be edited, returns a Promise.
5050
| -------- |
5151
| function |
5252

53+
### jumpToLatestMessage
54+
55+
Used in conjunction with `jumpToMessage`. Restores the position of the message list back to the most recent messages.
56+
57+
| Type |
58+
| --------------------- |
59+
| `() => Promise<void>` |
60+
61+
### jumpToMessage
62+
63+
When called, `jumpToMessage` causes the current message list to jump to the message with the id specified in the `messageId` parameter.
64+
Here's an example of a button, which, when clicked, searches for a given message and navigates to it:
65+
66+
```tsx
67+
const JumpToMessage = () => {
68+
const { jumpToMessage } = useChannelActionContext();
69+
70+
return (
71+
<button
72+
data-testid='jump-to-message'
73+
onClick={async () => {
74+
// the filtering based on channelId is just for example purposes.
75+
const results = await chatClient.search(
76+
{
77+
id: { $eq: channelId },
78+
},
79+
'Message 29',
80+
{ limit: 1, offset: 0 },
81+
);
82+
83+
jumpToMessage(results.results[0].message.id);
84+
}}
85+
>
86+
Jump to message 29
87+
</button>
88+
);
89+
};
90+
91+
// further down the line, add the JumpToMessage to the component tree as a child of `Channel`
92+
// ...
93+
return (<Channel channel={channel}>
94+
<JumpToMessage />
95+
<Window>
96+
<MessageList />
97+
</Window>
98+
</Channel>)
99+
```
100+
101+
102+
| Type |
103+
| ------------------------------------ |
104+
| `(messageId: string) => Promise<void>` |
105+
53106
### loadMore
54107

55108
The function to load next page/batch of `messages` (used for pagination).

docusaurus/docs/React/hooks/message-list-hooks.mdx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,6 @@ title: MessageList Hooks
77
The `MessageList` and `VirtualizedMessageList` components use a variety of custom hooks to assemble a scrollable list of `Message` components, with all of the necessary data and handlers provided to each.
88
These hooks can be useful when building a custom list component.
99

10-
### useCallLoadMore
11-
12-
A [custom hook](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/hooks/useCallLoadMore.ts) that returns a memoized callback of the loadMore function that only changes if the limit paramter changes.
13-
This returned value is then added to the props object given to the `InfiniteScroll` component.
14-
1510
### useEnrichedMessages
1611

1712
A [custom hook](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/hooks/useEnrichedMessages.ts) that determines which messages need date separators and group css styles, returns the processed array of messages.

e2e/fixtures.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,21 @@ dotenv.config({ path: `.env.local` });
3030
});
3131
await channel.create();
3232
await channel.truncate();
33+
let messageToQuote;
3334
for (let i = 0; i < MESSAGES_COUNT; i++) {
3435
if (process.stdout.clearLine && process.stdout.cursorTo) {
3536
printProgress(i / MESSAGES_COUNT);
3637
}
3738

38-
await channel.sendMessage({
39+
const res = await channel.sendMessage({
3940
text: `Message ${i}`,
4041
user: { id: i % 2 ? E2E_TEST_USER_1 : E2E_TEST_USER_2 },
42+
...(i === 140 ? { quoted_message_id: messageToQuote.message.id } : {}),
4143
});
44+
45+
if (i === 20) {
46+
messageToQuote = res;
47+
}
4248
}
4349
process.stdout.write('\n');
4450
}

e2e/jump-to-message.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/* eslint-disable jest/no-done-callback */
2+
/* eslint-disable jest/require-top-level-describe */
3+
import { expect, test } from '@playwright/test';
4+
5+
test.describe('jump to message', () => {
6+
[
7+
['virtualized', 'jump-to-message--jump-in-virtualized-message-list'],
8+
['regular', 'jump-to-message--jump-in-regular-message-list'],
9+
].forEach(([mode, story]) => {
10+
test.beforeEach(async ({ baseURL, page }) => {
11+
await page.goto(`${baseURL}/?story=${story}`);
12+
await page.waitForSelector('[data-storyloaded]');
13+
await page.waitForSelector('text=Message 149');
14+
});
15+
16+
test(`${mode} jumps to message 29 and then back to bottom`, async ({ page }) => {
17+
const message29 = page.locator('text=Message 29');
18+
await expect(message29).not.toBeVisible();
19+
await page.click('data-testid=jump-to-message');
20+
await expect(message29).toBeVisible();
21+
await page.click('text=Latest Messages');
22+
await expect(page.locator('text=Message 149')).toBeVisible();
23+
});
24+
25+
test(`${mode} jumps to quoted message`, async ({ page }) => {
26+
await page.click('.quoted-message :text("Message 20")');
27+
await expect(page.locator('text=Message 20')).toBeVisible();
28+
});
29+
});
30+
31+
test('only the current message set is loaded', async ({ baseURL, page }) => {
32+
await page.goto(`${baseURL}/?story=jump-to-message--jump-in-regular-message-list`);
33+
await page.waitForSelector('[data-storyloaded]');
34+
await page.waitForSelector('text=Message 149');
35+
await page.click('data-testid=jump-to-message');
36+
await page.waitForSelector('text=Message 29');
37+
const listItems = page.locator('.str-chat__ul > li');
38+
await expect(listItems).toHaveCount(26);
39+
});
40+
});

jest.config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ module.exports = {
1111
},
1212
preset: 'ts-jest',
1313
setupFiles: ['core-js'],
14-
testPathIgnorePatterns: ['/node_modules/', '/examples/', '__snapshots__', '/e2e'],
14+
testPathIgnorePatterns: ['/node_modules/', '/examples/', '__snapshots__', '/e2e/'],
1515
testRegex: [
1616
/**
1717
* If you want to test single file, mention it here
@@ -21,8 +21,8 @@ module.exports = {
2121
*/
2222
],
2323
transform: {
24-
'^.+\\.(js|jsx)?$': 'babel-jest',
2524
'\\.(ts|tsx)?$': ['ts-jest'],
25+
'^.+\\.(js|jsx)?$': 'babel-jest',
2626
},
2727
transformIgnorePatterns: [],
2828
verbose: true,

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"peerDependencies": {
6161
"react": "^17.0.0 || ^16.8.0",
6262
"react-dom": "^17.0.0 || ^16.8.0",
63-
"stream-chat": "^6.2.0"
63+
"stream-chat": "^6.4.0"
6464
},
6565
"files": [
6666
"dist",
@@ -160,7 +160,7 @@
160160
"rollup-plugin-visualizer": "^4.2.0",
161161
"semantic-release": "^18.0.0",
162162
"semantic-release-cli": "^5.4.4",
163-
"stream-chat": "^6.2.0",
163+
"stream-chat": "6.4.0",
164164
"style-loader": "^2.0.0",
165165
"ts-jest": "^26.5.1",
166166
"tslib": "2.3.0",

src/@types/vite-env.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/// <reference types="vite/client" />
2+
3+
interface ImportMetaEnv {
4+
readonly E2E_APP_KEY: string;
5+
readonly E2E_APP_SECRET: string;
6+
readonly E2E_TEST_USER_1: string;
7+
readonly E2E_TEST_USER_1_TOKEN: string;
8+
readonly E2E_TEST_USER_2: string;
9+
readonly E2E_TEST_USER_2_TOKEN: string;
10+
readonly E2E_JUMP_TO_MESSAGE_CHANNEL: string;
11+
readonly E2E_ADD_MESSAGE_CHANNEL: string;
12+
}
13+
14+
interface ImportMeta {
15+
readonly env: ImportMetaEnv;
16+
}

src/components/Channel/Channel.tsx

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ export type ChannelProps<
191191
VirtualMessage?: ComponentContextValue<StreamChatGenerics>['VirtualMessage'];
192192
};
193193

194+
const JUMP_MESSAGE_PAGE_SIZE = 25;
195+
194196
const UnMemoizedChannel = <
195197
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
196198
V extends CustomTrigger = CustomTrigger
@@ -449,7 +451,9 @@ const ChannelInner = <
449451
// prevent duplicate loading events...
450452
const oldestMessage = state?.messages?.[0];
451453

452-
if (state.loadingMore || oldestMessage?.status !== 'received') return 0;
454+
if (state.loadingMore || state.loadingMoreNewer || oldestMessage?.status !== 'received') {
455+
return 0;
456+
}
453457

454458
// initial state loads with up to 25 messages, so if less than 25 no need for additional query
455459
if (channel.state.messages.length < 25) {
@@ -480,6 +484,67 @@ const ChannelInner = <
480484
return queryResponse.messages.length;
481485
};
482486

487+
const loadMoreNewer = async (limit = 100) => {
488+
if (!online.current || !window.navigator.onLine) return 0;
489+
490+
const newestMessage = state?.messages?.[state?.messages?.length - 1];
491+
if (state.loadingMore || state.loadingMoreNewer) return 0;
492+
493+
dispatch({ loadingMoreNewer: true, type: 'setLoadingMoreNewer' });
494+
495+
const newestId = newestMessage?.id;
496+
const perPage = limit;
497+
let queryResponse: ChannelAPIResponse<StreamChatGenerics>;
498+
499+
try {
500+
queryResponse = await channel.query({
501+
messages: { id_gt: newestId, limit: perPage },
502+
watchers: { limit: perPage },
503+
});
504+
} catch (e) {
505+
console.warn('message pagination request failed with error', e);
506+
dispatch({ loadingMoreNewer: false, type: 'setLoadingMoreNewer' });
507+
return 0;
508+
}
509+
510+
const hasMoreNewer = channel.state.messages !== channel.state.latestMessages;
511+
512+
dispatch({ hasMoreNewer, messages: channel.state.messages, type: 'loadMoreNewerFinished' });
513+
return queryResponse.messages.length;
514+
};
515+
516+
const jumpToMessage = async (messageId: string) => {
517+
dispatch({ loadingMore: true, type: 'setLoadingMore' });
518+
await channel.state.loadMessageIntoState(messageId);
519+
520+
/**
521+
* if the message we are jumping to has less than half of the page size older messages,
522+
* we have jumped to the beginning of the channel.
523+
*/
524+
const indexOfMessage = channel.state.messages.findIndex((message) => message.id === messageId);
525+
const hasMoreMessages = indexOfMessage >= Math.floor(JUMP_MESSAGE_PAGE_SIZE / 2);
526+
527+
loadMoreFinished(hasMoreMessages, channel.state.messages);
528+
dispatch({
529+
hasMoreNewer: channel.state.messages !== channel.state.latestMessages,
530+
highlightedMessageId: messageId,
531+
type: 'jumpToMessageFinished',
532+
});
533+
534+
setTimeout(() => {
535+
dispatch({ type: 'clearHighlightedMessage' });
536+
}, 500);
537+
};
538+
539+
const jumpToLatestMessage = async () => {
540+
await channel.state.loadMessageIntoState('latest');
541+
const hasMoreOlder = channel.state.messages.length >= 25;
542+
loadMoreFinished(hasMoreOlder, channel.state.messages);
543+
dispatch({
544+
type: 'jumpToLatestMessage',
545+
});
546+
};
547+
483548
const updateMessage = (
484549
updatedMessage: MessageToSend<StreamChatGenerics> | StreamMessage<StreamChatGenerics>,
485550
) => {
@@ -707,7 +772,10 @@ const ChannelInner = <
707772
closeThread,
708773
dispatch,
709774
editMessage,
775+
jumpToLatestMessage,
776+
jumpToMessage,
710777
loadMore,
778+
loadMoreNewer,
711779
loadMoreThread,
712780
onMentionsClick: onMentionsHoverOrClick,
713781
onMentionsHover: onMentionsHoverOrClick,
@@ -719,7 +787,7 @@ const ChannelInner = <
719787
skipMessageDataMemoization,
720788
updateMessage,
721789
}),
722-
[channel.cid, loadMore, quotedMessage],
790+
[channel.cid, loadMore, loadMoreNewer, quotedMessage, jumpToMessage, jumpToLatestMessage],
723791
);
724792

725793
const componentContextValue: ComponentContextValue<StreamChatGenerics> = useMemo(

0 commit comments

Comments
 (0)