Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { DependencyList, forwardRef, UIEventHandler, useLayoutEffect, useRef } from 'react';
import React, { DependencyList, forwardRef, UIEventHandler, useCallback, useLayoutEffect, useRef } from 'react';
import type { BaseMessage } from '@sendbird/chat/message';
import { isAboutSame } from '../../../Channel/context/utils';
import { SCROLL_BUFFER } from '../../../../utils/consts';
Expand Down Expand Up @@ -61,7 +61,7 @@ export const InfiniteList = forwardRef((props: Props, listRef: React.RefObject<H
}
}, [listRef.current, messages.length]);

const handleScroll: UIEventHandler<HTMLDivElement> = async () => {
const handleScroll: UIEventHandler<HTMLDivElement> = useCallback(async () => {
if (!listRef.current) return;
const list = listRef.current;

Expand All @@ -87,7 +87,7 @@ export const InfiniteList = forwardRef((props: Props, listRef: React.RefObject<H
} else {
direction.current = undefined;
}
};
}, [listRef.current, messages.length]);

return (
<div className="sendbird-conversation__scroll-container">
Expand Down
13 changes: 8 additions & 5 deletions src/modules/GroupChannel/context/GroupChannelProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,11 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
scrollRef,
scrollPubSub,
scrollDistanceFromBottomRef,
isScrollBottomReached,
scrollPositionRef,
} = useMessageListScroll(scrollBehavior, [state.currentChannel?.url]);

const { isScrollBottomReached } = state;

// Configuration resolution
const resolvedReplyType = getCaseResolvedReplyType(moduleReplyType ?? config.groupChannel.replyType).upperCase;
const resolvedThreadReplySelectType = getCaseResolvedThreadReplySelectType(
Expand Down Expand Up @@ -143,8 +144,8 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
channels.forEach((it) => markAsReadScheduler.push(it));
}
},
onMessagesReceived: () => {
if (isScrollBottomReached && isContextMenuClosed()) {
onMessagesReceived: (messages) => {
if (isScrollBottomReached && isContextMenuClosed() && messages.length !== state.messages.length) {
setTimeout(() => actions.scrollToBottom(true), 10);
}
},
Expand All @@ -157,7 +158,9 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
onBackClick?.();
},
onChannelUpdated: (channel) => {
actions.setCurrentChannel(channel);
if (!state.currentChannel?.isEqual(channel)) {
actions.setCurrentChannel(channel);
}
},
logger: logger as any,
});
Expand Down Expand Up @@ -196,7 +199,7 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
return () => {
subscriptions.forEach(subscription => subscription.remove());
};
}, [messageDataSource.initialized, state.currentChannel?.url, pubSub?.subscribe]);
}, [messageDataSource.initialized, state.currentChannel?.url]);

// Starting point handling
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ describe('useMessageListScroll', () => {
const { result } = renderHook(() => useMessageListScroll('auto'));

expect(result.current.scrollRef.current).toBe(null);
expect(result.current.isScrollBottomReached).toBe(true);
expect(result.current.scrollDistanceFromBottomRef.current).toBe(0);
expect(result.current.scrollPositionRef.current).toBe(0);
expect(typeof result.current.scrollPubSub.publish).toBe('function');
Expand Down Expand Up @@ -63,7 +62,7 @@ describe('useMessageListScroll', () => {
result.current.scrollPubSub.publish('scrollToBottom', {});
await waitFor(() => {
expect(result.current.scrollDistanceFromBottomRef.current).toBe(0);
expect(result.current.isScrollBottomReached).toBe(true);
expect(mockSetIsScrollBottomReached).toHaveBeenCalledWith(true);
});
});
});
Expand Down
237 changes: 126 additions & 111 deletions src/modules/GroupChannel/context/hooks/useGroupChannel.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just wrapped all handlers with useCallback one by one. No logic changes.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useContext, useMemo } from 'react';
import { useContext, useMemo, useCallback } from 'react';
import type { GroupChannel } from '@sendbird/chat/groupChannel';
import type { SendbirdError } from '@sendbird/chat';
import { useSyncExternalStore } from 'use-sync-external-store/shim';
Expand Down Expand Up @@ -58,24 +58,21 @@ export const useGroupChannel = () => {
const { markAsReadScheduler } = config;
const state: GroupChannelState = useSyncExternalStore(store.subscribe, store.getState);

const flagActions = {
setAnimatedMessageId: (messageId: number | null) => {
store.setState(state => ({ ...state, animatedMessageId: messageId }));
},
const setAnimatedMessageId = useCallback((messageId: number | null) => {
store.setState(state => ({ ...state, animatedMessageId: messageId }));
}, []);

setIsScrollBottomReached: (isReached: boolean) => {
store.setState(state => ({ ...state, isScrollBottomReached: isReached }));
},
};
const setIsScrollBottomReached = useCallback((isReached: boolean) => {
store.setState(state => ({ ...state, isScrollBottomReached: isReached }));
}, []);

const scrollToBottom = async (animated?: boolean) => {
const scrollToBottom = useCallback(async (animated?: boolean) => {
if (!state.scrollRef.current) return;
setAnimatedMessageId(null);
setIsScrollBottomReached(true);

// wait a bit for scroll ref to be updated
await delay();

flagActions.setAnimatedMessageId(null);
flagActions.setIsScrollBottomReached(true);

if (config.isOnline && state.hasNext()) {
await state.resetWithStartingPoint(Number.MAX_SAFE_INTEGER);
}
Expand All @@ -87,109 +84,127 @@ export const useGroupChannel = () => {
markAsReadScheduler.push(state.currentChannel);
}
}
};
}, [state.scrollRef.current, config.isOnline, markAsReadScheduler]);

const scrollToMessage = useCallback(async (
createdAt: number,
messageId: number,
messageFocusAnimated?: boolean,
scrollAnimated?: boolean,
) => {
const element = state.scrollRef.current;
const parentNode = element?.parentNode as HTMLDivElement;
const clickHandler = {
activate() {
if (!element || !parentNode) return;
element.style.pointerEvents = 'auto';
parentNode.style.cursor = 'auto';
},
deactivate() {
if (!element || !parentNode) return;
element.style.pointerEvents = 'none';
parentNode.style.cursor = 'wait';
},
};

clickHandler.deactivate();

setAnimatedMessageId(null);
const message = state.messages.find(
(it) => it.messageId === messageId || it.createdAt === createdAt,
);

if (message) {
const topOffset = getMessageTopOffset(message.createdAt);
if (topOffset) state.scrollPubSub.publish('scroll', { top: topOffset, animated: scrollAnimated });
if (messageFocusAnimated ?? true) setAnimatedMessageId(messageId);
} else {
await state.resetWithStartingPoint(createdAt);
setTimeout(() => {
const topOffset = getMessageTopOffset(createdAt);
if (topOffset) {
state.scrollPubSub.publish('scroll', {
top: topOffset,
lazy: false,
animated: scrollAnimated,
});
}
if (messageFocusAnimated ?? true) setAnimatedMessageId(messageId);
});
}
clickHandler.activate();
}, [setAnimatedMessageId, state.scrollRef.current, state.messages?.map(it => it?.messageId)]);

const toggleReaction = useCallback((message: SendableMessageType, emojiKey: string, isReacted: boolean) => {
if (!state.currentChannel) return;
if (isReacted) {
state.currentChannel.deleteReaction(message, emojiKey)
.catch(error => {
config.logger?.warning('Failed to delete reaction:', error);
});
} else {
state.currentChannel.addReaction(message, emojiKey)
.catch(error => {
config.logger?.warning('Failed to add reaction:', error);
});
}
}, [state.currentChannel?.deleteReaction, state.currentChannel?.addReaction]);

const messageActions = useMessageActions({
...state,
scrollToBottom,
});

const actions: GroupChannelActions = useMemo(() => ({
setCurrentChannel: (channel: GroupChannel) => {
store.setState(state => ({
...state,
currentChannel: channel,
fetchChannelError: null,
quoteMessage: null,
animatedMessageId: null,
nicknamesMap: new Map(
channel.members.map(({ userId, nickname }) => [userId, nickname]),
),
}));
},

handleChannelError: (error: SendbirdError) => {
store.setState(state => ({
...state,
currentChannel: null,
fetchChannelError: error,
quoteMessage: null,
animatedMessageId: null,
}));
},

setQuoteMessage: (message: SendableMessageType | null) => {
store.setState(state => ({ ...state, quoteMessage: message }));
},

const setCurrentChannel = useCallback((channel: GroupChannel) => {
store.setState(state => ({
...state,
currentChannel: channel,
fetchChannelError: null,
quoteMessage: null,
animatedMessageId: null,
nicknamesMap: new Map(
channel.members.map(({ userId, nickname }) => [userId, nickname]),
),
}));
}, []);

const handleChannelError = useCallback((error: SendbirdError) => {
store.setState(state => ({
...state,
currentChannel: null,
fetchChannelError: error,
quoteMessage: null,
animatedMessageId: null,
}));
}, []);

const setQuoteMessage = useCallback((message: SendableMessageType | null) => {
store.setState(state => ({ ...state, quoteMessage: message }));
}, []);

const actions: GroupChannelActions = useMemo(() => {
return {
setCurrentChannel,
handleChannelError,
setQuoteMessage,
scrollToBottom,
scrollToMessage,
toggleReaction,
setAnimatedMessageId,
setIsScrollBottomReached,
...messageActions,
};
}, [
setCurrentChannel,
handleChannelError,
setQuoteMessage,
scrollToBottom,
scrollToMessage: async (
createdAt: number,
messageId: number,
messageFocusAnimated?: boolean,
scrollAnimated?: boolean,
) => {
const element = state.scrollRef.current;
const parentNode = element?.parentNode as HTMLDivElement;
const clickHandler = {
activate() {
if (!element || !parentNode) return;
element.style.pointerEvents = 'auto';
parentNode.style.cursor = 'auto';
},
deactivate() {
if (!element || !parentNode) return;
element.style.pointerEvents = 'none';
parentNode.style.cursor = 'wait';
},
};

clickHandler.deactivate();

flagActions.setAnimatedMessageId(null);
const message = state.messages.find(
(it) => it.messageId === messageId || it.createdAt === createdAt,
);

if (message) {
const topOffset = getMessageTopOffset(message.createdAt);
if (topOffset) state.scrollPubSub.publish('scroll', { top: topOffset, animated: scrollAnimated });
if (messageFocusAnimated ?? true) flagActions.setAnimatedMessageId(messageId);
} else {
await state.resetWithStartingPoint(createdAt);
setTimeout(() => {
const topOffset = getMessageTopOffset(createdAt);
if (topOffset) {
state.scrollPubSub.publish('scroll', {
top: topOffset,
lazy: false,
animated: scrollAnimated,
});
}
if (messageFocusAnimated ?? true) flagActions.setAnimatedMessageId(messageId);
});
}

clickHandler.activate();
},

toggleReaction: (message: SendableMessageType, emojiKey: string, isReacted: boolean) => {
if (!state.currentChannel) return;

if (isReacted) {
state.currentChannel.deleteReaction(message, emojiKey)
.catch(error => {
config.logger?.warning('Failed to delete reaction:', error);
});
} else {
state.currentChannel.addReaction(message, emojiKey)
.catch(error => {
config.logger?.warning('Failed to add reaction:', error);
});
}
},
...flagActions,
...messageActions,
}), [store, state, config.isOnline, markAsReadScheduler]);
scrollToMessage,
toggleReaction,
setAnimatedMessageId,
setIsScrollBottomReached,
messageActions,
]);

return { state, actions };
};
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen

const [scrollPubSub] = useState(() => pubSubFactory<ScrollTopics, ScrollTopicUnion>({ publishSynchronous: true }));
const {
state: { isScrollBottomReached },
actions: { setIsScrollBottomReached },
} = useGroupChannel();

Expand Down Expand Up @@ -99,8 +98,6 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen
return {
scrollRef,
scrollPubSub,
isScrollBottomReached,
setIsScrollBottomReached,
scrollDistanceFromBottomRef,
scrollPositionRef,
};
Expand Down
13 changes: 12 additions & 1 deletion src/utils/storeManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import isEqual from 'lodash/isEqual';

// Referrence: https://github.com/pmndrs/zustand
export type Store<T> = {
getState: () => T;
Expand All @@ -7,7 +9,16 @@ export type Store<T> = {

export function hasStateChanged<T>(prevState: T, updates: Partial<T>): boolean {
return Object.entries(updates).some(([key, value]) => {
return prevState[key as keyof T] !== value;
if (typeof prevState[key as keyof T] === 'function' && typeof value === 'function') {
/**
* Function is not considered as state change. Why?
* Because function is not a value, it's a reference.
* If we consider non-memoized function as state change,
* it will always be true and cause unnecessary re-renders.
*/
return false;
}
return !isEqual(prevState[key as keyof T], value);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed the way of comparing prev <-> next state with isEqual for deeper comparison like useDeepCompareEffect

});
}

Expand Down