Progress on DMs responsiveness

Progress on DMs responsiveness
This commit is contained in:
mgabdev 2020-12-03 22:27:09 -05:00
parent 137a36b810
commit f6a7422704
29 changed files with 682 additions and 186 deletions

View File

@ -1 +1 @@
2.6.5
2.6.1

View File

@ -4,7 +4,7 @@ class Api::BaseController < ApplicationController
DEFAULT_STATUSES_LIMIT = 20
DEFAULT_ACCOUNTS_LIMIT = 20
DEFAULT_CHAT_CONVERSATION_LIMIT = 12
DEFAULT_CHAT_CONVERSATION_MESSAGE_LIMIT = 10
DEFAULT_CHAT_CONVERSATION_MESSAGE_LIMIT = 20
include RateLimitHeaders

View File

@ -55,7 +55,7 @@ class Api::V1::ChatConversations::MessagesController < Api::BaseController
end
def insert_pagination_headers
set_pagination_headers(next_path, nil)
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params)
@ -63,10 +63,27 @@ class Api::V1::ChatConversations::MessagesController < Api::BaseController
end
def next_path
if records_continue?
api_v1_chat_conversations_message_url params[:id], pagination_params(max_id: pagination_max_id)
end
end
def prev_path
unless @chats.empty?
api_v1_chat_conversations_message_url params[:id], pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
@chats.last.id
end
def pagination_since_id
@chats.first.id
end
def records_continue?
@chats.size == limit_param(DEFAULT_CHAT_CONVERSATION_MESSAGE_LIMIT)
end
end

View File

@ -72,15 +72,17 @@ export const expandChatMessages = (chatConversationId, params = {}, done = noop)
dispatch(expandChatMessagesRequest(chatConversationId, isLoadingMore))
api(getState).get(`/api/v1/chat_conversations/messages/${chatConversationId}`, { params }).then((response) => {
console.log("response:", response)
api(getState).get(`/api/v1/chat_conversations/messages/${chatConversationId}`, {
params: {
max_id: params.maxId,
since_id: params.sinceId,
}
}).then((response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next')
console.log("next:", next, getLinks(response).refs)
dispatch(importFetchedChatMessages(response.data))
dispatch(expandChatMessagesSuccess(chatConversationId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore))
done()
}).catch((error) => {
console.log("error:", error)
dispatch(expandChatMessagesFail(chatConversationId, error, isLoadingMore))
done()
})

View File

@ -18,6 +18,7 @@ class AvatarGroup extends ImmutablePureComponent {
return (
<div className={[_s.d].join(' ')}>
{
!!accounts &&
accounts.map((account) => {
const isPro = account.get('is_pro')
const alt = `${account.get('display_name')} ${isPro ? '(PRO)' : ''}`.trim()

View File

@ -19,6 +19,7 @@ class BackButton extends React.PureComponent {
handleBackClick = () => {
this.historyBack()
if (!!this.props.onClick) this.props.onClick()
}
render() {

View File

@ -0,0 +1,100 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import ImmutablePropTypes from 'react-immutable-proptypes'
import ImmutablePureComponent from 'react-immutable-pure-component'
import { makeGetChatConversation } from '../../selectors'
import { openPopover } from '../../actions/popover'
import { setChatConversationSelected } from '../../actions/chats'
import { POPOVER_CHAT_CONVERSATION_OPTIONS } from '../../constants'
import Heading from '../heading'
import Button from '../button'
import BackButton from '../back_button'
import Text from '../text'
import AvatarGroup from '../avatar_group'
class ChatNavigationBar extends React.PureComponent {
handleOnOpenChatConversationOptionsPopover = () => {
this.props.onOpenChatConversationOptionsPopover(this.props.chatConversationId, this.optionsBtnRef)
}
handleOnBack = () => {
this.props.onSetChatConversationSelectedEmpty()
}
setOptionsBtnRef = (c) => {
this.optionsBtnRef = c
}
render() {
const { chatConversation } = this.props
const otherAccounts = chatConversation ? chatConversation.get('other_accounts') : null
const nameHTML = !!otherAccounts ? otherAccounts.get(0).get('display_name_html') : ''
return (
<div className={[_s.d, _s.z4, _s.h53PX, _s.w100PC].join(' ')}>
<div className={[_s.d, _s.h53PX, _s.bgNavigation, _s.aiCenter, _s.z3, _s.top0, _s.right0, _s.left0, _s.posFixed].join(' ')} >
<div className={[_s.d, _s.flexRow, _s.saveAreaInsetPT, _s.saveAreaInsetPL, _s.saveAreaInsetPR, _s.w100PC].join(' ')}>
<BackButton
className={[_s.h53PX, _s.pl10, _s.pr10].join(' ')}
iconSize='18px'
onClick={this.handleOnBack}
iconClassName={[_s.mr5, _s.fillNavigation].join(' ')}
/>
<div className={[_s.d, _s.h53PX, _s.flexRow, _s.jcCenter, _s.aiCenter, _s.mrAuto].join(' ')}>
<AvatarGroup accounts={otherAccounts} size={35} noHover />
<Heading size='h1'>
<div className={[_s.dangerousContent, _s.pl10, _s.fs19PX].join(' ')} dangerouslySetInnerHTML={{ __html: nameHTML }} />
</Heading>
</div>
<div className={[_s.d, _s.h53PX, _s.mlAuto, _s.aiCenter, _s.jcCenter, _s.mr15].join(' ')}>
<Button
isNarrow
backgroundColor='tertiary'
color='primary'
onClick={this.handleOnOpenChatConversationOptionsPopover}
className={[_s.px5].join(' ')}
icon='ellipsis'
iconClassName={[_s.cSecondary, _s.px5, _s.py5].join(' ')}
iconSize='15px'
/>
</div>
</div>
</div>
</div>
)
}
}
const mapStateToProps = (state, { chatConversationId }) => ({
chatConversation: makeGetChatConversation()(state, { id: chatConversationId }),
})
const mapDispatchToProps = (dispatch) => ({
onSetChatConversationSelectedEmpty() {
dispatch(setChatConversationSelected(null))
},
onOpenChatConversationOptionsPopover(chatConversationId, targetRef) {
dispatch(openPopover(POPOVER_CHAT_CONVERSATION_OPTIONS, {
chatConversationId,
targetRef,
position: 'bottom',
}))
},
})
ChatNavigationBar.propTypes = {
chatConversation: ImmutablePropTypes.map,
chatConversationId: PropTypes.string.isRequired,
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatNavigationBar)

View File

@ -42,6 +42,10 @@ class ChatMessageDeletePopover extends React.PureComponent {
const mapDispatchToProps = (dispatch) => ({
onDeleteChatMessage(chatMessageId) {
dispatch(deleteChatMessage(chatMessageId))
dispatch(closePopover())
},
onClosePopover() {
dispatch(closePopover())
},
})

View File

@ -4,12 +4,11 @@ import throttle from 'lodash.throttle'
import { List as ImmutableList } from 'immutable'
import IntersectionObserverArticle from './intersection_observer_article'
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'
import { MOUSE_IDLE_DELAY } from '../constants'
import Block from './block'
import ColumnIndicator from './column_indicator'
import LoadMore from './load_more'
const MOUSE_IDLE_DELAY = 300
class ScrollableList extends React.PureComponent {
static contextTypes = {
@ -27,7 +26,7 @@ class ScrollableList extends React.PureComponent {
lastScrollWasSynthetic = false;
scrollToTopOnMouseIdle = false;
setScrollTop = newScrollTop => {
setScrollTop = (newScrollTop) => {
if (this.documentElement.scrollTop !== newScrollTop) {
this.lastScrollWasSynthetic = true;
this.documentElement.scrollTop = newScrollTop;
@ -104,8 +103,6 @@ class ScrollableList extends React.PureComponent {
if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop()
} else if (scrollTop < 100 && this.props.onScrollToBottom) {
this.props.onScrollToBottom()
} else if (this.props.onScroll) {
this.props.onScroll()
}
@ -194,7 +191,6 @@ class ScrollableList extends React.PureComponent {
placeholderComponent: Placeholder,
placeholderCount,
onScrollToTop,
onScrollToBottom,
} = this.props
const childrenCount = React.Children.count(children);
@ -221,16 +217,6 @@ class ScrollableList extends React.PureComponent {
return (
<div onMouseMove={this.handleMouseMove} ref={this.setRef}>
<div role='feed'>
{
(hasMore && onLoadMore && !isLoading) && !!onScrollToBottom &&
<LoadMore onClick={this.handleLoadMore} />
}
{
isLoading && !!onScrollToBottom &&
<ColumnIndicator type='loading' />
}
{
!!this.props.children &&
React.Children.map(this.props.children, (child, index) => (
@ -287,7 +273,6 @@ ScrollableList.propTypes = {
]),
children: PropTypes.node,
onScrollToTop: PropTypes.func,
onScrollToBottom: PropTypes.func,
onScroll: PropTypes.func,
placeholderComponent: PropTypes.node,
placeholderCount: PropTypes.number,

View File

@ -51,7 +51,6 @@ class TimelineInjectionRoot extends React.PureComponent {
handleResize = () => {
const { width } = getWindowDimension()
this.setState({ width })
}

View File

@ -11,6 +11,8 @@ export const BREAKPOINT_MEDIUM = 1160
export const BREAKPOINT_SMALL = 1080
export const BREAKPOINT_EXTRA_SMALL = 992
export const MOUSE_IDLE_DELAY = 300
export const LAZY_LOAD_SCROLL_OFFSET = 50
export const ALLOWED_AROUND_SHORT_CODE = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'

View File

@ -0,0 +1,39 @@
import React from 'react'
import PropTypes from 'prop-types'
import ResponsiveClassesComponent from '../../ui/util/responsive_classes_component'
import ChatConversationsSearch from './chat_conversations_search'
import ChatConversationsList from './chat_conversations_list'
import ChatConversationRequestsListItem from './chat_conversations_requests_list_item'
class ChatApprovedConversationsSidebar extends React.PureComponent {
render() {
const { source } = this.props
return (
<ResponsiveClassesComponent
classNames={[_s.d, _s.w340PX, _s.h100PC, _s.bgPrimary, _s.borderLeft1PX, _s.borderRight1PX, _s.borderColorSecondary].join(' ')}
classNamesSmall={[_s.d, _s.w300PX, _s.h100PC, _s.bgPrimary, _s.borderLeft1PX, _s.borderRight1PX, _s.borderColorSecondary].join(' ')}
classNamesXS={[_s.d, _s.w100PC, _s.h100PC, _s.overflowYScroll, _s.bgPrimary].join(' ')}
>
<div className={[_s.d, _s.h100PC, _s.overflowHidden, _s.w100PC, _s.boxShadowNone].join(' ')}>
<ChatConversationsSearch />
<ResponsiveClassesComponent
classNames={[_s.d, _s.w100PC, _s.posAbs, _s.bottom0, _s.top60PX, _s.overflowYScroll].join(' ')}
classNamesXS={[_s.d, _s.w100PC].join(' ')}
>
<ChatConversationRequestsListItem />
<ChatConversationsList source={source} />
</ResponsiveClassesComponent>
</div>
</ResponsiveClassesComponent>
)
}
}
ChatApprovedConversationsSidebar.propTypes = {
source: PropTypes.string,
}
export default ChatApprovedConversationsSidebar

View File

@ -0,0 +1,61 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { openPopover } from '../../../actions/popover'
import { approveChatConversationRequest } from '../../../actions/chat_conversations'
import {
POPOVER_CHAT_CONVERSATION_OPTIONS
} from '../../../constants'
import Button from '../../../components/button'
import Text from '../../../components/text'
class ChatConversationRequestApproveBar extends React.PureComponent {
handleOnApproveMessageRequest = () => {
this.props.onApproveChatConversationRequest(this.props.chatConversationId)
}
setOptionsBtnRef = (c) => {
this.optionsBtnRef = c
}
render () {
return (
<div className={[_s.d, _s.z4, _s.minH53PX, _s.w100PC].join(' ')}>
<div className={[_s.d, _s.minH53PX, _s.bgNavigation, _s.aiCenter, _s.z3, _s.bottom0, _s.right0, _s.left0, _s.posFixed].join(' ')} >
<div className={[_s.d, _s.w100PC, _s.pt15, _s.px15, _s.aiCenter, _s.jcCenter, _s.saveAreaInsetPB, _s.saveAreaInsetPL, _s.saveAreaInsetPR, _s.w100PC].join(' ')}>
<Button
isNarrow
onClick={this.handleOnApproveMessageRequest}
>
<Text color='inherit' align='center'>
Approve Message Request
</Text>
</Button>
</div>
</div>
</div>
)
}
}
const mapDispatchToProps = (dispatch) => ({
onApproveChatConversationRequest(chatConversationId) {
dispatch(approveChatConversationRequest(chatConversationId))
},
onOpenChatConversationOptionsPopover(chatConversationId, targetRef) {
dispatch(openPopover(POPOVER_CHAT_CONVERSATION_OPTIONS, {
chatConversationId,
targetRef,
position: 'bottom',
}))
},
})
ChatConversationRequestApproveBar.propTypes = {
chatConversationId: PropTypes.string,
onApproveChatConversationRequest: PropTypes.func.isRequired,
}
export default connect(null, mapDispatchToProps)(ChatConversationRequestApproveBar)

View File

@ -28,55 +28,51 @@ class ChatMessagesComposeForm extends React.PureComponent {
}
onBlur = () => {
this.setState({ focused: false });
this.setState({ focused: false })
}
onFocus = () => {
this.setState({ focused: true });
this.setState({ focused: true })
}
onKeyDown = (e) => {
const { disabled } = this.props;
const { disabled } = this.props
if (disabled) {
e.preventDefault();
return;
}
if (disabled) return e.preventDefault()
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
if (e.which === 229 || e.isComposing) return;
if (e.which === 229) return
switch (e.key) {
case 'Escape':
document.querySelector('#gabsocial').focus()
break;
break
case 'Enter':
this.handleOnSendChatMessage()
return e.preventDefault()
case 'Tab':
//
break;
this.sendBtn.focus()
return e.preventDefault()
break
}
// if (e.defaultPrevented || !this.props.onKeyDown) return;
if (e.defaultPrevented) return
}
setTextbox = (c) => {
this.textbox = c
}
setSendBtn = (c) => {
this.sendBtn = c
}
render () {
const { chatConversationId } = this.props
const { isXS, chatConversationId } = this.props
const { value } = this.state
const disabled = false
const textareaContainerClasses = CX({
d: 1,
maxW100PC: 1,
flexGrow1: 1,
jcCenter: 1,
py5: 1,
})
const textareaClasses = CX({
d: 1,
font: 1,
@ -95,9 +91,7 @@ class ChatMessagesComposeForm extends React.PureComponent {
py10: 1,
})
return (
<div className={[_s.d, _s.posAbs, _s.bottom0, _s.left0, _s.right0, _s.flexRow, _s.aiCenter, _s.minH58PX, _s.bgPrimary, _s.w100PC, _s.borderTop1PX, _s.borderColorSecondary, _s.px15].join(' ')}>
<div className={[_s.d, _s.pr15, _s.flexGrow1, _s.py10].join(' ')}>
const textarea = (
<Textarea
id='chat-message-compose-input'
inputRef={this.setTextbox}
@ -112,14 +106,44 @@ class ChatMessagesComposeForm extends React.PureComponent {
onKeyDown={this.onKeyDown}
aria-autocomplete='list'
/>
</div>
<div className={[_s.d, _s.h100PC, _s.aiCenter, _s.jcCenter].join(' ')}>
)
const button = (
<Button
buttonRef={this.setSendBtn}
disabled={disabled}
onClick={this.handleOnSendChatMessage}
>
<Text color='inherit' className={_s.px10}>Send</Text>
</Button>
)
if (isXS) {
return (
<div className={[_s.d, _s.z4, _s.minH58PX, _s.w100PC].join(' ')}>
<div className={[_s.d, _s.minH58PX, _s.bgPrimary, _s.aiCenter, _s.z3, _s.bottom0, _s.right0, _s.left0, _s.posFixed].join(' ')} >
<div className={[_s.d, _s.w100PC, _s.pb5, _s.px15, _s.aiCenter, _s.jcCenter, _s.saveAreaInsetPB, _s.saveAreaInsetPL, _s.saveAreaInsetPR, _s.w100PC].join(' ')}>
<div className={[_s.d, _s.flexRow, _s.aiCenter, _s.minH58PX, _s.w100PC, _s.borderTop1PX, _s.borderColorSecondary, _s.px10].join(' ')}>
<div className={[_s.d, _s.pr15, _s.flexGrow1, _s.py10].join(' ')}>
{textarea}
</div>
<div className={[_s.d, _s.h100PC, _s.aiCenter, _s.jcCenter].join(' ')}>
{button}
</div>
</div>
</div>
</div>
</div>
)
}
return (
<div className={[_s.d, _s.posAbs, _s.bottom0, _s.left0, _s.right0, _s.flexRow, _s.aiCenter, _s.minH58PX, _s.bgPrimary, _s.w100PC, _s.borderTop1PX, _s.borderColorSecondary, _s.px15].join(' ')}>
<div className={[_s.d, _s.pr15, _s.flexGrow1, _s.py10].join(' ')}>
{textarea}
</div>
<div className={[_s.d, _s.h100PC, _s.aiCenter, _s.jcCenter].join(' ')}>
{button}
</div>
</div>
)
@ -135,6 +159,7 @@ const mapDispatchToProps = (dispatch) => ({
ChatMessagesComposeForm.propTypes = {
chatConversationId: PropTypes.string,
isXS: PropTypes.bool,
onSendMessage: PropTypes.func.isRequired,
}

View File

@ -75,7 +75,7 @@ class ChatMessageItem extends ImmutablePureComponent {
const account = chatMessage.get('account')
if (!account) return <div />
const content = { __html: chatMessage.get('text') }
const content = { __html: chatMessage.get('text_html') }
const alt = account.get('id', null) === me
const createdAt = chatMessage.get('created_at')

View File

@ -2,20 +2,25 @@ import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import moment from 'moment-mini'
import throttle from 'lodash.throttle'
import { List as ImmutableList } from 'immutable'
import ImmutablePropTypes from 'react-immutable-proptypes'
import ImmutablePureComponent from 'react-immutable-pure-component'
import { createSelector } from 'reselect'
import debounce from 'lodash.debounce'
import { me } from '../../../initial_state'
import { CX, MOUSE_IDLE_DELAY } from '../../../constants'
import { setChatConversationSelected } from '../../../actions/chats'
import {
expandChatMessages,
scrollBottomChatMessageConversation,
} from '../../../actions/chat_conversation_messages'
import ScrollableList from '../../../components/scrollable_list'
import IntersectionObserverArticle from '../../../components/intersection_observer_article'
import IntersectionObserverWrapper from '../../ui/util/intersection_observer_wrapper'
import ChatMessagePlaceholder from '../../../components/placeholder/chat_message_placeholder'
import ChatMessageItem from './chat_message_item'
import ColumnIndicator from '../../../components/column_indicator'
import LoadMore from '../../../components/load_more'
class ChatMessageScrollingList extends ImmutablePureComponent {
@ -23,6 +28,13 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
isRefreshing: false,
}
intersectionObserverWrapper = new IntersectionObserverWrapper()
mouseIdleTimer = null
mouseMovedRecently = false
lastScrollWasSynthetic = false
scrollToTopOnMouseIdle = false
componentDidMount () {
const { chatConversationId } = this.props
this.props.onExpandChatMessages(chatConversationId)
@ -30,6 +42,8 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
componentWillUnmount() {
this.props.onSetChatConversationSelected(null)
this.detachScrollListener()
this.detachIntersectionObserver()
}
componentWillReceiveProps (nextProps) {
@ -40,20 +54,48 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
}
}
handleLoadMore = (sinceId) => {
const { chatConversationId, dispatch } = this.props
this.props.onExpandChatMessages(chatConversationId, { sinceId })
componentDidUpdate(prevProps, prevState, snapshot) {
// Reset the scroll position when a new child comes in in order not to
// jerk the scrollbar around if you're already scrolled down the page.
if (snapshot !== null && this.scrollContainerRef) {
console.log("snapshot:", snapshot)
this.setScrollTop(this.scrollContainerRef.scrollHeight - snapshot)
}
componentDidUpdate(prevProps, prevState) {
if (this.state.isRefreshing) {
this.setState({ isRefreshing: false })
}
if (prevProps.chatMessageIds.size === 0 && this.props.chatMessageIds.size > 0) {
this.containerNode.scrollTop = this.containerNode.scrollHeight
if (prevProps.chatMessageIds.size === 0 && this.props.chatMessageIds.size > 0 && this.scrollContainerRef) {
this.scrollContainerRef.scrollTop = this.scrollContainerRef.scrollHeight
}
}
attachScrollListener() {
if (!this.scrollContainerRef) return
this.scrollContainerRef.addEventListener('scroll', this.handleScroll)
this.scrollContainerRef.addEventListener('wheel', this.handleWheel)
}
detachScrollListener() {
if (!this.scrollContainerRef) return
this.scrollContainerRef.removeEventListener('scroll', this.handleScroll)
this.scrollContainerRef.removeEventListener('wheel', this.handleWheel)
}
attachIntersectionObserver() {
this.intersectionObserverWrapper.connect()
}
detachIntersectionObserver() {
this.intersectionObserverWrapper.disconnect()
}
onLoadMore = (maxId) => {
const { chatConversationId } = this.props
this.props.onExpandChatMessages(chatConversationId, { maxId })
}
getCurrentChatMessageIndex = (id) => {
// : todo :
return this.props.chatMessageIds.indexOf(id)
@ -69,14 +111,13 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
this._selectChild(elementIndex, false)
}
handleLoadOlder = debounce(() => {
this.handleLoadMore(this.props.chatMessageIds.size > 0 ? this.props.chatMessageIds.last() : undefined)
}, 300, { leading: true })
handleOnReload = debounce(() => {
this.handleLoadMore()
this.setState({ isRefreshing: true })
}, 300, { trailing: true })
setScrollTop = (newScrollTop) => {
if (!this.scrollContainerRef) return
if (this.scrollContainerRef.scrollTop !== newScrollTop) {
this.lastScrollWasSynthetic = true
this.scrollContainerRef.scrollTop = newScrollTop
}
}
_selectChild(index, align_top) {
const container = this.node.node
@ -92,6 +133,83 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
}
}
handleLoadOlder = debounce(() => {
const maxId = this.props.chatMessageIds.size > 0 ? this.props.chatMessageIds.last() : undefined
this.onLoadMore(maxId)
}, 300, { leading: true })
handleScroll = throttle(() => {
if (this.scrollContainerRef) {
const { offsetHeight, scrollTop, scrollHeight } = this.scrollContainerRef
const offset = scrollHeight - scrollTop - offsetHeight
if (scrollTop < 100 && this.props.hasMore && !this.props.isLoading) {
this.handleLoadOlder()
}
if (offset < 100) {
this.props.onScrollToBottom()
}
if (!this.lastScrollWasSynthetic) {
// If the last scroll wasn't caused by setScrollTop(), assume it was
// intentional and cancel any pending scroll reset on mouse idle
this.scrollToTopOnMouseIdle = false
}
this.lastScrollWasSynthetic = false
}
}, 150, {
trailing: true,
})
handleWheel = throttle(() => {
this.scrollToTopOnMouseIdle = false
}, 150, {
trailing: true,
})
clearMouseIdleTimer = () => {
if (this.mouseIdleTimer === null) return
clearTimeout(this.mouseIdleTimer)
this.mouseIdleTimer = null
}
handleMouseMove = throttle(() => {
// As long as the mouse keeps moving, clear and restart the idle timer.
this.clearMouseIdleTimer()
this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY)
// Only set if we just started moving and are scrolled to the top.
if (!this.mouseMovedRecently && this.scrollContainerRef.scrollTop === 0) {
this.scrollToTopOnMouseIdle = true
}
// Save setting this flag for last, so we can do the comparison above.
this.mouseMovedRecently = true
}, MOUSE_IDLE_DELAY / 2)
handleMouseIdle = () => {
if (this.scrollToTopOnMouseIdle) {
this.setScrollTop(0)
}
this.mouseMovedRecently = false
this.scrollToTopOnMouseIdle = false
}
getSnapshotBeforeUpdate(prevProps) {
const someItemInserted = prevProps.chatMessageIds.size > 0 &&
prevProps.chatMessageIds.size < this.props.chatMessageIds.size &&
prevProps.chatMessageIds.get(prevProps.chatMessageIds.size - 1) !== this.props.chatMessageIds.get(this.props.chatMessageIds.size - 1)
if (someItemInserted && (this.scrollContainerRef.scrollTop > 0 || this.mouseMovedRecently)) {
return this.scrollContainerRef.scrollHeight - this.scrollContainerRef.scrollTop
}
return null
}
setRef = (c) => {
this.node = c
}
@ -100,6 +218,15 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
this.containerNode = c
}
setScrollContainerRef = (c) => {
this.scrollContainerRef = c
this.attachScrollListener()
this.attachIntersectionObserver()
// Handle initial scroll posiiton
this.handleScroll()
}
render() {
const {
chatConversationId,
@ -109,13 +236,10 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
hasMore,
onScrollToBottom,
onScroll,
isXS,
} = this.props
const { isRefreshing } = this.state
if (isPartial || (isLoading && chatMessageIds.size === 0)) {
return null
}
let scrollableContent = []
let emptyContent = []
@ -128,8 +252,8 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
<div
key={`chat-message-gap:${(i + 1)}`}
disabled={isLoading}
sinceId={i > 0 ? chatMessageIds.get(i - 1) : null}
onClick={this.handleLoadMore}
maxId={i > 0 ? chatMessageIds.get(i - 1) : null}
onClick={this.handleLoadOlder}
/>
)
} else {
@ -148,23 +272,67 @@ class ChatMessageScrollingList extends ImmutablePureComponent {
}
}
const childrenCount = React.Children.count(scrollableContent)
if (isLoading || childrenCount > 0 || hasMore) {
const containerClasses = CX({
d: 1,
bgPrimary: 1,
boxShadowNone: 1,
posAbs: !isXS,
bottom60PX: !isXS,
left0: !isXS,
right0: !isXS,
top60PX: !isXS,
w100PC: 1,
overflowHidden: 1,
})
return (
<div
className={[_s.d, _s.boxShadowNone, _s.posAbs, _s.bottom60PX, _s.left0, _s.right0, _s.px15, _s.py15, _s.top60PX, _s.w100PC, _s.overflowYScroll].join(' ')}
onMouseMove={this.handleMouseMove}
className={containerClasses}
ref={this.containerRef}
>
<ScrollableList
scrollRef={this.setRef}
onLoadMore={this.handleLoadMore && this.handleLoadOlder}
scrollKey='chat_messages'
hasMore={hasMore}
emptyMessage='No chats found'
onScrollToBottom={onScrollToBottom}
onScroll={onScroll}
isLoading={isLoading}
<div
className={[_s.d, _s.h100PC, _s.w100PC, _s.px15, _s.py15, _s.overflowYScroll].join(' ')}
ref={this.setScrollContainerRef}
>
{scrollableContent}
</ScrollableList>
{
(hasMore && !isLoading) &&
<LoadMore onClick={this.handleLoadOlder} />
}
{
isLoading &&
<ColumnIndicator type='loading' />
}
<div role='feed'>
{
!!scrollableContent &&
scrollableContent.map((child, index) => (
<IntersectionObserverArticle
key={`chat_message:${chatConversationId}:${index}`}
id={`chat_message:${chatConversationId}:${index}`}
index={index}
listLength={childrenCount}
intersectionObserverWrapper={this.intersectionObserverWrapper}
saveHeightKey={`chat_messages:${chatConversationId}:${index}`}
>
{child}
</IntersectionObserverArticle>
))
}
</div>
</div>
</div>
)
}
return (
<div className={[_s.d, _s.boxShadowNone, _s.posAbs, _s.bottom60PX, _s.left0, _s.right0, _s.top60PX, _s.w100PC, _s.overflowHidden].join(' ')}>
<div className={[_s.d, _s.h100PC, _s.w100PC, _s.px15, _s.py15, _s.overflowYScroll].join(' ')}>
<ColumnIndicator type='error' message='No chat messages found' />
</div>
</div>
)
}
@ -207,6 +375,7 @@ ChatMessageScrollingList.propTypes = {
onClearTimeline: PropTypes.func.isRequired,
onScrollToTop: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired,
isXS: PropTypes.bool.isRequired,
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatMessageScrollingList)

View File

@ -0,0 +1,42 @@
import React from 'react'
import PropTypes from 'prop-types'
import ResponsiveClassesComponent from '../../ui/util/responsive_classes_component'
import ChatSettingsHeader from './chat_settings_header'
import List from '../../../components/list'
class ChatSettingsSidebar extends React.PureComponent {
render() {
return (
<ResponsiveClassesComponent
classNames={[_s.d, _s.w340PX, _s.h100PC, _s.bgPrimary, _s.borderLeft1PX, _s.borderRight1PX, _s.borderColorSecondary].join(' ')}
classNamesSmall={[_s.d, _s.w300PX, _s.h100PC, _s.bgPrimary, _s.borderLeft1PX, _s.borderRight1PX, _s.borderColorSecondary].join(' ')}
>
<ChatSettingsHeader />
<List
items={[
{
title: 'Preferences',
to: '/messages/settings',
},
{
title: 'Message Requests',
to: '/messages/requests',
},
{
title: 'Blocked Chats',
to: '/messages/blocks',
},
{
title: 'Muted Chats',
to: '/messages/mutes',
},
]}
/>
</ResponsiveClassesComponent>
)
}
}
export default ChatSettingsSidebar

View File

@ -10,7 +10,7 @@ class Messages extends React.PureComponent {
render () {
const {
account,
isXS,
selectedChatConversationId,
chatConverationIsRequest,
} = this.props
@ -48,6 +48,7 @@ const mapStateToProps = (state, props) => {
}
Messages.propTypes = {
isXS: PropTypes.bool,
selectedChatConversationId: PropTypes.string,
chatConverationIsRequest: PropTypes.bool.isRequired,
}

View File

@ -204,6 +204,7 @@ class SwitchingArea extends React.PureComponent {
<WrappedRoute path='/news/view/:trendsRSSId' page={NewsPage} component={NewsView} content={children} componentParams={{ title: 'News RSS Feed' }} />
<WrappedRoute path='/messages' exact page={MessagesPage} component={Messages} content={children} componentParams={{ source: 'approved' }} />
<WrappedRoute path='/messages/new' exact page={BasicPage} component={ChatConversationCreate} content={children} componentParams={{ title: 'New Message' }} />
<WrappedRoute path='/messages/settings' exact page={MessagesPage} component={MessagesSettings} content={children} componentParams={{ isSettings: true }} />
<WrappedRoute path='/messages/requests' exact page={MessagesPage} component={ChatConversationRequests} content={children} componentParams={{ isSettings: true, source: 'requested' }} />
<WrappedRoute path='/messages/blocks' exact page={MessagesPage} component={ChatConversationBlockedAccounts} content={children} componentParams={{ isSettings: true }} />

View File

@ -23,9 +23,9 @@ export default class IntersectionObserverWrapper {
};
this.observer = new IntersectionObserver(onIntersection, options);
this.observerBacklog.forEach(([ id, node, callback ]) => {
Array.isArray(this.observerBacklog) ? this.observerBacklog.forEach(([ id, node, callback ]) => {
this.observe(id, node, callback);
});
}) : null;
this.observerBacklog = null;
}

View File

@ -8,17 +8,41 @@ import {
BREAKPOINT_EXTRA_SMALL,
MODAL_CHAT_CONVERSATION_CREATE,
} from '../constants'
import { getWindowDimension } from '../utils/is_mobile'
import Layout from './layout'
import Responsive from '../features/ui/util/responsive_component'
import List from '../components/list'
import ResponsiveClassesComponent from '../features/ui/util/responsive_classes_component'
import ChatConversationsSearch from '../features/messages/components/chat_conversations_search'
import ChatConversationsList from '../features/messages/components/chat_conversations_list'
import ChatSettingsHeader from '../features/messages/components/chat_settings_header'
import ChatConversationRequestsListItem from '../features/messages/components/chat_conversations_requests_list_item'
import ChatSettingsSidebar from '../features/messages/components/chat_settings_sidebar'
import ChatApprovedConversationsSidebar from '../features/messages/components/chat_approved_conversations_sidebar'
import FooterBar from '../components/footer_bar'
import DefaultNavigationBar from '../components/navigation_bar/default_navigation_bar'
import ChatNavigationBar from '../components/navigation_bar/chat_navigation_bar_xs'
import ChatMessageScrollingList from '../features/messages/components/chat_message_scrolling_list'
import ChatMessageComposeForm from '../features/messages/components/chat_message_compose_form'
import ChatConversationRequestApproveBar from '../features/messages/components/chat_conversation_request_approve_bar'
const initialState = getWindowDimension()
class MessagesLayout extends React.PureComponent {
state = {
width: initialState.width,
}
componentDidMount() {
this.handleResize()
window.addEventListener('resize', this.handleResize, false)
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize, false)
}
handleResize = () => {
const { width } = getWindowDimension()
this.setState({ width })
}
onClickAdd = () => {
this.props.onOpenChatConversationCreateModal()
}
@ -31,15 +55,54 @@ class MessagesLayout extends React.PureComponent {
showBackBtn,
source,
currentConversationIsRequest,
selectedChatConversationId,
} = this.props
const { width } = this.state
const mainBlockClasses = CX({
d: 1,
w1015PX: 1,
h100PC: 1,
flexRow: 1,
jcEnd: 1,
})
const isXS = width <= BREAKPOINT_EXTRA_SMALL
if (isXS) {
if (!selectedChatConversationId) {
return (
<div className={[_s.d, _s.w100PC, _s.minH100VH, _s.bgTertiary].join(' ')}>
<DefaultNavigationBar
showBackBtn
actions={[
{
icon: 'add',
to: '/messages/new',
}
]}
title={title}
/>
<main role='main' className={[_s.d, _s.w100PC].join(' ')}>
<div className={[_s.d, _s.w100PC, _s.flexRow, _s.pb15].join(' ')}>
{
(isSettings || currentConversationIsRequest) &&
<ChatSettingsSidebar />
}
{
!isSettings && !currentConversationIsRequest &&
<ChatApprovedConversationsSidebar source={source} />
}
</div>
<FooterBar />
</main>
</div>
)
} else {
return (
<div className={[_s.d, _s.w100PC, _s.minH100VH, _s.bgTertiary].join(' ')}>
<ChatNavigationBar chatConversationId={selectedChatConversationId} />
<main role='main' className={[_s.d, _s.w100PC].join(' ')}>
<ChatMessageScrollingList chatConversationId={selectedChatConversationId} isXS={isXS} />
</main>
{ currentConversationIsRequest && <ChatConversationRequestApproveBar /> }
{ !currentConversationIsRequest && <ChatMessageComposeForm chatConversationId={selectedChatConversationId} isXS={isXS} /> }
</div>
)
}
}
return (
<Layout
@ -68,57 +131,17 @@ class MessagesLayout extends React.PureComponent {
classNamesXS={[_s.d, _s.w100PC].join(' ')}
>
<ResponsiveClassesComponent
classNames={mainBlockClasses}
classNamesXS={[_s.d, _s.w100PC, _s.jcEnd].join(' ')}
classNames={[_s.d, _s.w1015PX, _s.h100PC, _s.flexRow, _s.jcEnd].join(' ')}
classNamesXS={[_s.d, _s.w100PC, _s.h100PC, _s.jcEnd].join(' ')}
>
<Responsive min={BREAKPOINT_EXTRA_SMALL}>
<div className={[_s.d, _s.w340PX, _s.h100PC, _s.bgPrimary, _s.borderLeft1PX, _s.borderRight1PX, _s.borderColorSecondary].join(' ')}>
<div className={[_s.d, _s.h100PC, _s.overflowHidden, _s.w100PC, _s.boxShadowNone].join(' ')}>
{
(isSettings || currentConversationIsRequest) &&
<React.Fragment>
<ChatSettingsHeader />
<List
items={[
{
title: 'Preferences',
to: '/messages/settings',
},
{
title: 'Message Requests',
to: '/messages/requests',
},
{
title: 'Blocked Chats',
to: '/messages/blocks',
},
{
title: 'Muted Chats',
to: '/messages/mutes',
},
]}
/>
</React.Fragment>
<ChatSettingsSidebar />
}
{
!isSettings && !currentConversationIsRequest &&
<React.Fragment>
<ChatConversationsSearch />
<div className={[_s.d, _s.w100PC, _s.posAbs, _s.bottom0, _s.top60PX, _s.overflowYScroll].join(' ')}>
<ChatConversationRequestsListItem />
<ChatConversationsList source={source} />
</div>
</React.Fragment>
<ChatApprovedConversationsSidebar source={source} />
}
</div>
</div>
</Responsive>
<div className={[_s.d, _s.flexGrow1, _s.h100PC, _s.bgPrimary, _s.borderColorSecondary, _s.borderRight1PX, _s.z1].join(' ')}>
<div className={[_s.d, _s.w100PC, _s.h100PC].join(' ')}>
{children}
@ -134,10 +157,13 @@ class MessagesLayout extends React.PureComponent {
}
const mapStateToProps = (state) => {
const selectedId = state.getIn(['chats', 'selectedChatConversationId'], null)
const currentConversationIsRequest = selectedId ? !state.getIn(['chat_conversations', selectedId, 'is_approved'], true) : false
const selectedChatConversationId = state.getIn(['chats', 'selectedChatConversationId'], null)
const currentConversationIsRequest = selectedChatConversationId ? !state.getIn(['chat_conversations', selectedChatConversationId, 'is_approved'], true) : false
return { currentConversationIsRequest }
return {
selectedChatConversationId,
currentConversationIsRequest,
}
}
const mapDispatchToProps = (dispatch) => ({

View File

@ -11,7 +11,7 @@ class EntityCache
Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:username, :domain, :url).find_local(username) }
end
def emoji(shortcodes, domain)
def emoji(shortcodes, domain = nil)
shortcodes = [shortcodes] unless shortcodes.is_a?(Array)
cached = Rails.cache.read_multi(*shortcodes.map { |shortcode| to_key(:emoji, shortcode, domain) })
uncached_ids = []

View File

@ -80,6 +80,21 @@ class Formatter
include ActionView::Helpers::TextHelper
def chatMessageText(chatMessage)
raw_content = chatMessage.text
return '' if raw_content.blank?
html = raw_content
html = encode_and_link_urls(html, nil, keep_html: false)
html = reformat(html, true)
html = encode_custom_emojis(html, chatMessage.emojis)
html.html_safe # rubocop:disable Rails/OutputSafety
html
end
def format(status, **options)
if options[:use_markdown]
raw_content = status.markdown

View File

@ -434,7 +434,7 @@ class Account < ApplicationRecord
end
def emojis
@emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
@emojis ||= CustomEmoji.from_text(emojifiable_text)
end
before_create :generate_keys

View File

@ -25,4 +25,9 @@ class ChatMessage < ApplicationRecord
scope :recent, -> { reorder(created_at: :desc) }
def emojis
return @emojis if defined?(@emojis)
@emojis = CustomEmoji.from_text(text)
end
end

View File

@ -54,14 +54,14 @@ class CustomEmoji < ApplicationRecord
end
class << self
def from_text(text, domain)
def from_text(text)
return [] if text.blank?
shortcodes = text.scan(SCAN_RE).map(&:first).uniq
return [] if shortcodes.empty?
EntityCache.instance.emoji(shortcodes, domain)
EntityCache.instance.emoji(shortcodes)
end
def search(shortcode)
@ -72,10 +72,7 @@ class CustomEmoji < ApplicationRecord
private
def remove_entity_cache
Rails.cache.delete(EntityCache.instance.to_key(:emoji, shortcode, domain))
Rails.cache.delete(EntityCache.instance.to_key(:emoji, shortcode,))
end
def downcase_domain
self.domain = domain.downcase unless domain.nil?
end
end

View File

@ -57,7 +57,7 @@ class Poll < ApplicationRecord
end
def emojis
@emojis ||= CustomEmoji.from_text(options.join(' '), account.domain)
@emojis ||= CustomEmoji.from_text(options.join(' '))
end
class Option < ActiveModelSerializers::Model

View File

@ -231,7 +231,7 @@ class Status < ApplicationRecord
fields = [spoiler_text, text]
fields += preloadable_poll.options unless preloadable_poll.nil?
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
@emojis = CustomEmoji.from_text(fields.join(' '))
end
def mark_for_mass_destruction!

View File

@ -1,13 +1,17 @@
# frozen_string_literal: true
class REST::ChatMessageSerializer < ActiveModel::Serializer
attributes :id, :text, :language, :from_account_id,
attributes :id, :text_html, :text, :language, :from_account_id,
:chat_conversation_id, :created_at
def id
object.id.to_s
end
def text_html
Formatter.instance.chatMessageText(object).strip
end
def from_account_id
object.from_account_id.to_s
end