cleanup scroll to unread of quote or search result on open

This commit is contained in:
Audric Ackermann 2022-01-21 14:39:24 +11:00
parent d269693544
commit 792c23da87
No known key found for this signature in database
GPG key ID: 999F434D76324AD4
11 changed files with 169 additions and 138 deletions

View file

@ -275,6 +275,9 @@ $session-highlight-message-shadow: 0px 0px 10px 1px $session-color-green;
@keyframes remove-box-shadow {
0% {
box-shadow: none;
}
10% {
box-shadow: $session-highlight-message-shadow;
}
75% {

View file

@ -1,5 +1,8 @@
import React from 'react';
import React, { useContext, useLayoutEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { getQuotedMessageToAnimate } from '../../state/selectors/conversations';
import { ScrollToLoadedMessageContext } from './SessionMessagesListContainer';
const LastSeenBarContainer = styled.div`
padding-bottom: 35px;
@ -26,14 +29,29 @@ const LastSeenText = styled.div`
color: var(--color-last-seen-indicator-text);
`;
export const SessionLastSeenIndicator = () => {
const { i18n } = window;
const text = i18n('unreadMessages');
export const SessionLastSeenIndicator = (props: { messageId: string }) => {
// if this unread-indicator is not unique it's going to cause issues
const [didScroll, setDidScroll] = useState(false);
const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate);
const scrollToLoadedMessage = useContext(ScrollToLoadedMessageContext);
// if this unread-indicator is rendered,
// we want to scroll here only if the conversation was not opened to a specific message
useLayoutEffect(() => {
if (!quotedMessageToAnimate && !didScroll) {
scrollToLoadedMessage(props.messageId, 'unread-indicator');
setDidScroll(true);
} else if (quotedMessageToAnimate) {
setDidScroll(true);
}
});
return (
<LastSeenBarContainer id="unread-indicator">
<LastSeenBar>
<LastSeenText>{text}</LastSeenText>
<LastSeenText>{window.i18n('unreadMessages')}</LastSeenText>
</LastSeenBar>
</LastSeenBarContainer>
);

View file

@ -85,7 +85,7 @@ export const SessionMessagesList = (props: {
{messagesProps.map(messageProps => {
const messageId = messageProps.message.props.messageId;
const unreadIndicator = messageProps.showUnreadIndicator ? (
<SessionLastSeenIndicator key={`unread-indicator-${messageId}`} />
<SessionLastSeenIndicator key={`unread-indicator-${messageId}`} messageId={messageId} />
) : null;
const dateBreak =

View file

@ -15,7 +15,6 @@ import {
ReduxConversationType,
resetOldBottomMessageId,
resetOldTopMessageId,
showScrollToBottomButton,
SortedMessageModelProps,
} from '../../state/ducks/conversations';
import { StateType } from '../../state/reducer';
@ -34,9 +33,18 @@ export type SessionMessageListProps = {
messageContainerRef: React.RefObject<HTMLDivElement>;
};
export const messageContainerDomID = 'messages-container';
export type ScrollToLoadedReasons =
| 'quote-or-search-result'
| 'go-to-bottom'
| 'unread-indicator'
| 'load-more-top'
| 'load-more-bottom';
export const ScrollToLoadedMessageContext = React.createContext(
// tslint:disable-next-line: no-empty
(_loadedMessageIdToScrollTo: string) => {}
(_loadedMessageIdToScrollTo: string, _reason: ScrollToLoadedReasons) => {}
);
const SessionUnreadAboveIndicator = styled.div`
@ -85,10 +93,6 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
// ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
public componentDidMount() {
this.initialMessageLoadingPosition();
}
public componentWillUnmount() {
if (this.timeoutResetQuotedScroll) {
global.clearTimeout(this.timeoutResetQuotedScroll);
@ -108,7 +112,6 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
) {
this.setupTimeoutResetQuotedHighlightedMessage(this.props.animateQuotedMessageId);
// displayed conversation changed. We have a bit of cleaning to do here
this.initialMessageLoadingPosition();
}
}
@ -129,6 +132,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
return (
<div
className="messages-container"
id={messageContainerDomID}
onScroll={this.handleScroll}
ref={this.props.messageContainerRef}
data-testid="messages-container"
@ -143,18 +147,13 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
key="typing-bubble"
/>
<ScrollToLoadedMessageContext.Provider value={this.scrollToQuoteMessage}>
<ScrollToLoadedMessageContext.Provider value={this.scrollToLoadedMessage}>
<SessionMessagesList
scrollAfterLoadMore={(
messageIdToScrollTo: string,
type: 'load-more-top' | 'load-more-bottom'
) => {
const isLoadMoreTop = type === 'load-more-top';
const isLoadMoreBottom = type === 'load-more-bottom';
this.scrollToMessage(messageIdToScrollTo, isLoadMoreTop ? 'start' : 'end', {
isLoadMoreTop,
isLoadMoreBottom,
});
this.scrollToMessage(messageIdToScrollTo, type);
}}
onPageDownPressed={this.scrollPgDown}
onPageUpPressed={this.scrollPgUp}
@ -175,43 +174,6 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
contextMenu.hideAll();
}
/**
* Position the list to the middle of the loaded list if the conversation has unread messages and we have some messages loaded
*/
private initialMessageLoadingPosition() {
const { messagesProps, conversation, firstUnreadOnOpen } = this.props;
if (!conversation || !messagesProps.length) {
return;
}
if (
(conversation.unreadCount && conversation.unreadCount <= 0) ||
firstUnreadOnOpen === undefined
) {
this.scrollToMostRecentMessage();
} else {
// just assume that this need to be shown by default
window.inboxStore?.dispatch(showScrollToBottomButton(true));
const firstUnreadIndex = messagesProps.findIndex(
m => m.propsForMessage.id === firstUnreadOnOpen
);
if (firstUnreadIndex === -1) {
// the first unread message is not in the 30 most recent messages
// just scroll to the middle as we don't have enough loaded message nevertheless
const middle = Math.floor(messagesProps.length / 2);
const idToStringTo = messagesProps[middle].propsForMessage.id;
this.scrollToMessage(idToStringTo, 'center');
} else {
const messageElementDom = document.getElementById('unread-indicator');
messageElementDom?.scrollIntoView({
behavior: 'auto',
block: 'center',
});
}
}
}
/**
* Could not find a better name, but when we click on a quoted message,
* the UI takes us there and highlights it.
@ -233,27 +195,57 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
}
}
private scrollToMessage(
messageId: string,
block: ScrollLogicalPosition | undefined,
options?: { isLoadMoreTop: boolean | undefined; isLoadMoreBottom: boolean | undefined }
) {
private scrollToMessage(messageId: string, reason: ScrollToLoadedReasons) {
const messageElementDom = document.getElementById(`msg-${messageId}`);
// annoyingly, useLayoutEffect, which is calling this function, is run before ref are set on a react component.
// so the only way to scroll in the container at this time, is with the DOM itself
const messageContainerDom = document.getElementById(messageContainerDomID);
messageElementDom?.scrollIntoView({
behavior: 'auto',
block,
});
// * if quote or search result we want to scroll to start AND do a -50px
// * if scroll-to-unread we want to scroll end AND do a +200px to be really at the end
// * if load-more-top or bottom we want to center
this.props.messageContainerRef.current?.scrollBy({ top: -50 });
switch (reason) {
case 'load-more-bottom':
messageElementDom?.scrollIntoView({
behavior: 'auto',
block: 'end',
});
// reset the oldBottomInRedux so that a refresh/new message does not scroll us back here again
window.inboxStore?.dispatch(resetOldBottomMessageId());
break;
case 'load-more-top':
messageElementDom?.scrollIntoView({
behavior: 'auto',
block: 'start',
});
// reset the oldTopInRedux so that a refresh/new message does not scroll us back here again
window.inboxStore?.dispatch(resetOldTopMessageId());
break;
case 'quote-or-search-result':
messageElementDom?.scrollIntoView({
behavior: 'auto',
block: 'start',
});
messageContainerDom?.scrollBy({ top: -50 });
if (options?.isLoadMoreTop) {
// reset the oldTopInRedux so that a refresh/new message does not scroll us back here again
window.inboxStore?.dispatch(resetOldTopMessageId());
}
if (options?.isLoadMoreBottom) {
// reset the oldBottomInRedux so that a refresh/new message does not scroll us back here again
window.inboxStore?.dispatch(resetOldBottomMessageId());
break;
case 'go-to-bottom':
messageElementDom?.scrollIntoView({
behavior: 'auto',
block: 'end',
});
messageContainerDom?.scrollBy({ top: 200 });
break;
case 'unread-indicator':
messageElementDom?.scrollIntoView({
behavior: 'auto',
block: 'center',
});
messageContainerDom?.scrollBy({ top: -50 });
break;
default:
}
}
@ -307,8 +299,8 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
messageContainer.scrollTo(0, 0);
}
private scrollToQuoteMessage(loadedQuoteMessageToScrollTo: string) {
if (!this.props.conversationKey || !loadedQuoteMessageToScrollTo) {
private scrollToLoadedMessage(loadedMessageToScrollTo: string, reason: ScrollToLoadedReasons) {
if (!this.props.conversationKey || !loadedMessageToScrollTo) {
return;
}
@ -316,14 +308,16 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
// If there's no message already in memory, we won't be scrolling. So we'll gather
// some more information then show an informative toast to the user.
if (!messagesProps.find(m => m.propsForMessage.id === loadedQuoteMessageToScrollTo)) {
if (!messagesProps.find(m => m.propsForMessage.id === loadedMessageToScrollTo)) {
throw new Error('this message is not loaded');
}
this.scrollToMessage(loadedQuoteMessageToScrollTo, 'start');
this.scrollToMessage(loadedMessageToScrollTo, reason);
// Highlight this message on the UI
window.inboxStore?.dispatch(quotedMessageToAnimate(loadedQuoteMessageToScrollTo));
this.setupTimeoutResetQuotedHighlightedMessage(loadedQuoteMessageToScrollTo);
if (reason === 'quote-or-search-result') {
window.inboxStore?.dispatch(quotedMessageToAnimate(loadedMessageToScrollTo));
this.setupTimeoutResetQuotedHighlightedMessage(loadedMessageToScrollTo);
}
}
}

View file

@ -103,7 +103,7 @@ export const MessageContent = (props: Props) => {
);
const [isMessageVisible, setMessageIsVisible] = useState(false);
const scrollToMessage = useContext(ScrollToLoadedMessageContext);
const scrollToLoadedMessage = useContext(ScrollToLoadedMessageContext);
const [imageBroken, setImageBroken] = useState(false);
@ -129,7 +129,7 @@ export const MessageContent = (props: Props) => {
if (isQuotedMessageToAnimate) {
if (!flashGreen) {
//scroll to me and flash me
scrollToMessage(props.messageId);
scrollToLoadedMessage(props.messageId, 'quote-or-search-result');
setFlashGreen(true);
}
return;

View file

@ -27,7 +27,6 @@ export const MessageQuote = (props: Props) => {
const multiSelectMode = useSelector(isMessageSelectionMode);
const isMessageDetailViewMode = useSelector(isMessageDetailView);
// const scrollToLoadedMessage = useContext(ScrollToLoadedMessageContext);
const quote = selected ? selected.quote : undefined;
const direction = selected ? selected.direction : undefined;
@ -73,12 +72,6 @@ export const MessageQuote = (props: Props) => {
conversationKey: foundInDb.get('conversationId'),
messageIdToNavigateTo: foundInDb.get('id'),
});
// scrollToLoadedMessage?.({
// quoteAuthor: sender,
// quoteId,
// referencedMessageNotFound: referencedMessageNotFound || false,
// });
},
[quote, multiSelectMode, props.messageId]
);

View file

@ -1,5 +1,5 @@
import _, { noop } from 'lodash';
import React, { useCallback } from 'react';
import React, { useCallback, useContext, useLayoutEffect, useState } from 'react';
import { InView } from 'react-intersection-observer';
import { useDispatch, useSelector } from 'react-redux';
import { getMessageById } from '../../../../data/data';
@ -13,13 +13,16 @@ import {
import {
areMoreBottomMessagesBeingFetched,
areMoreTopMessagesBeingFetched,
getFirstUnreadMessageId,
getLoadedMessagesLength,
getMostRecentMessageId,
getOldestMessageId,
getQuotedMessageToAnimate,
getSelectedConversationKey,
getYoungestMessageId,
} from '../../../../state/selectors/conversations';
import { getIsAppFocused } from '../../../../state/selectors/section';
import { ScrollToLoadedMessageContext } from '../../SessionMessagesListContainer';
type ReadableMessageProps = {
children: React.ReactNode;
@ -67,21 +70,44 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
const youngestMessageId = useSelector(getYoungestMessageId);
const fetchingTopMore = useSelector(areMoreTopMessagesBeingFetched);
const fetchingBottomMore = useSelector(areMoreBottomMessagesBeingFetched);
const conversationHasUnread = Boolean(useSelector(getFirstUnreadMessageId));
const shouldMarkReadWhenVisible = isUnread;
const [didScroll, setDidScroll] = useState(false);
const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate);
const scrollToLoadedMessage = useContext(ScrollToLoadedMessageContext);
// if this unread-indicator is rendered,
// we want to scroll here only if the conversation was not opened to a specific message
useLayoutEffect(() => {
if (
props.messageId === youngestMessageId &&
!quotedMessageToAnimate &&
!didScroll &&
!conversationHasUnread
) {
scrollToLoadedMessage(props.messageId, 'go-to-bottom');
setDidScroll(true);
} else if (quotedMessageToAnimate) {
setDidScroll(true);
}
});
const onVisible = useCallback(
// tslint:disable-next-line: cyclomatic-complexity
async (inView: boolean | Object) => {
// we are the most recent message
if (mostRecentMessageId === messageId) {
if (mostRecentMessageId === messageId && selectedConversationKey) {
// 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)
.get(selectedConversationKey)
?.markRead(receivedAt || 0)
.then(() => {
dispatch(markConversationFullyRead(selectedConversationKey as string));
dispatch(markConversationFullyRead(selectedConversationKey));
});
} else if (inView === false) {
dispatch(showScrollToBottomButton(true));

View file

@ -101,7 +101,7 @@ export function useWeAreAdmin(convoId?: string) {
export function useExpireTimer(convoId?: string) {
const convoProps = useConversationPropsById(convoId);
return Boolean(convoProps && convoProps.expireTimer);
return convoProps && convoProps.expireTimer;
}
export function useIsPinned(convoId?: string) {

View file

@ -930,19 +930,21 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
await this.setIsApproved(true);
}
// no need to trigger a UI update now, we trigger a messageAdded just below
// no need to trigger a UI update now, we trigger a messagesAdded just below
const messageId = await model.commit(false);
model.set({ id: messageId });
if (setToExpire) {
await model.setToExpire();
}
// window.inboxStore?.dispatch(
// conversationActions.messageAdded({
// conversationKey: this.id,
// messageModelProps: model.getMessageModelProps(),
// })
// );
window.inboxStore?.dispatch(
conversationActions.messagesAdded([
{
conversationKey: this.id,
messageModelProps: model.getMessageModelProps(),
},
])
);
const unreadCount = await this.getUnreadCount();
this.set({ unreadCount });
this.updateLastMessage();

View file

@ -3,6 +3,7 @@ import { toNumber } from 'lodash';
import { getConversationController } from '../session/conversations';
import { ConversationTypeEnum } from '../models/conversation';
import { toLogFormat } from '../types/attachments/Errors';
import { messagesAdded } from '../state/ducks/conversations';
export async function onError(ev: any) {
const { error } = ev;
@ -33,12 +34,14 @@ export async function onError(ev: any) {
conversation.updateLastMessage();
await conversation.notify(message);
// window.inboxStore?.dispatch(
// conversationActions.messageAdded({
// conversationKey: conversation.id,
// messageModelProps: message.getMessageModelProps(),
// })
// );
window.inboxStore?.dispatch(
messagesAdded([
{
conversationKey: conversation.id,
messageModelProps: message.getMessageModelProps(),
},
])
);
if (ev.confirm) {
ev.confirm();

View file

@ -406,31 +406,33 @@ function handleMessageAdded(
}
) {
const { messages } = state;
const { conversationKey, messageModelProps: addedMessageProps } = payload;
if (conversationKey === state.selectedConversation) {
const messageInStoreIndex = state?.messages?.findIndex(
m => m.propsForMessage.id === addedMessageProps.propsForMessage.id
);
if (messageInStoreIndex >= 0) {
// we cannot edit the array directly, so slice the first part, insert our edited message, and slice the second part
const editedMessages = [
...state.messages.slice(0, messageInStoreIndex),
addedMessageProps,
...state.messages.slice(messageInStoreIndex + 1),
];
return {
...state,
messages: editedMessages,
};
}
const { conversationKey, messageModelProps: addedMessageProps } = payload;
if (conversationKey !== state.selectedConversation) {
return state;
}
const messageInStoreIndex = state.messages.findIndex(
m => m.propsForMessage.id === addedMessageProps.propsForMessage.id
);
if (messageInStoreIndex >= 0) {
// we cannot edit the array directly, so slice the first part, insert our edited message, and slice the second part
const editedMessages = [
...state.messages.slice(0, messageInStoreIndex),
addedMessageProps,
...state.messages.slice(messageInStoreIndex + 1),
];
return {
...state,
messages: [...messages, addedMessageProps], // sorting happens in the selector
messages: editedMessages,
};
}
return state;
return {
...state,
messages: [...messages, addedMessageProps], // sorting happens in the selector
};
}
function handleMessageChanged(
@ -630,15 +632,6 @@ const conversationsSlice = createSlice({
return getEmptyConversationState();
},
messageAdded(
state: ConversationsStateType,
action: PayloadAction<{
conversationKey: string;
messageModelProps: MessageModelPropsWithoutConvoProps;
}>
) {
return handleMessageAdded(state, action.payload);
},
messagesAdded(
state: ConversationsStateType,
action: PayloadAction<
@ -741,7 +734,7 @@ const conversationsSlice = createSlice({
firstUnreadMessageId: action.payload.firstUnreadIdOnOpen,
};
},
navigateInConversationToMessageId(
openConversationToSpecificMessage(
state: ConversationsStateType,
action: PayloadAction<{
conversationKey: string;
@ -897,7 +890,6 @@ export const {
conversationRemoved,
removeAllConversations,
messageExpired,
messageAdded,
messagesAdded,
messageDeleted,
conversationReset,
@ -955,7 +947,7 @@ export async function openConversationToSpecificMessage(args: {
});
window.inboxStore?.dispatch(
actions.navigateInConversationToMessageId({
actions.openConversationToSpecificMessage({
conversationKey,
messageIdToNavigateTo,
initialMessages: messagesAroundThisMessage,