Skip to content

Instantly share code, notes, and snippets.

@ladifire
Created September 25, 2021 03:26
Show Gist options
  • Save ladifire/cf3d05040aca51959a97c663adb0f40c to your computer and use it in GitHub Desktop.
Save ladifire/cf3d05040aca51959a97c663adb0f40c to your computer and use it in GitHub Desktop.
A Messages list component like Facebook Messenger
/**
* Copyright (c) Ladifire, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import * as React from 'react';
import fbt from 'fbt';
import {forEach} from "lodash";
import {ConversationType, FanPage, Message, FacebookUser} from "@chot.sale/conversations-js";
import {useVisibilityObserver} from '@ladifire-ui-react/observer-intersection';
import {focusScopeQueries} from '@ladifire-ui-react/focus-manager';
import {TetraButton} from '@ladifire-ui-react/tetra-button';
import {BaseHeadingContextWrapper} from '@ladifire-ui-react/tetra-text';
import {CometProgressRingIndeterminate} from '@ladifire-ui-react/progress-ring';
import {bs_curry, CometHeroHoldTrigger} from '@ladifire-ui-react/utils';
import {CometErrorProjectContext} from "@ladifire-ui-react/errorguard";
import stylex from '@ladifire-opensource/stylex';
import {MWChatDateBreak} from 'src/components/MWChatDateBreak';
import {MWChatIncomingGroup} from 'src/components/MWChatIncomingGroup';
import {MWChatOutgoingGroup} from 'src/components/MWChatOutgoingGroup';
import {LSTypingIndicators} from 'src/components/LSTypingIndicators';
import {MWChatMessageListTabbableRow} from 'src/components/MWChatMessageListTabbableRow';
import {MWChatUnreadIndicator} from 'src/components/MWChatUnreadIndicator';
import {MWChatAdminItem} from 'src/components/MWChatAdminItem';
import {MWChatMessageTableFocusTable} from 'src/components/MWChatReactionsAction/MWChatMessageTableFocusTable';
import {MWChatConversationScroller_DEPRECATED} from 'src/components/MWChatConversationScroller_DEPRECATED';
import EventEmitter from "src/utilities/event_emitter";
import {EventTypes} from "src/utilities/constants";
import {PageInitializingBanner} from "src/components/PageInitializingBanner";
import {SelectedPagesContext} from 'src/components/SelectedPagesProvider';
import {VisitorPostNotice} from 'src/components/VisitorPostNotice';
import {UserProfile} from "src/store/types/users";
import {MWChatFocusComposerContext} from "../MWChatFocusComposerContext";
import {Post} from './Post';
const styles = stylex.create({
spacer: {
flexBasis: 0,
flexGrow: 1
},
mask: {
backgroundColor: "transparent"
},
separator: {
marginBottom: 12,
marginTop: 12,
marginLeft: "auto",
marginRight: "auto",
height: 1,
width: "calc(100% - 24px)",
backgroundColor: "var(--lf-divider-on-wash)"
},
spinner: {
display: "flex",
alignItems: "center",
justifyContent: "center",
paddingTop: 12,
paddingBottom: 12,
// minHeight: "100%"
},
spinnerPlaceholder: {
height: 24,
width: "100%",
backgroundColor: "var(--messenger-card-background)"
},
moreButton: {
display: 'grid',
},
emptySpacer: {
height: 30,
},
});
const PAGE_SIZE = 30;
interface Props {
displayType?: 'normal' | 'user';
siteUrl?: string;
schemaAuth: string;
emojiSize: number;
/**
* The type of conversation: message or comment
* */
type: ConversationType | undefined;
/**
* Indicate whether user has read conversation
* */
readWatermark?: number;
page: FanPage | undefined;
/**
* Id of conversation
* */
conversationId: string;
conversationRootCommentId?: string;
isVisitorPost?: boolean;
pageId?: string;
postId?: string;
/**
* An array of conversation ids
* */
keys: string[];
requestConversation: (pageId: string, id: string, limit: number, offset: number) => void;
requestMoreConversation: (pageId: string, id: string, limit: number, offset: number) => void;
isLoading: boolean;
isLoadingOlder: boolean;
reachedStart?: boolean;
reachedEnd?: boolean;
rows?: Message[];
from?: string;
messageDispatch?: (data: any) => void;
facebookUser?: FacebookUser;
typingUsers?: UserProfile[];
maybeMarkSeen?: () => void;
onReply: (message: Message) => void;
onEnsureScrollToBottom?: (isAtBottom?: boolean) => void;
}
const compareCreatedTime = (direction: number) => (a: any, b: any) => {
return (a.created_time < b.created_time) ? -1 * direction : ((a.created_time > b.created_timedate) ? 1 * direction : 0);
};
const compareMessageId = (direction: number) => (a: any, b: any) => {
return a.id.localeCompare(b.id)*direction;
};
const compareSystemMessage = (direction: number) => (a: any, b: any) => {
if ( a.is_system_message === b.is_system_message ) {
return 0;
}
if ( a.is_system_message ) {
return -1 * direction;
}
return 1 * direction;
};
const createSort = ( comparers = [] ) => ( a: any, b: any ) =>
comparers.reduce( ( result: any, compareFn: any ) => ( result === 0 ? compareFn( a, b ) : result ), 0 );
const orderMessagesByCreatedTime = (messages: Message[], readWatermark?: number) => {
let sorted_messages = messages.map((m) => {
const timeMilliseconds = (new Date(m.created_time)).getTime();
return {
...m,
created_time: timeMilliseconds,
}
});
return sorted_messages.sort(createSort([compareCreatedTime(1), compareSystemMessage(1)]));
};
const groupMessagesBySender = (messages: any) => {
const grouped = messages.reduce(
( { user_id, group, groups }, message ) => {
const author = message && message.from ? (message.is_system_message ? 'SYSTEM' : message.from) : null;
if ( user_id !== author ) {
return {
user_id: author,
group: [ message ],
groups: group ? groups.concat( [ group ] ) : groups,
};
}
// it's the same user so group it together
return { user_id, group: group.concat( [ message ] ), groups };
},
{ groups: [] }
);
return grouped.groups.concat( [ grouped.group ] );
};
const isSameDay = ( d1: any, d2: any ) => {
return (
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
);
};
const groupMessagesByDate = (messages: any) => {
const grouped = messages.reduce(
( { group, groups, created_time }, message ) => {
if ( ! isSameDay( new Date( created_time ), new Date( message.created_time ) ) ) {
return {
created_time: message.created_time,
group: [ message ],
groups: group ? groups.concat( [ group ] ) : groups,
};
}
return { created_time, group: group.concat( [ message ] ), groups };
},
{ groups: [] }
);
return grouped.groups.concat( [ grouped.group ] );
};
export const MessageListV2 = (props: Props) => {
const {
displayType = 'normal',
reachedStart,
reachedEnd,
requestConversation,
requestMoreConversation,
isLoading,
isLoadingOlder,
rows,
conversationId,
readWatermark,
from,
pageId,
postId,
schemaAuth,
messageDispatch,
facebookUser,
page,
type: conversationType,
typingUsers,
maybeMarkSeen = () => {},
onReply,
isVisitorPost,
conversationRootCommentId,
onEnsureScrollToBottom,
} = props;
const isScrolledToBottomRef = React.useRef(0);
const scrollerRef = React.useRef<any>(null);
const selectedPagesContext = React.useContext(SelectedPagesContext);
const e = React.useContext(MWChatFocusComposerContext.context);
const m = e.focusComposer;
const f = React.useCallback(() => {
if (m) {
return bs_curry._1(m, undefined);
}
}, [m, conversationId]);
React.useEffect(() => {
EventEmitter.addListener(
EventTypes.REPLY_CONVERSATION,
handleReplyConversation
);
return () => {
EventEmitter.addListener(
EventTypes.REPLY_CONVERSATION,
handleReplyConversation
);
}
}, []);
const handleReplyConversation = () => {
if (scrollerRef && scrollerRef.current) {
scrollerRef.current.scrollToBottom();
}
};
let items: any[] = [];
React.useEffect(() => {
if (conversationId) {
items = [];
getItems(true).then(() => {
scrollToBottom();
f();
});
}
}, [conversationId]);
const sortedByCreatedTimeMessages = React.useMemo(() => {
if (rows && rows.length > 0) {
return orderMessagesByCreatedTime(rows, readWatermark);
}
return [];
}, [rows]);
if (rows && rows.length > 0) {
let _closestItem: Message | undefined;
if (conversationType === 'message') {
const _closestItems = readWatermark && sortedByCreatedTimeMessages.filter(
a => a.from === pageId && a.created_time <= readWatermark
);
_closestItem = _closestItems && _closestItems.length ? _closestItems[_closestItems.length - 1] : undefined;
}
let _sortedWithMeta = sortedByCreatedTimeMessages.map((m) => {
const _shouldShowDelivered = m.delivered && (!readWatermark || readWatermark < m.created_time);
return {
...m,
should_show_delivered: _shouldShowDelivered,
should_show_sent: !_shouldShowDelivered && (/*!m.pending_message_id && */m.sent && (!readWatermark || readWatermark < m.created_time)),
should_show_sending: m.pending_message_id && !m.sent,
}
});
const groups = groupMessagesByDate(_sortedWithMeta);
let _tmpItems: any = [];
if (conversationType === 'comment') {
// always render post first
_tmpItems.push({
type: 'post',
});
}
if (conversationType === 'comment' && !reachedStart) {
// push load more button
_tmpItems.push({
type: 'loadmore',
});
}
_tmpItems.push({
type: 'spacer'
});
forEach( groups, group => {
if (conversationType === 'message') {
const date = new Date( group[0].created_time ).getTime();
_tmpItems.push({
type: 'dayDividerLabel',
id: date,
key: date + '_group',
});
}
const messagesBySender = groupMessagesBySender(group);
forEach(messagesBySender, messages => {
const _isIncoming = messages[0].from !== pageId;
const senderDivider =
messages[0].type !== 'read_watermark'
? messages[0].from + '_' + messages[0].id
: null;
_tmpItems.push( {
type: _isIncoming ? 'incomingGroup' : 'outgoingGroup',
id: senderDivider,
key: senderDivider,
data: messages,
pid: pageId,
uid: messages[0].from,
timestamp: messages[0].created_time,
readWatermarkId: _closestItem && _closestItem.id,
readWatermark: readWatermark,
} );
if (messages[0].type === 'read_watermark') {
_tmpItems.push( {
type: 'read_watermark',
id: 'read_watermark',
key: 'read_watermark',
data: messages[0],
} );
}
} );
});
items = _tmpItems;
}
if (!rows || !rows.length) {
if (conversationType === 'comment' && isVisitorPost) {
// always render post first
items = [
{
type: 'post',
},
{
type: 'visitor_post_notice',
}
];
}
}
const getItems = async (resetState = false) => {
if (isLoading || isLoadingOlder || reachedStart || !pageId) {
return;
}
if (!rows || rows.length === 0 || resetState) {
await requestConversation(pageId, conversationId, PAGE_SIZE, 0);
} else {
await requestMoreConversation(pageId, conversationId, PAGE_SIZE, rows.length);
}
f();
};
const loadMoreComments = React.useCallback(() => {
getItems();
}, [reachedStart, getItems]);
const handleScrollDispatch = (a: number) => {
switch (a) {
case 0:
break;
case 1:
maybeMarkSeen();
break;
case 2:
if (conversationType === 'message') {
getItems();
}
break;
}
};
const renderItem = (item: any, index: number) => {
switch (item.type) {
case 'post':
return (
<Post
pageId={pageId}
postId={postId}
/>
);
case 'visitor_post_notice':
return (
<VisitorPostNotice postId={postId}/>
);
case 'loadmore':
return renderLoadMoreComments();
case 'spacer':
return (
<div className={stylex(styles.emptySpacer)}/>
);
case 'dayDividerLabel':
return (
<MWChatMessageListTabbableRow.make>
<MWChatDateBreak.make
timestamp={item.id}
/>
</MWChatMessageListTabbableRow.make>
);
case 'incomingGroup':
return (
<BaseHeadingContextWrapper>
<MWChatIncomingGroup.make
displayType={displayType}
from={from}
conversationType={conversationType}
conversationRootCommentId={conversationRootCommentId}
messages={item.data}
uid={item.uid}
pid={item.pid}
timestamp={item.timestamp}
schemaAuth={schemaAuth}
dispatch={messageDispatch}
facebookUser={facebookUser}
page={page}
onReply={onReply}
totalMessagesCount={items ? items.length : 0}
latestMessageId={sortedByCreatedTimeMessages && sortedByCreatedTimeMessages[sortedByCreatedTimeMessages.length - 1].id}
/>
</BaseHeadingContextWrapper>
);
case 'outgoingGroup':
return (
<BaseHeadingContextWrapper>
<MWChatOutgoingGroup.make
displayType={displayType}
from={from}
conversationType={conversationType}
messages={item.data}
uid={item.uid}
pid={item.pid}
timestamp={item.timestamp}
lastTimestamp={item.lastTimestamp}
readWatermark={item.readWatermark}
readWatermarkId={item.readWatermarkId}
schemaAuth={schemaAuth}
dispatch={messageDispatch}
page={page}
conversationRootCommentId={conversationRootCommentId}
totalMessagesCount={items ? items.length : 0}
latestMessageId={sortedByCreatedTimeMessages && sortedByCreatedTimeMessages[sortedByCreatedTimeMessages.length - 1].id}
/>
</BaseHeadingContextWrapper>
);
case 'unread':
return (
<MWChatMessageListTabbableRow.make>
<MWChatUnreadIndicator.make
unreadCount={4}
dispatch={() => {}}
/>
</MWChatMessageListTabbableRow.make>
);
case 'system':
return (
<MWChatMessageListTabbableRow.make>
<MWChatAdminItem.make text={item.text}/>
</MWChatMessageListTabbableRow.make>
);
default:
return (
<div style={{height: 100}}>
{item.message}
{`item___${item.key}`}
</div>
);
}
};
const renderTypingUsers = () => {
if (!typingUsers || !typingUsers.length) {
return null;
}
const _typingData = typingUsers.map(t => {
return {
a: t.id,
b: `/api/v1/users/${t.id}/image`,
h: t.first_name + " " + t.last_name,
};
});
return (
<LSTypingIndicators.make
typingContacts={_typingData}
/>
)
};
const ensureScrollToBottom = (a) => {
if (isScrolledToBottomRef.current !== 0) {
if (typeof onEnsureScrollToBottom === 'function') {
onEnsureScrollToBottom(true);
}
return;
}
a = scrollerRef.current;
if (!(a == null)) {
if (typeof onEnsureScrollToBottom === 'function') {
onEnsureScrollToBottom();
}
return a.scrollToBottom();
}
};
const renderLoadMoreComments = () => {
return (
<div className={stylex(styles.spinner, styles.moreButton)}>
<TetraButton
type='secondary'
label={isLoading ? fbt('Đang tải thêm...', 'ss') : fbt('Tải thêm bình luận', 'ss')}
onPress={loadMoreComments}
disabled={isLoading || isLoadingOlder}
addOnPrimary={isLoading && (
<CometProgressRingIndeterminate color="disabled" size={16}/>
)}
/>
</div>
);
};
const scrollToBottom = () => {
if (scrollerRef && scrollerRef.current) {
scrollerRef.current.scrollToBottom();
}
};
return (
<MWChatConversationScroller_DEPRECATED.make
dispatch={handleScrollDispatch}
hasMoreAfter={!reachedEnd}
isScrolledToBottomRef={isScrolledToBottomRef}
ref={scrollerRef}
>
<PageInitializingBanner
pageId={pageId}
selectedPageIds={selectedPagesContext && selectedPagesContext.pageIds}
/>
{!reachedStart && conversationType === 'message' && <LoadingMoreSpinner/>}
{
!(isLoading) && (
<MWChatMessageTableFocusTable.keyCommands>
<MWChatMessageTableFocusTable.Table_.Table.make
tabScopeQuery={focusScopeQueries.tabbableScopeQuery}
wrapX={true}
wrapY={false}
allowModifiers={true}
>
<CometErrorProjectContext.Provider value='messages_list_v2'>
<BaseHeadingContextWrapper>
<div
data-visualcompletion="ignore-dynamic"
role="grid"
aria-label={fbt("Hội thoại", "ss")}
onLoadedData={(a) => {
return bs_curry._1(ensureScrollToBottom, undefined);
}}
onLoad={(a) => {
return bs_curry._1(ensureScrollToBottom, undefined);
}}
onTransitionEnd={(event: any) => {
event.stopPropagation()
}}
>
{items && items.map(renderItem)}
</div>
</BaseHeadingContextWrapper>
</CometErrorProjectContext.Provider>
</MWChatMessageTableFocusTable.Table_.Table.make>
</MWChatMessageTableFocusTable.keyCommands>
)
}
{renderTypingUsers()}
</MWChatConversationScroller_DEPRECATED.make>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment