Created
September 25, 2021 03:26
-
-
Save ladifire/cf3d05040aca51959a97c663adb0f40c to your computer and use it in GitHub Desktop.
A Messages list component like Facebook Messenger
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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