improve marking message as read with hooks

pull/1804/head
Audric Ackermann 4 years ago
parent 3dc11b923d
commit 3f065a7b0e
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { getTimerBucketIcon } from '../../util/timer'; import { getTimerBucketIcon } from '../../util/timer';
import { useInterval } from '../../hooks/useInterval'; import { useInterval } from '../../hooks/useInterval';
@ -45,12 +45,12 @@ export const ExpireTimer = (props: Props) => {
const initialTimeLeft = Math.max(Math.round((expirationTimestamp - Date.now()) / 1000), 0); const initialTimeLeft = Math.max(Math.round((expirationTimestamp - Date.now()) / 1000), 0);
const [timeLeft, setTimeLeft] = useState(initialTimeLeft); const [timeLeft, setTimeLeft] = useState(initialTimeLeft);
const update = () => { const update = useCallback(() => {
const newTimeLeft = Math.max(Math.round((expirationTimestamp - Date.now()) / 1000), 0); const newTimeLeft = Math.max(Math.round((expirationTimestamp - Date.now()) / 1000), 0);
if (newTimeLeft !== timeLeft) { if (newTimeLeft !== timeLeft) {
setTimeLeft(newTimeLeft); setTimeLeft(newTimeLeft);
} }
}; }, [expirationTimestamp, timeLeft, setTimeLeft]);
const updateFrequency = 500; const updateFrequency = 500;

@ -39,31 +39,20 @@ import { getMessageById } from '../../data/data';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { StateType } from '../../state/reducer'; import { StateType } from '../../state/reducer';
import { import {
areMoreMessagesBeingFetched,
getLoadedMessagesLength,
getMostRecentMessageId,
getOldestMessageId,
getQuotedMessageToAnimate, getQuotedMessageToAnimate,
getSelectedConversationKey,
getSelectedMessageIds, getSelectedMessageIds,
haveDoneFirstScroll,
} from '../../state/selectors/conversations'; } from '../../state/selectors/conversations';
import { import {
fetchMessagesForConversation,
markConversationFullyRead,
messageExpired, messageExpired,
showLightBox, showLightBox,
showScrollToBottomButton,
toggleSelectedMessageId, toggleSelectedMessageId,
} from '../../state/ducks/conversations'; } from '../../state/ducks/conversations';
import { saveAttachmentToDisk } from '../../util/attachmentsUtil'; import { saveAttachmentToDisk } from '../../util/attachmentsUtil';
import { LightBoxOptions } from '../session/conversation/SessionConversation'; import { LightBoxOptions } from '../session/conversation/SessionConversation';
import { MessageContextMenu } from './MessageContextMenu'; import { MessageContextMenu } from './MessageContextMenu';
import { ReadableMessage } from './ReadableMessage'; import { ReadableMessage } from './ReadableMessage';
import { isElectronWindowFocused } from '../../session/utils/WindowUtils';
import { getConversationController } from '../../session/conversations'; import { getConversationController } from '../../session/conversations';
import { MessageMetadata } from './message/MessageMetadata'; import { MessageMetadata } from './message/MessageMetadata';
import { Constants } from '../../session';
// Same as MIN_WIDTH in ImageGrid.tsx // Same as MIN_WIDTH in ImageGrid.tsx
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
@ -80,12 +69,6 @@ const EXPIRED_DELAY = 600;
type Props = MessageRenderingProps & { type Props = MessageRenderingProps & {
selectedMessages: Array<string>; selectedMessages: Array<string>;
quotedMessageToAnimate: string | undefined; quotedMessageToAnimate: string | undefined;
mostRecentMessageId: string | undefined;
oldestMessageId: string | undefined;
areMoreMessagesBeingFetched: boolean;
loadedMessagesLength: number;
selectedConversationKey: string | undefined;
haveDoneFirstScroll: boolean;
}; };
function attachmentIsAttachmentTypeWithPath(attac: any): attac is AttachmentTypeWithPath { function attachmentIsAttachmentTypeWithPath(attac: any): attac is AttachmentTypeWithPath {
@ -147,7 +130,6 @@ class MessageInner extends React.PureComponent<Props, State> {
imageBroken: false, imageBroken: false,
}; };
this.ctxMenuID = `ctx-menu-message-${uuid()}`; this.ctxMenuID = `ctx-menu-message-${uuid()}`;
this.loadMoreMessages = _.debounce(this.loadMoreMessages, 100);
} }
public componentDidMount() { public componentDidMount() {
@ -175,7 +157,7 @@ class MessageInner extends React.PureComponent<Props, State> {
} }
} }
public componentDidUpdate() { public componentDidUpdate(prevProps: Props) {
this.checkExpired(); this.checkExpired();
} }
@ -617,9 +599,9 @@ class MessageInner extends React.PureComponent<Props, State> {
direction, direction,
id: messageId, id: messageId,
conversationType, conversationType,
areMoreMessagesBeingFetched: fetchingMore,
isUnread,
selectedMessages, selectedMessages,
receivedAt,
isUnread,
} = this.props; } = this.props;
const { expired, expiring } = this.state; const { expired, expiring } = this.state;
@ -632,8 +614,6 @@ class MessageInner extends React.PureComponent<Props, State> {
const width = this.getWidth(); const width = this.getWidth();
const isShowingImage = this.isShowingImage(); const isShowingImage = this.isShowingImage();
const isIncoming = direction === 'incoming';
const shouldMarkReadWhenVisible = isIncoming && isUnread;
const divClasses = ['session-message-wrapper']; const divClasses = ['session-message-wrapper'];
if (selected) { if (selected) {
@ -648,52 +628,16 @@ class MessageInner extends React.PureComponent<Props, State> {
divClasses.push('flash-green-once'); divClasses.push('flash-green-once');
} }
const onVisible = async (inView: boolean | Object) => { const isIncoming = direction === 'incoming';
// when the view first loads, it needs to scroll to the unread messages.
// we need to disable the inview on the first loading
if (!this.props.haveDoneFirstScroll) {
if (inView === true) {
window.log.info('onVisible but waiting for first scroll event');
}
return;
}
// we are the bottom message
if (this.props.mostRecentMessageId === messageId && isElectronWindowFocused()) {
if (inView === true) {
window.inboxStore?.dispatch(showScrollToBottomButton(false));
void getConversationController()
.get(this.props.selectedConversationKey as string)
?.markRead(this.props.receivedAt || 0)
.then(() => {
window.inboxStore?.dispatch(
markConversationFullyRead(this.props.selectedConversationKey as string)
);
});
} else if (inView === false) {
window.inboxStore?.dispatch(showScrollToBottomButton(true));
}
}
if (inView === true && this.props.oldestMessageId === messageId && !fetchingMore) {
this.loadMoreMessages();
}
if (inView === true && shouldMarkReadWhenVisible && isElectronWindowFocused()) {
const found = await getMessageById(messageId);
if (found && Boolean(found.get('unread'))) {
// mark the message as read.
// this will trigger the expire timer.
void found.markRead(Date.now());
}
}
};
return ( return (
<ReadableMessage <ReadableMessage
messageId={messageId} messageId={messageId}
className={classNames(divClasses)} className={classNames(divClasses)}
onChange={onVisible}
onContextMenu={this.handleContextMenu} onContextMenu={this.handleContextMenu}
receivedAt={receivedAt}
isUnread={isUnread}
direction={direction}
key={`readable-message-${messageId}`} key={`readable-message-${messageId}`}
> >
{this.renderAvatar()} {this.renderAvatar()}
@ -767,18 +711,6 @@ class MessageInner extends React.PureComponent<Props, State> {
); );
} }
private loadMoreMessages() {
const { loadedMessagesLength, selectedConversationKey } = this.props;
const numMessages = loadedMessagesLength + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT;
(window.inboxStore?.dispatch as any)(
fetchMessagesForConversation({
conversationKey: selectedConversationKey as string,
count: numMessages,
})
);
}
private handleContextMenu(e: any) { private handleContextMenu(e: any) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -935,12 +867,6 @@ const mapStateToProps = (state: StateType) => {
return { return {
selectedMessages: getSelectedMessageIds(state), selectedMessages: getSelectedMessageIds(state),
quotedMessageToAnimate: getQuotedMessageToAnimate(state), quotedMessageToAnimate: getQuotedMessageToAnimate(state),
mostRecentMessageId: getMostRecentMessageId(state),
oldestMessageId: getOldestMessageId(state),
areMoreMessagesBeingFetched: areMoreMessagesBeingFetched(state),
selectedConversationKey: getSelectedConversationKey(state),
loadedMessagesLength: getLoadedMessagesLength(state),
haveDoneFirstScroll: haveDoneFirstScroll(state),
}; };
}; };

@ -5,7 +5,6 @@ import moment from 'moment';
import { Avatar, AvatarSize } from '../Avatar'; import { Avatar, AvatarSize } from '../Avatar';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Message } from './Message'; import { Message } from './Message';
import { MessageRenderingProps } from '../../models/messageType';
import { deleteMessagesById } from '../../interactions/conversationInteractions'; import { deleteMessagesById } from '../../interactions/conversationInteractions';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { ContactPropsMessageDetail } from '../../state/ducks/conversations'; import { ContactPropsMessageDetail } from '../../state/ducks/conversations';

@ -1,29 +1,141 @@
import React from 'react'; import _, { noop } from 'lodash';
import { useFocus } from '../../hooks/useFocus'; import React, { useCallback } from 'react';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useDispatch, useSelector } from 'react-redux';
// tslint:disable-next-line: no-submodule-imports
import useDebounce from 'react-use/lib/useDebounce';
import { getMessageById } from '../../data/data';
import { useAppIsFocused } from '../../hooks/useAppFocused';
import { MessageModelType } from '../../models/messageType';
import { Constants } from '../../session';
import { getConversationController } from '../../session/conversations';
import {
fetchMessagesForConversation,
markConversationFullyRead,
showScrollToBottomButton,
} from '../../state/ducks/conversations';
import {
areMoreMessagesBeingFetched,
getHaveDoneFirstScroll,
getLoadedMessagesLength,
getMostRecentMessageId,
getOldestMessageId,
getSelectedConversationKey,
} from '../../state/selectors/conversations';
type ReadableMessageProps = { type ReadableMessageProps = {
children: React.ReactNode; children: React.ReactNode;
messageId: string; messageId: string;
className: string; className: string;
onChange: (inView: boolean) => void; receivedAt: number | undefined;
isUnread: boolean;
direction: MessageModelType;
onContextMenu: (e: any) => void; onContextMenu: (e: any) => void;
}; };
const debouncedTriggerLoadMore = _.debounce(
(loadedMessagesLength: number, selectedConversationKey: string | undefined) => {
const numMessages = loadedMessagesLength + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT;
(window.inboxStore?.dispatch as any)(
fetchMessagesForConversation({
conversationKey: selectedConversationKey as string,
count: numMessages,
})
);
},
100
);
export const ReadableMessage = (props: ReadableMessageProps) => { export const ReadableMessage = (props: ReadableMessageProps) => {
const { onChange, messageId, onContextMenu, className } = props; const { messageId, onContextMenu, className, receivedAt, isUnread, direction } = props;
useFocus(onChange);
const isAppFocused = useAppIsFocused();
const dispatch = useDispatch();
// onVisible={haveDoneFirstScrollProp ? onVisible : noop}
const selectedConversationKey = useSelector(getSelectedConversationKey);
const loadedMessagesLength = useSelector(getLoadedMessagesLength);
const haveDoneFirstScroll = useSelector(getHaveDoneFirstScroll);
const mostRecentMessageId = useSelector(getMostRecentMessageId);
const oldestMessageId = useSelector(getOldestMessageId);
const fetchingMore = useSelector(areMoreMessagesBeingFetched);
const isIncoming = direction === 'incoming';
const shouldMarkReadWhenVisible = isIncoming && isUnread;
const onVisible = useCallback(
async (inView: boolean | Object) => {
// when the view first loads, it needs to scroll to the unread messages.
// we need to disable the inview on the first loading
if (!haveDoneFirstScroll) {
if (inView === true) {
window.log.info('onVisible but waiting for first scroll event');
}
return;
}
// we are the most recent message
if (mostRecentMessageId === messageId) {
// make sure the app is focused, because we mark message as read here
if (inView === true && isAppFocused) {
dispatch(showScrollToBottomButton(false));
void getConversationController()
.get(selectedConversationKey as string)
?.markRead(receivedAt || 0)
.then(() => {
dispatch(markConversationFullyRead(selectedConversationKey as string));
});
} else if (inView === false) {
dispatch(showScrollToBottomButton(true));
}
}
if (inView === true && isAppFocused && oldestMessageId === messageId && !fetchingMore) {
debouncedTriggerLoadMore(loadedMessagesLength, selectedConversationKey);
}
// this part is just handling the marking of the message as read if needed
if (
(inView === true ||
((inView as any).type === 'focus' && (inView as any).returnValue === true)) &&
shouldMarkReadWhenVisible &&
isAppFocused
) {
const found = await getMessageById(messageId);
if (found && Boolean(found.get('unread'))) {
// mark the message as read.
// this will trigger the expire timer.
await found.markRead(Date.now());
}
}
},
[
selectedConversationKey,
haveDoneFirstScroll,
mostRecentMessageId,
oldestMessageId,
fetchingMore,
isAppFocused,
loadedMessagesLength,
receivedAt,
shouldMarkReadWhenVisible,
messageId,
debouncedTriggerLoadMore,
]
);
return ( return (
// tslint:disable-next-line: use-simple-attributes
<InView <InView
id={`msg-${messageId}`} id={`msg-${messageId}`}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
className={className} className={className}
as="div" as="div"
threshold={0.5} threshold={0.5}
delay={100} delay={haveDoneFirstScroll && isAppFocused ? 100 : 200}
onChange={onChange} onChange={haveDoneFirstScroll && isAppFocused ? onVisible : noop}
triggerOnce={false} triggerOnce={false}
trackVisibility={true}
> >
{props.children} {props.children}
</InView> </InView>

@ -22,17 +22,14 @@ export const SessionScrollButton = (props: Props) => {
const show = useSelector(getShowScrollButton); const show = useSelector(getShowScrollButton);
return ( return (
<>
{show && (
<SessionScrollButtonDiv theme={themeContext}> <SessionScrollButtonDiv theme={themeContext}>
<SessionIconButton <SessionIconButton
iconType={SessionIconType.Chevron} iconType={SessionIconType.Chevron}
iconSize={SessionIconSize.Huge} iconSize={SessionIconSize.Huge}
isHidden={!show}
onClick={props.onClick} onClick={props.onClick}
theme={themeContext} theme={themeContext}
/> />
</SessionScrollButtonDiv> </SessionScrollButtonDiv>
)}
</>
); );
}; };

@ -109,14 +109,14 @@ export const GenericMessageItem = (props: {
}; };
return ( return (
<React.Fragment key={props.messageId}> <React.Fragment key={messageId}>
<Message <Message
{...regularProps} {...regularProps}
playableMessageIndex={props.playableMessageIndex} playableMessageIndex={props.playableMessageIndex}
multiSelectMode={multiSelectMode} multiSelectMode={multiSelectMode}
key={messageId} key={messageId}
/> />
<UnreadIndicator messageId={props.messageId} /> <UnreadIndicator messageId={messageId} />
</React.Fragment> </React.Fragment>
); );
}; };

@ -9,6 +9,7 @@ interface SProps extends SessionIconProps {
notificationCount?: number; notificationCount?: number;
isSelected?: boolean; isSelected?: boolean;
theme?: DefaultTheme; theme?: DefaultTheme;
isHidden?: boolean;
} }
export const SessionIconButton = (props: SProps) => { export const SessionIconButton = (props: SProps) => {
@ -23,6 +24,7 @@ export const SessionIconButton = (props: SProps) => {
glowDuration, glowDuration,
glowStartDelay, glowStartDelay,
noScale, noScale,
isHidden,
} = props; } = props;
const clickHandler = (e: any) => { const clickHandler = (e: any) => {
if (props.onClick) { if (props.onClick) {
@ -38,6 +40,7 @@ export const SessionIconButton = (props: SProps) => {
className={classNames('session-icon-button', iconSize, isSelected ? 'no-opacity' : '')} className={classNames('session-icon-button', iconSize, isSelected ? 'no-opacity' : '')}
role="button" role="button"
onClick={clickHandler} onClick={clickHandler}
style={{ display: isHidden ? 'none' : 'flex' }}
> >
<SessionIcon <SessionIcon
iconType={iconType} iconType={iconType}

@ -0,0 +1,40 @@
import { remote } from 'electron';
import { useCallback, useEffect, useState } from 'react';
import { isElectronWindowFocused } from '../session/utils/WindowUtils';
export function useAppIsFocused() {
const [isAppFocused, setIsAppFocused] = useState(false);
useEffect(() => {
setIsAppFocused(isElectronWindowFocused());
}, []);
const onFocusCallback = useCallback(
(_event, win) => {
if (win.webContents.id === 1) {
setIsAppFocused(true);
}
},
[setIsAppFocused]
);
const onBlurCallback = useCallback(
(_event, win) => {
if (win.webContents.id === 1) {
setIsAppFocused(false);
}
},
[setIsAppFocused]
);
useEffect(() => {
remote.app.on('browser-window-focus', onFocusCallback);
remote.app.on('browser-window-blur', onBlurCallback);
return () => {
remote.app.removeListener('browser-window-blur', onBlurCallback);
remote.app.removeListener('browser-window-focus', onFocusCallback);
};
});
return isAppFocused;
}

@ -1,10 +0,0 @@
import { useEffect } from 'react';
export const useFocus = (action: (param: any) => void) => {
useEffect(() => {
window.addEventListener('focus', action);
return () => {
window.removeEventListener('focus', action);
};
});
};

@ -837,7 +837,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
source, source,
}); });
const isOutgoing = Boolean(receivedAt); const isOutgoing = Boolean(!receivedAt);
source = source || UserUtils.getOurPubKeyStrFromCache(); source = source || UserUtils.getOurPubKeyStrFromCache();
@ -850,7 +850,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const messageAttributes = { const messageAttributes = {
// Even though this isn't reflected to the user, we want to place the last seen // Even though this isn't reflected to the user, we want to place the last seen
// indicator above it. We set it to 'unread' to trigger that placement. // indicator above it. We set it to 'unread' to trigger that placement.
unread: 1, unread: isOutgoing ? 0 : 1,
conversationId: this.id, conversationId: this.id,
// No type; 'incoming' messages are specially treated by conversation.markRead() // No type; 'incoming' messages are specially treated by conversation.markRead()
sent_at: timestamp, sent_at: timestamp,

@ -1179,9 +1179,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
} }
} }
private dispatchMessageUpdate() { private dispatchMessageUpdate() {
trotthledAllMessagesDispatch();
updatesToDispatch.set(this.id, this.getProps()); updatesToDispatch.set(this.id, this.getProps());
trotthledAllMessagesDispatch();
} }
} }

@ -1,11 +1,7 @@
import { DefaultTheme } from 'styled-components';
import _ from 'underscore'; import _ from 'underscore';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { QuotedAttachmentType } from '../components/conversation/Quote';
import { PropsForMessage } from '../state/ducks/conversations'; import { PropsForMessage } from '../state/ducks/conversations';
import { AttachmentType, AttachmentTypeWithPath } from '../types/Attachment'; import { AttachmentTypeWithPath } from '../types/Attachment';
import { Contact } from '../types/Contact';
import { ConversationTypeEnum } from './conversation';
export type MessageModelType = 'incoming' | 'outgoing'; export type MessageModelType = 'incoming' | 'outgoing';
export type MessageDeliveryStatus = 'sending' | 'sent' | 'read' | 'error'; export type MessageDeliveryStatus = 'sending' | 'sent' | 'read' | 'error';

@ -101,6 +101,7 @@ async function updateProfile(
ConversationTypeEnum.PRIVATE ConversationTypeEnum.PRIVATE
); );
await conv.setLokiProfile(newProfile); await conv.setLokiProfile(newProfile);
await conv.commit();
} }
function cleanAttachment(attachment: any) { function cleanAttachment(attachment: any) {

@ -88,11 +88,8 @@ function contentTypeSupported(type: string): boolean {
return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type); return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type);
} }
async function copyFromQuotedMessage( // tslint:disable-next-line: cyclomatic-complexity
msg: MessageModel, async function copyFromQuotedMessage(msg: MessageModel, quote?: Quote): Promise<void> {
quote?: Quote,
attemptCount: number = 1
): Promise<void> {
const { upgradeMessageSchema } = window.Signal.Migrations; const { upgradeMessageSchema } = window.Signal.Migrations;
const { Message: TypedMessage, Errors } = window.Signal.Types; const { Message: TypedMessage, Errors } = window.Signal.Types;
@ -100,10 +97,10 @@ async function copyFromQuotedMessage(
return; return;
} }
const { attachments, id: longId, author } = quote; const { attachments, id: quoteId, author } = quote;
const firstAttachment = attachments[0]; const firstAttachment = attachments[0];
const id: number = Long.isLong(longId) ? longId.toNumber() : longId; const id: number = Long.isLong(quoteId) ? quoteId.toNumber() : quoteId;
// We always look for the quote by sentAt timestamp, for opengroups, closed groups and session chats // We always look for the quote by sentAt timestamp, for opengroups, closed groups and session chats
// this will return an array of sent message by id we have locally. // this will return an array of sent message by id we have locally.
@ -115,17 +112,10 @@ async function copyFromQuotedMessage(
}); });
if (!found) { if (!found) {
// Exponential backoff, giving up after 5 attempts: window?.log?.warn(`We did not found quoted message ${id}.`);
if (attemptCount < 5) {
setTimeout(() => {
window?.log?.info(`Looking for the message id : ${id}, attempt: ${attemptCount + 1}`);
void copyFromQuotedMessage(msg, quote, attemptCount + 1);
}, attemptCount * attemptCount * 500);
} else {
window?.log?.warn(`We did not found quoted message ${id} after ${attemptCount} attempts.`);
}
quote.referencedMessageNotFound = true; quote.referencedMessageNotFound = true;
msg.set({ quote });
await msg.commit();
return; return;
} }
@ -135,14 +125,6 @@ async function copyFromQuotedMessage(
const queryMessage = getMessageController().register(found.id, found); const queryMessage = getMessageController().register(found.id, found);
quote.text = queryMessage.get('body') || ''; quote.text = queryMessage.get('body') || '';
if (attemptCount > 1) {
// Normally the caller would save the message, but in case we are
// called by a timer, we need to update the message manually
msg.set({ quote });
await msg.commit();
return;
}
if (!firstAttachment || !contentTypeSupported(firstAttachment.contentType)) { if (!firstAttachment || !contentTypeSupported(firstAttachment.contentType)) {
return; return;
} }

@ -1,4 +1,5 @@
import { remote } from 'electron'; import { app, BrowserWindow, remote } from 'electron';
import { useEffect, useState } from 'react';
export function isElectronWindowFocused() { export function isElectronWindowFocused() {
const [yourBrowserWindow] = remote.BrowserWindow.getAllWindows(); const [yourBrowserWindow] = remote.BrowserWindow.getAllWindows();

@ -320,7 +320,7 @@ export const areMoreMessagesBeingFetched = createSelector(
(state: ConversationsStateType): boolean => state.areMoreMessagesBeingFetched || false (state: ConversationsStateType): boolean => state.areMoreMessagesBeingFetched || false
); );
export const haveDoneFirstScroll = createSelector( export const getHaveDoneFirstScroll = createSelector(
getConversations, getConversations,
(state: ConversationsStateType): boolean => state.haveDoneFirstScroll (state: ConversationsStateType): boolean => state.haveDoneFirstScroll
); );

Loading…
Cancel
Save