improve marking message as read with hooks

This commit is contained in:
Audric Ackermann 2021-07-30 16:12:36 +10:00
parent 3dc11b923d
commit 3f065a7b0e
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4
16 changed files with 198 additions and 152 deletions

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { getTimerBucketIcon } from '../../util/timer';
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 [timeLeft, setTimeLeft] = useState(initialTimeLeft);
const update = () => {
const update = useCallback(() => {
const newTimeLeft = Math.max(Math.round((expirationTimestamp - Date.now()) / 1000), 0);
if (newTimeLeft !== timeLeft) {
setTimeLeft(newTimeLeft);
}
};
}, [expirationTimestamp, timeLeft, setTimeLeft]);
const updateFrequency = 500;

View File

@ -39,31 +39,20 @@ import { getMessageById } from '../../data/data';
import { connect } from 'react-redux';
import { StateType } from '../../state/reducer';
import {
areMoreMessagesBeingFetched,
getLoadedMessagesLength,
getMostRecentMessageId,
getOldestMessageId,
getQuotedMessageToAnimate,
getSelectedConversationKey,
getSelectedMessageIds,
haveDoneFirstScroll,
} from '../../state/selectors/conversations';
import {
fetchMessagesForConversation,
markConversationFullyRead,
messageExpired,
showLightBox,
showScrollToBottomButton,
toggleSelectedMessageId,
} from '../../state/ducks/conversations';
import { saveAttachmentToDisk } from '../../util/attachmentsUtil';
import { LightBoxOptions } from '../session/conversation/SessionConversation';
import { MessageContextMenu } from './MessageContextMenu';
import { ReadableMessage } from './ReadableMessage';
import { isElectronWindowFocused } from '../../session/utils/WindowUtils';
import { getConversationController } from '../../session/conversations';
import { MessageMetadata } from './message/MessageMetadata';
import { Constants } from '../../session';
// Same as MIN_WIDTH in ImageGrid.tsx
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
@ -80,12 +69,6 @@ const EXPIRED_DELAY = 600;
type Props = MessageRenderingProps & {
selectedMessages: Array<string>;
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 {
@ -147,7 +130,6 @@ class MessageInner extends React.PureComponent<Props, State> {
imageBroken: false,
};
this.ctxMenuID = `ctx-menu-message-${uuid()}`;
this.loadMoreMessages = _.debounce(this.loadMoreMessages, 100);
}
public componentDidMount() {
@ -175,7 +157,7 @@ class MessageInner extends React.PureComponent<Props, State> {
}
}
public componentDidUpdate() {
public componentDidUpdate(prevProps: Props) {
this.checkExpired();
}
@ -617,9 +599,9 @@ class MessageInner extends React.PureComponent<Props, State> {
direction,
id: messageId,
conversationType,
areMoreMessagesBeingFetched: fetchingMore,
isUnread,
selectedMessages,
receivedAt,
isUnread,
} = this.props;
const { expired, expiring } = this.state;
@ -632,8 +614,6 @@ class MessageInner extends React.PureComponent<Props, State> {
const width = this.getWidth();
const isShowingImage = this.isShowingImage();
const isIncoming = direction === 'incoming';
const shouldMarkReadWhenVisible = isIncoming && isUnread;
const divClasses = ['session-message-wrapper'];
if (selected) {
@ -648,52 +628,16 @@ class MessageInner extends React.PureComponent<Props, State> {
divClasses.push('flash-green-once');
}
const onVisible = 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 (!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());
}
}
};
const isIncoming = direction === 'incoming';
return (
<ReadableMessage
messageId={messageId}
className={classNames(divClasses)}
onChange={onVisible}
onContextMenu={this.handleContextMenu}
receivedAt={receivedAt}
isUnread={isUnread}
direction={direction}
key={`readable-message-${messageId}`}
>
{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) {
e.preventDefault();
e.stopPropagation();
@ -935,12 +867,6 @@ const mapStateToProps = (state: StateType) => {
return {
selectedMessages: getSelectedMessageIds(state),
quotedMessageToAnimate: getQuotedMessageToAnimate(state),
mostRecentMessageId: getMostRecentMessageId(state),
oldestMessageId: getOldestMessageId(state),
areMoreMessagesBeingFetched: areMoreMessagesBeingFetched(state),
selectedConversationKey: getSelectedConversationKey(state),
loadedMessagesLength: getLoadedMessagesLength(state),
haveDoneFirstScroll: haveDoneFirstScroll(state),
};
};

View File

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

View File

@ -1,29 +1,141 @@
import React from 'react';
import { useFocus } from '../../hooks/useFocus';
import _, { noop } from 'lodash';
import React, { useCallback } from 'react';
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 = {
children: React.ReactNode;
messageId: string;
className: string;
onChange: (inView: boolean) => void;
receivedAt: number | undefined;
isUnread: boolean;
direction: MessageModelType;
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) => {
const { onChange, messageId, onContextMenu, className } = props;
useFocus(onChange);
const { messageId, onContextMenu, className, receivedAt, isUnread, direction } = props;
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 (
// tslint:disable-next-line: use-simple-attributes
<InView
id={`msg-${messageId}`}
onContextMenu={onContextMenu}
className={className}
as="div"
threshold={0.5}
delay={100}
onChange={onChange}
delay={haveDoneFirstScroll && isAppFocused ? 100 : 200}
onChange={haveDoneFirstScroll && isAppFocused ? onVisible : noop}
triggerOnce={false}
trackVisibility={true}
>
{props.children}
</InView>

View File

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

View File

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

View File

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

40
ts/hooks/useAppFocused.ts Normal file
View File

@ -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;
}

View File

@ -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);
};
});
};

View File

@ -837,7 +837,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
source,
});
const isOutgoing = Boolean(receivedAt);
const isOutgoing = Boolean(!receivedAt);
source = source || UserUtils.getOurPubKeyStrFromCache();
@ -850,7 +850,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const messageAttributes = {
// 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.
unread: 1,
unread: isOutgoing ? 0 : 1,
conversationId: this.id,
// No type; 'incoming' messages are specially treated by conversation.markRead()
sent_at: timestamp,

View File

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

View File

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

View File

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

View File

@ -88,11 +88,8 @@ function contentTypeSupported(type: string): boolean {
return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type);
}
async function copyFromQuotedMessage(
msg: MessageModel,
quote?: Quote,
attemptCount: number = 1
): Promise<void> {
// tslint:disable-next-line: cyclomatic-complexity
async function copyFromQuotedMessage(msg: MessageModel, quote?: Quote): Promise<void> {
const { upgradeMessageSchema } = window.Signal.Migrations;
const { Message: TypedMessage, Errors } = window.Signal.Types;
@ -100,10 +97,10 @@ async function copyFromQuotedMessage(
return;
}
const { attachments, id: longId, author } = quote;
const { attachments, id: quoteId, author } = quote;
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
// this will return an array of sent message by id we have locally.
@ -115,17 +112,10 @@ async function copyFromQuotedMessage(
});
if (!found) {
// Exponential backoff, giving up after 5 attempts:
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.`);
}
window?.log?.warn(`We did not found quoted message ${id}.`);
quote.referencedMessageNotFound = true;
msg.set({ quote });
await msg.commit();
return;
}
@ -135,14 +125,6 @@ async function copyFromQuotedMessage(
const queryMessage = getMessageController().register(found.id, found);
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)) {
return;
}

View File

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

View File

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