chat: fixes unread marker behavior

fixes #3495
This commit is contained in:
Tyler Brown Cifu Shuster 2020-09-17 20:13:51 -07:00
parent 6f5663bcd3
commit 80815b9880
2 changed files with 61 additions and 51 deletions

View File

@ -1,6 +1,7 @@
import React, { Component, PureComponent } from "react"; import React, { Component, PureComponent } from "react";
import moment from "moment"; import moment from "moment";
import _ from "lodash"; import _ from "lodash";
import { Box } from "@tlon/indigo-react";
import { OverlaySigil } from './overlay-sigil'; import { OverlaySigil } from './overlay-sigil';
import { uxToHex, cite, writeText } from '~/logic/lib/util'; import { uxToHex, cite, writeText } from '~/logic/lib/util';
@ -12,14 +13,10 @@ import RemoteContent from '~/views/components/RemoteContent';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D'; export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
export const UnreadMarker = React.forwardRef(({ dayBreak, when, style }, ref) => ( export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
<div ref={element => { <div ref={ref} className="green2 flex items-center f9 absolute w-100 left-0">
setTimeout(() => {
element.style.opacity = '1';
}, 250);
}} className="green2 flex items-center f9 absolute w-100" style={{...style, opacity: '0'}}>
<hr className="dn-s ma0 w2 b--green2 bt-0" /> <hr className="dn-s ma0 w2 b--green2 bt-0" />
<p className="mh4">New messages below</p> <p className="mh4" style={{ whiteSpace: 'normal' }}>New messages below</p>
<hr className="ma0 flex-grow-1 b--green2 bt-0" /> <hr className="ma0 flex-grow-1 b--green2 bt-0" />
{dayBreak {dayBreak
? <p className="gray2 mh4">{moment(when).calendar()}</p> ? <p className="gray2 mh4">{moment(when).calendar()}</p>
@ -39,18 +36,19 @@ interface ChatMessageProps {
msg: Envelope | IMessage; msg: Envelope | IMessage;
previousMsg: Envelope | IMessage | undefined; previousMsg: Envelope | IMessage | undefined;
nextMsg: Envelope | IMessage | undefined; nextMsg: Envelope | IMessage | undefined;
isFirstUnread: boolean; isLastRead: boolean;
group: Group; group: Group;
association: Association; association: Association;
contacts: Contacts; contacts: Contacts;
unreadRef: React.RefObject<HTMLDivElement>;
hideAvatars: boolean; hideAvatars: boolean;
hideNicknames: boolean; hideNicknames: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy; remoteContentPolicy: LocalUpdateRemoteContentPolicy;
className: string; className?: string;
isPending: boolean; isPending: boolean;
style?: any; style?: any;
scrollWindow: HTMLDivElement; scrollWindow: HTMLDivElement;
isLastMessage?: boolean;
unreadMarkerRef: React.RefObject<HTMLDivElement>;
} }
export default class ChatMessage extends Component<ChatMessageProps> { export default class ChatMessage extends Component<ChatMessageProps> {
@ -72,11 +70,10 @@ export default class ChatMessage extends Component<ChatMessageProps> {
msg, msg,
previousMsg, previousMsg,
nextMsg, nextMsg,
isFirstUnread, isLastRead,
group, group,
association, association,
contacts, contacts,
unreadRef,
hideAvatars, hideAvatars,
hideNicknames, hideNicknames,
remoteContentPolicy, remoteContentPolicy,
@ -84,7 +81,9 @@ export default class ChatMessage extends Component<ChatMessageProps> {
isPending, isPending,
style, style,
measure, measure,
scrollWindow scrollWindow,
isLastMessage,
unreadMarkerRef
} = this.props; } = this.props;
const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1); const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1);
@ -92,7 +91,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
const containerClass = `${renderSigil const containerClass = `${renderSigil
? `w-100 flex flex-wrap cf pr3 f7 pt4 pl3 lh-copy` ? `w-100 flex flex-wrap cf pr3 f7 pt4 pl3 lh-copy`
: `w-100 flex flex-wrap cf pr3 hide-child`} ${isPending ? ' o-40' : ''} ${className}` : `w-100 flex flex-wrap cf pr3 hide-child`} ${isPending ? 'o-40' : ''} ${isLastMessage ? 'pb3' : ''} ${className}`
const timestamp = moment.unix(msg.when / 1000).format(renderSigil ? 'hh:mm a' : 'hh:mm'); const timestamp = moment.unix(msg.when / 1000).format(renderSigil ? 'hh:mm a' : 'hh:mm');
@ -116,15 +115,19 @@ export default class ChatMessage extends Component<ChatMessageProps> {
scrollWindow scrollWindow
}; };
const unreadContainerStyle = {
height: isLastRead ? '1.66em' : '0',
};
return ( return (
<div ref={this.divRef} className={containerClass} style={style} data-number={msg.number}> <div ref={this.divRef} className={containerClass} style={style} data-number={msg.number}>
{dayBreak && !isFirstUnread ? <DayBreak when={msg.when} /> : null} {dayBreak && !isLastRead ? <DayBreak when={msg.when} /> : null}
{renderSigil {renderSigil
? <MessageWithSigil {...messageProps} /> ? <MessageWithSigil {...messageProps} />
: <MessageWithoutSigil {...messageProps} />} : <MessageWithoutSigil {...messageProps} />}
{isFirstUnread <Box fontSize='0' position='relative' width='100%' overflow='hidden' style={unreadContainerStyle}>{isLastRead
? <UnreadMarker ref={unreadRef} dayBreak={dayBreak} when={msg.when} style={{ marginTop: (renderSigil ? "-17px" : "-6px") }} /> ? <UnreadMarker dayBreak={dayBreak} when={msg.when} ref={unreadMarkerRef} />
: null} : null}</Box>
</div> </div>
); );
} }

View File

@ -48,10 +48,12 @@ interface ChatWindowState {
fetchPending: boolean; fetchPending: boolean;
idle: boolean; idle: boolean;
initialized: boolean; initialized: boolean;
lastRead: number;
} }
export default class ChatWindow extends Component<ChatWindowProps, ChatWindowState> { export default class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
private virtualList: VirtualScroller | null; private virtualList: VirtualScroller | null;
private unreadMarkerRef: React.RefObject<HTMLDivElement>;
INITIALIZATION_MAX_TIME = 1500; INITIALIZATION_MAX_TIME = 1500;
@ -61,18 +63,20 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.state = { this.state = {
fetchPending: false, fetchPending: false,
idle: true, idle: true,
initialized: false initialized: false,
lastRead: props.unreadCount ? props.mailboxSize - props.unreadCount : Infinity,
}; };
this.dismissUnread = this.dismissUnread.bind(this); this.dismissUnread = this.dismissUnread.bind(this);
this.initialIndex = this.initialIndex.bind(this);
this.scrollToUnread = this.scrollToUnread.bind(this); this.scrollToUnread = this.scrollToUnread.bind(this);
this.handleWindowBlur = this.handleWindowBlur.bind(this); this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this); this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.stayLockedIfActive = this.stayLockedIfActive.bind(this); this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
this.firstUnread = this.firstUnread.bind(this); this.dismissIfLineVisible = this.dismissIfLineVisible.bind(this);
this.lastRead = this.lastRead.bind(this);
this.virtualList = null; this.virtualList = null;
this.unreadMarkerRef = React.createRef();
} }
componentDidMount() { componentDidMount() {
@ -97,14 +101,6 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.setState({ idle: false }); this.setState({ idle: false });
} }
initialIndex() {
const { mailboxSize, unreadCount } = this.props;
return Math.min(Math.max(mailboxSize - 1 < INITIAL_LOAD
? 0
: this.firstUnread(),
0), mailboxSize);
}
initialFetch() { initialFetch() {
const { envelopes, mailboxSize, unreadCount } = this.props; const { envelopes, mailboxSize, unreadCount } = this.props;
if (envelopes.length > 0) { if (envelopes.length > 0) {
@ -112,17 +108,9 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.stayLockedIfActive(); this.stayLockedIfActive();
this.fetchMessages(start, start + DEFAULT_BACKLOG_SIZE, true).then(() => { this.fetchMessages(start, start + DEFAULT_BACKLOG_SIZE, true).then(() => {
if (!this.virtualList) return; if (!this.virtualList) return;
const initialIndex = this.initialIndex(); this.setState({ idle: false });
this.virtualList.scrollToData(initialIndex).then(() => { this.setState({ initialized: true });
if ( this.dismissIfLineVisible();
initialIndex === mailboxSize
|| (this.virtualList && this.virtualList.window && this.virtualList.window.scrollTop === 0)
) {
this.setState({ idle: false });
this.dismissUnread();
}
this.setState({ initialized: true });
});
}); });
} else { } else {
setTimeout(() => { setTimeout(() => {
@ -195,9 +183,31 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
}); });
} }
firstUnread() { lastRead() {
const { mailboxSize, unreadCount } = this.props; const { mailboxSize, unreadCount } = this.props;
return mailboxSize - unreadCount + 1; return mailboxSize - unreadCount;
}
onScroll({ scrollTop, scrollHeight, windowHeight }) {
if (!this.state.idle && scrollTop > IDLE_THRESHOLD) {
this.setState({ idle: true });
}
this.dismissIfLineVisible();
}
dismissIfLineVisible() {
if (this.props.unreadCount === 0) return;
if (!this.unreadMarkerRef.current || !this.virtualList?.window) return;
const parent = this.unreadMarkerRef.current.parentElement?.parentElement;
if (!parent) return;
const { scrollTop, scrollHeight, offsetHeight } = this.virtualList.window;
if (
(scrollHeight - parent.offsetTop > scrollTop)
&& (scrollHeight - parent.offsetTop < scrollTop + offsetHeight)
) {
this.dismissUnread();
}
} }
render() { render() {
@ -220,6 +230,8 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
remoteContentPolicy, remoteContentPolicy,
} = this.props; } = this.props;
const unreadMarkerRef = this.unreadMarkerRef;
const messages = new Map(); const messages = new Map();
let lastMessage = 0; let lastMessage = 0;
@ -238,7 +250,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
lastMessage = envelopes.length + index; lastMessage = envelopes.length + index;
}); });
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy }; const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy, unreadMarkerRef };
return ( return (
<> <>
@ -258,11 +270,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.setState({ idle: false }); this.setState({ idle: false });
this.dismissUnread(); this.dismissUnread();
}} }}
onScroll={({ scrollTop }) => { onScroll={this.onScroll.bind(this)}
if (!this.state.idle && scrollTop > IDLE_THRESHOLD) {
this.setState({ idle: true });
}
}}
data={messages} data={messages}
size={mailboxSize + stationPendingMessages.length} size={mailboxSize + stationPendingMessages.length}
renderer={({ index, measure, scrollWindow }) => { renderer={({ index, measure, scrollWindow }) => {
@ -272,15 +280,14 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
return <MessagePlaceholder key={index} height="64px" index={index} />; return <MessagePlaceholder key={index} height="64px" index={index} />;
} }
const isPending: boolean = 'pending' in msg && Boolean(msg.pending); const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
const isFirstUnread: boolean = Boolean(unreadCount && index === this.firstUnread());
const isLastMessage: boolean = Boolean(index === lastMessage) const isLastMessage: boolean = Boolean(index === lastMessage)
const props = { measure, scrollWindow, isPending, isFirstUnread, msg, ...messageProps }; const isLastRead: boolean = Boolean(!isLastMessage && index === this.state.lastRead);
const props = { measure, scrollWindow, isPending, isLastRead, isLastMessage, msg, ...messageProps };
return ( return (
<ChatMessage <ChatMessage
key={index} key={index}
previousMsg={messages.get(index + 1)} previousMsg={messages.get(index + 1)}
nextMsg={messages.get(index - 1)} nextMsg={messages.get(index - 1)}
className={isLastMessage ? 'pb3' : ''}
{...props} {...props}
/> />
); );