chat: fixes new scroll implementation behavior

Fixes #3482, fixes #3465, fixes #3440
This commit is contained in:
Tyler Brown Cifu Shuster 2020-09-10 11:05:20 -07:00
parent f4b4a6b1fc
commit dc1c23921b
19 changed files with 869 additions and 492 deletions

Binary file not shown.

View File

@ -32,7 +32,6 @@
"react-markdown": "^4.3.1",
"react-oembed-container": "^1.0.0",
"react-router-dom": "^5.0.0",
"react-virtuoso": "^0.20.0",
"remark-disable-tokenizers": "^1.0.24",
"style-loader": "^1.2.1",
"styled-components": "^5.1.0",

View File

@ -69,7 +69,7 @@ export interface Envelope {
uid: string;
number: number;
author: Patp;
when: string;
when: number;
letter: Letter;
}

View File

@ -79,8 +79,8 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
return Boolean(this.chatInput.current?.s3Uploader.current?.inputRef.current);
}
onDragEnter() {
if (!this.readyToUpload()) {
onDragEnter(event) {
if (!this.readyToUpload() || !event.dataTransfer.files.length) {
return;
}
this.setState({ dragover: true });
@ -88,9 +88,14 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
onDrop(event: DragEvent) {
this.setState({ dragover: false });
event.preventDefault();
if (!event.dataTransfer || !event.dataTransfer.files.length) {
return;
}
if (event.dataTransfer.items.length && !event.dataTransfer.files.length) {
event.preventDefault();
return;
}
event.preventDefault();
this.chatInput.current?.uploadFiles(event.dataTransfer.files);
}
@ -135,7 +140,13 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
onDragEnter={this.onDragEnter.bind(this)}
onDragOver={event => {
event.preventDefault();
if (!this.state.dragover) {
if (
!this.state.dragover
&& (
(event.dataTransfer.files.length && event.dataTransfer.files[0].kind === 'file')
|| (event.dataTransfer.items.length && event.dataTransfer.items[0].kind === 'file')
)
) {
this.setState({ dragover: true });
}
}}

View File

@ -5,7 +5,7 @@ export const BacklogElement = (props) => {
return null;
}
return (
<div className="center mw6">
<div className="center mw6 absolute z-9999" style={{ left: 0, right: 0, top: 48}}>
<div className={
"db pa3 ma3 ba b--gray4 bg-gray5 b--gray2-d bg-gray1-d " +
"white-d flex items-center"

View File

@ -1,11 +1,68 @@
import React, { PureComponent, Fragment } from "react";
import React, { PureComponent } from "react";
import moment from "moment";
import { Message } from "./message";
import { Envelope } from "~/types/chat-update";
import _ from "lodash";
export class ChatMessage extends PureComponent {
import { OverlaySigil } from './overlay-sigil';
import { uxToHex, cite, writeText } from '~/logic/lib/util';
import { Envelope, IMessage } from "~/types/chat-update";
import { Group, Association, Contacts, LocalUpdateRemoteContentPolicy } from "~/types";
import TextContent from './content/text';
import CodeContent from './content/code';
import RemoteContent from '~/views/components/RemoteContent';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
export const UnreadMarker = React.forwardRef(({ dayBreak, when, style }, ref) => (
<div ref={ref} className="green2 flex items-center f9 absolute w-100" style={style}>
<hr className="dn-s ma0 w2 b--green2 bt-0" />
<p className="mh4">New messages below</p>
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
{dayBreak
? <p className="gray2 mh4">{moment(when).calendar()}</p>
: null}
<hr style={{ width: "calc(50% - 48px)" }} className="b--green2 ma0 bt-0" />
</div>
));
export const DayBreak = ({ when }) => (
<div className="pv3 gray2 b--gray2 flex items-center justify-center f9 w-100">
<p>{moment(when).calendar()}</p>
</div>
);
interface ChatMessageProps {
measure(element): void;
msg: Envelope | IMessage;
previousMsg: Envelope | IMessage | undefined;
nextMsg: Envelope | IMessage | undefined;
isFirstUnread: boolean;
group: Group;
association: Association;
contacts: Contacts;
unreadRef: React.RefObject<HTMLDivElement>;
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
className: string;
isPending: boolean;
style?: any;
scrollWindow: HTMLDivElement;
}
export default class ChatMessage extends PureComponent<ChatMessageProps> {
private divRef: React.RefObject<HTMLDivElement>;
constructor(props) {
super(props);
this.divRef = React.createRef();
}
componentDidMount() {
if (this.divRef.current) {
this.props.measure(this.divRef.current);
}
}
render() {
const {
msg,
@ -19,74 +76,204 @@ export class ChatMessage extends PureComponent {
hideAvatars,
hideNicknames,
remoteContentPolicy,
className = ''
className = '',
isPending,
style,
measure,
scrollWindow
} = this.props;
// Render sigil if previous message is not by the same sender
const aut = ["author"];
const renderSigil =
_.get(nextMsg, aut) !== _.get(msg, aut, msg.author);
const paddingTop = renderSigil;
const paddingBot =
_.get(previousMsg, aut) !== _.get(msg, aut, msg.author);
const when = ["when"];
const dayBreak =
moment(_.get(nextMsg, when)).format("YYYY.MM.DD") !==
moment(_.get(msg, when)).format("YYYY.MM.DD");
const messageElem = (
<Message
key={msg.uid}
msg={msg}
renderSigil={renderSigil}
paddingTop={paddingTop}
paddingBot={paddingBot}
pending={Boolean(msg.pending)}
group={group}
contacts={contacts}
association={association}
hideNicknames={hideNicknames}
hideAvatars={hideAvatars}
const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1);
const dayBreak = nextMsg && new Date(msg.when).getDate() !== new Date(nextMsg.when).getDate();
const containerClass = `${renderSigil
? `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}`
const timestamp = moment.unix(msg.when / 1000).format(renderSigil ? 'hh:mm a' : 'hh:mm');
const reboundMeasure = (event) => {
return measure(this.divRef.current);
};
const messageProps = {
msg,
timestamp,
contacts,
hideNicknames,
association,
group,
hideAvatars,
remoteContentPolicy,
measure: reboundMeasure.bind(this),
style,
containerClass,
isPending,
scrollWindow
};
return (
<div ref={this.divRef} className={containerClass} style={style} data-number={msg.number}>
{dayBreak && !isFirstUnread ? <DayBreak when={msg.when} /> : null}
{renderSigil
? <MessageWithSigil {...messageProps} />
: <MessageWithoutSigil {...messageProps} />}
{isFirstUnread
? <UnreadMarker ref={unreadRef} dayBreak={dayBreak} when={msg.when} style={{ marginTop: (renderSigil ? "-17px" : "-6px") }} />
: null}
</div>
);
}
}
interface MessageProps {
msg: Envelope | IMessage;
timestamp: string;
group: Group;
association: Association;
contacts: Contacts;
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
containerClass: string;
isPending: boolean;
style: any;
measure(element): void;
scrollWindow: HTMLDivElement;
};
export class MessageWithSigil extends PureComponent<MessageProps> {
render() {
const {
msg,
timestamp,
contacts,
hideNicknames,
association,
group,
hideAvatars,
remoteContentPolicy,
measure,
scrollWindow
} = this.props;
const datestamp = moment.unix(msg.when / 1000).format(DATESTAMP_FORMAT);
const contact = msg.author in contacts ? contacts[msg.author] : false;
const showNickname = !hideNicknames && contact && contact.nickname;
const name = showNickname ? contact.nickname : cite(msg.author);
const color = contact ? `#${uxToHex(contact.color)}` : '#000000';
const sigilClass = contact ? '' : 'mix-blend-diff';
let nameSpan = null;
const copyNotice = (saveName) => {
if (nameSpan !== null) {
nameSpan.innerText = 'Copied';
setTimeout(() => {
nameSpan.innerText = saveName;
}, 800);
}
};
return (
<>
<OverlaySigil
ship={msg.author}
contact={contact}
color={color}
sigilClass={sigilClass}
association={association}
group={group}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
scrollWindow={scrollWindow}
className="fl pr3 v-top bg-white bg-gray0-d"
/>
<div className="fr clamp-message white-d" style={{ flexGrow: 1, marginTop: -8 }}>
<div className="hide-child" style={{ paddingTop: '6px' }}>
<p className="v-mid f9 gray2 dib mr3 c-default">
<span
className={`mw5 db truncate pointer ${showNickname ? '' : 'mono'}`}
ref={e => nameSpan = e}
onClick={() => {
writeText(msg.author);
copyNotice(name);
}}
title={`~${msg.author}`}
>{name}</span>
</p>
<p className="v-mid mono f9 gray2 dib">{timestamp}</p>
<p className="v-mid mono f9 gray2 dib ml2 child dn-s">{datestamp}</p>
</div>
<MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} />
</div>
</>
);
}
}
export const MessageWithoutSigil = ({ timestamp, msg, remoteContentPolicy, measure }) => (
<>
<p className="child pt2 pl2 pr1 mono f9 gray2 dib">{timestamp}</p>
<div className="fr f7 clamp-message white-d pr3 lh-copy" style={{ flexGrow: 1 }}>
<MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure}/>
</div>
</>
);
export const MessageContent = ({ content, remoteContentPolicy, measure }) => {
if ('code' in content) {
return <CodeContent content={content} />;
} else if ('url' in content) {
return (
<RemoteContent
url={content.url}
remoteContentPolicy={remoteContentPolicy}
className={className}
onLoad={measure}
imageProps={{style: {
maxWidth: '18rem'
}}}
videoProps={{style: {
maxWidth: '18rem'
}}}
/>
);
if (isFirstUnread) {
return (
<Fragment key={msg.uid}>
{messageElem}
<div ref={unreadRef}
className="mv2 green2 flex items-center f9">
<hr className="dn-s ma0 w2 b--green2 bt-0" />
<p className="mh4">New messages below</p>
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
{dayBreak && (
<p className="gray2 mh4">
{moment(_.get(msg, when)).calendar()}
</p>
)}
<hr
style={{ width: "calc(50% - 48px)" }}
className="b--green2 ma0 bt-0"
/>
</div>
</Fragment>
);
} else if (dayBreak) {
return (
<Fragment key={msg.uid}>
<div
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
>
<p>{moment(_.get(msg, when)).calendar()}</p>
</div>
{messageElem}
</Fragment>
);
} else {
return messageElem;
}
} else if ('me' in content) {
return (
<p className='f7 i lh-copy v-top'>
{content.me}
</p>
);
}
}
else if ('text' in content) {
return <TextContent content={content} />;
} else {
return null;
}
};
export const MessagePlaceholder = ({ height, index, className = '', style = {}, ...props }) => (
<div className={`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ${className}`} style={{ height, ...style }} {...props}>
<div className="fl pr3 v-top bg-white bg-gray0-d">
<span
className="db bg-gray2 bg-white-d"
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
visibility: (index % 5 == 0) ? "initial" : "hidden",
}}
></span>
</div>
<div className="fr clamp-message white-d" style={{ flexGrow: 1, marginTop: -8 }}>
<div className="hide-child" style={{paddingTop: "6px", visibility: (index % 5 == 0) ? "initial" : "hidden" }}>
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
<span className="mw5 db"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></span>
</p>
<p className="v-mid mono f9 gray2 dib"><span className="bg-gray5 bg-gray1-d db w-100 h-100" style={{height: "1em", width: `${(index % 3 + 1) * 3}em`}}></span></p>
<p className="v-mid mono f9 ml2 gray2 dib child dn-s"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></p>
</div>
<span className="bg-gray5 bg-gray1-d db w-100 h-100 db" style={{height: `1em`, width: `${(index % 5) * 20}%`}}></span>
</div>
</div>
);

View File

@ -1,52 +1,25 @@
import React, { Component, Fragment } from "react";
import { Virtuoso as VirtualList, VirtuosoMethods } from 'react-virtuoso';
import { ChatMessage } from './chat-message';
import { UnreadNotice } from "./unread-notice";
import { ResubscribeElement } from "./resubscribe-element";
import { BacklogElement } from "./backlog-element";
import { Envelope, IMessage } from "~/types/chat-update";
import React, { Component } from "react";
import { RouteComponentProps } from "react-router-dom";
import _ from "lodash";
import GlobalApi from "~/logic/api/global";
import { Patp, Path } from "~/types/noun";
import { Contacts } from "~/types/contact-update";
import { Association } from "~/types/metadata-update";
import { Group } from "~/types/group-update";
import GlobalApi from "~/logic/api/global";
import _ from "lodash";
import { Envelope, IMessage } from "~/types/chat-update";
import { LocalUpdateRemoteContentPolicy } from "~/types";
import { ListRange } from "react-virtuoso/dist/engines/scrollSeekEngine";
import VirtualScroller from "~/views/components/VirtualScroller";
import ChatMessage, { MessagePlaceholder } from './chat-message';
import { UnreadNotice } from "./unread-notice";
import { ResubscribeElement } from "./resubscribe-element";
import { BacklogElement } from "./backlog-element";
const INITIAL_LOAD = 20;
const DEFAULT_BACKLOG_SIZE = 200;
const IDLE_THRESHOLD = 3;
const Placeholder = ({ height, index, className = '', style = {}, ...props }) => (
<div className={`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ${className}`} style={{ height, ...style }} {...props}>
<div className="fl pr3 v-top bg-white bg-gray0-d">
<span
className="db bg-gray2 bg-white-d"
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
visibility: (index % 5 == 0) ? "initial" : "hidden",
}}
></span>
</div>
<div className="fr clamp-message white-d" style={{ flexGrow: 1, marginTop: -8 }}>
<div className="hide-child" style={{paddingTop: "6px", visibility: (index % 5 == 0) ? "initial" : "hidden" }}>
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
<span className="mw5 db"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></span>
</p>
<p className="v-mid mono f9 gray2 dib"><span className="bg-gray5 bg-gray1-d db w-100 h-100" style={{height: "1em", width: `${(index % 3 + 1) * 3}em`}}></span></p>
<p className="v-mid mono f9 ml2 gray2 dib child dn-s"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></p>
</div>
<span className="bg-gray5 bg-gray1-d db w-100 h-100 db" style={{height: `1em`, width: `${(index % 5) * 20}%`}}></span>
</div>
</div>
);
const DEFAULT_BACKLOG_SIZE = 100;
const IDLE_THRESHOLD = 64;
type ChatWindowProps = RouteComponentProps<{
ship: Patp;
@ -74,67 +47,93 @@ type ChatWindowProps = RouteComponentProps<{
interface ChatWindowState {
fetchPending: boolean;
idle: boolean;
range: ListRange;
initialized: boolean;
lastMessageNumber: number;
messagesToRender: Map<number, Envelope | IMessage>;
}
export class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
private unreadReference: React.RefObject<Component>;
private virtualList: React.RefObject<VirtuosoMethods>;
private unreadReference: React.RefObject<HTMLDivElement>;
private virtualList: VirtualScroller | null;
INITIALIZATION_MAX_TIME = 1500;
constructor(props) {
super(props);
this.state = {
fetchPending: false,
idle: (this.initialIndex() < props.mailboxSize - IDLE_THRESHOLD) ? true : false,
range: { startIndex: 0, endIndex: 0},
initialized: false
idle: true,
initialized: false,
lastMessageNumber: 0,
messagesToRender: new Map(),
};
this.dismissUnread = this.dismissUnread.bind(this);
this.initialIndex = this.initialIndex.bind(this);
this.scrollToUnread = this.scrollToUnread.bind(this);
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
this.assembleMessages = this.assembleMessages.bind(this);
this.virtualList = null;
this.unreadReference = React.createRef();
this.virtualList = React.createRef();
}
componentDidMount() {
window.addEventListener('blur', this.handleWindowBlur);
window.addEventListener('focus', this.handleWindowFocus);
this.initialFetch();
this.assembleMessages();
setTimeout(() => {
this.setState({ initialized: true });
}, this.INITIALIZATION_MAX_TIME);
}
componentWillUnmount() {
window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('focus', this.handleWindowFocus);
}
handleWindowBlur() {
this.setState({ idle: true });
}
handleWindowFocus() {
this.setState({ idle: false });
}
initialIndex() {
const { mailboxSize, unreadCount } = this.props;
return Math.min(Math.max(mailboxSize - 1 < INITIAL_LOAD
const { unreadCount } = this.props;
const { lastMessageNumber } = this.state;
return Math.min(Math.max(lastMessageNumber - 1 < INITIAL_LOAD
? 0
: unreadCount // otherwise if there are unread messages
? mailboxSize - unreadCount - 1 // put the one right before at the top
: mailboxSize - 1,
0), mailboxSize);
? lastMessageNumber - unreadCount // put the one right before at the top
: lastMessageNumber,
0), lastMessageNumber);
}
initialFetch() {
const { envelopes, mailboxSize, unreadCount } = this.props;
if (envelopes.length > 0) {
const start = Math.min(mailboxSize - unreadCount, mailboxSize - DEFAULT_BACKLOG_SIZE);
this.fetchMessages(start, start + DEFAULT_BACKLOG_SIZE, true);
const initialIndex = this.initialIndex();
if (initialIndex < mailboxSize - IDLE_THRESHOLD) {
this.setState({ idle: true });
}
if (unreadCount !== mailboxSize) {
this.virtualList.current?.scrollToIndex({
index: initialIndex,
align: initialIndex <= 1 ? 'end' : 'start'
});
setTimeout(() => {
this.stayLockedIfActive();
this.fetchMessages(start, start + DEFAULT_BACKLOG_SIZE, true).then(() => {
if (!this.virtualList) return;
const initialIndex = this.initialIndex();
this.virtualList.scrollToData(initialIndex).then(() => {
if (
initialIndex === mailboxSize
|| (this.virtualList && this.virtualList.window && this.virtualList.window.scrollTop === 0)
) {
this.setState({ idle: false });
this.dismissUnread();
}
this.setState({ initialized: true });
}, 500);
} else {
this.setState({ initialized: true });
}
});
});
} else {
setTimeout(() => {
this.initialFetch();
@ -142,9 +141,8 @@ export class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
}
}
componentDidUpdate(prevProps, prevState) {
const { isChatMissing, history, envelopes, mailboxSize, unreadCount } = this.props;
let { idle } = this.state;
componentDidUpdate(prevProps: ChatWindowProps, prevState) {
const { isChatMissing, history, envelopes, mailboxSize, stationPendingMessages } = this.props;
if (isChatMissing) {
history.push("/~chat");
@ -152,45 +150,67 @@ export class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
this.setState({ fetchPending: false });
}
if (this.state.range.endIndex !== prevState.range.endIndex) {
if (this.state.range.endIndex < mailboxSize - IDLE_THRESHOLD) {
if (!idle) {
idle = true;
if ((mailboxSize !== prevProps.mailboxSize) || (envelopes.length !== prevProps.envelopes.length)) {
this.virtualList?.calculateVisibleItems();
this.stayLockedIfActive();
this.assembleMessages();
}
if (stationPendingMessages.length !== prevProps.stationPendingMessages.length) {
this.virtualList?.calculateVisibleItems();
this.virtualList?.scrollToData(mailboxSize);
this.assembleMessages();
}
if (!this.state.fetchPending && prevState.fetchPending) {
this.virtualList?.calculateVisibleItems();
}
}
assembleMessages() {
const { envelopes, stationPendingMessages } = this.props;
const messages: Map<number, Envelope | IMessage> = new Map();
let lastMessageNumber = 0;
[...envelopes]
.sort((a, b) => a.when - b.when)
.forEach((message) => {
messages.set(message.number, message);
if (message.number > lastMessageNumber) {
lastMessageNumber = message.number;
}
} else if (idle && (unreadCount === 0 || this.state.range.endIndex === 0)) {
idle = false;
}
this.setState({ idle });
}
});
if (!idle && idle !== prevState.idle) {
setTimeout(() => {
this.virtualList.current?.scrollToIndex(mailboxSize);
}, 500)
if (lastMessageNumber !== this.state.lastMessageNumber) {
this.setState({ lastMessageNumber });
}
stationPendingMessages.sort((a, b) => a.when - b.when).forEach((message, index) => {
messages.set(lastMessageNumber + index + 1, message);
});
if (!idle && prevProps.unreadCount !== unreadCount) {
this.virtualList.current?.scrollToIndex(mailboxSize);
}
this.setState({ messagesToRender: messages });
}
if (!idle && envelopes.length !== prevProps.envelopes.length) {
this.virtualList.current?.scrollToIndex(mailboxSize);
stayLockedIfActive() {
if (this.virtualList && !this.state.idle) {
this.virtualList.resetScroll();
this.dismissUnread();
}
}
scrollToUnread() {
const { mailboxSize, unreadCount } = this.props;
this.virtualList.current?.scrollToIndex({
index: mailboxSize - unreadCount,
align: 'center'
});
this.virtualList?.scrollToData(mailboxSize - unreadCount);
}
dismissUnread() {
if (this.state.fetchPending) return;
if (this.props.unreadCount === 0) return;
this.props.api.chat.read(this.props.station);
}
fetchMessages(start, end, force = false) {
fetchMessages(start, end, force = false): Promise<void> {
start = Math.max(start, 0);
end = Math.max(end, 0);
const { api, mailboxSize, station } = this.props;
@ -200,21 +220,20 @@ export class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
mailboxSize <= 0)
&& !force
) {
return;
return new Promise((resolve, reject) => {});
}
api.chat
this.setState({ fetchPending: true });
return api.chat
.fetchMessages(Math.max(mailboxSize - end, 0), Math.min(mailboxSize - start, mailboxSize), station)
.finally(() => {
this.setState({ fetchPending: false });
});
this.setState({ fetchPending: true });
}
render() {
const {
envelopes,
stationPendingMessages,
unreadCount,
unreadMsg,
@ -232,76 +251,69 @@ export class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
remoteContentPolicy,
} = this.props;
const messages: Envelope[] = [];
const debouncedFetch = _.debounce(this.fetchMessages, 500).bind(this);
envelopes
.forEach((message) => {
messages[message.number] = message;
});
stationPendingMessages.sort((a, b) => a.when - b.when).forEach((message, index) => {
messages[mailboxSize + index + 1] = message;
});
const messages = this.state.messagesToRender;
return (
<Fragment>
<>
<UnreadNotice
unreadCount={unreadCount}
unreadMsg={this.state.idle ? unreadMsg : false}
unreadMsg={unreadCount === 1 && unreadMsg && unreadMsg.author === window.ship ? false : unreadMsg}
dismissUnread={this.dismissUnread}
onClick={this.scrollToUnread}
/>
<BacklogElement isChatLoading={isChatLoading} />
<ResubscribeElement {...{ api, host: ship, station, isChatUnsynced}} />
{messages.length ? <VirtualList
ref={this.virtualList}
style={{height: '100%', width: '100%', visibility: this.state.initialized ? 'initial' : 'hidden'}}
totalCount={mailboxSize + stationPendingMessages.length}
followOutput={!this.state.idle}
endReached={this.dismissUnread}
scrollSeek={{
enter: velocity => Math.abs(velocity) > 2000,
exit: velocity => Math.abs(velocity) < 200,
change: (_velocity, _range) => {},
placeholder: this.state.initialized ? Placeholder : () => <div></div>
<VirtualScroller
ref={list => {this.virtualList = list}}
origin="bottom"
style={{ height: '100%' }}
onStartReached={() => {
this.setState({ idle: false });
this.dismissUnread();
}}
startReached={() => debouncedFetch(0, DEFAULT_BACKLOG_SIZE)}
overscan={DEFAULT_BACKLOG_SIZE}
rangeChanged={(range) => {
this.setState({ range });
debouncedFetch(range.startIndex - (DEFAULT_BACKLOG_SIZE / 2), range.endIndex + (DEFAULT_BACKLOG_SIZE / 2));
}}
item={(i) => {
const number = i + 1;
const msg = messages[number];
if (!msg) {
debouncedFetch(number - DEFAULT_BACKLOG_SIZE, number + DEFAULT_BACKLOG_SIZE);
return <Placeholder index={number} height="0px" style={{overflow: 'hidden'}} />;
onScroll={({ scrollTop }) => {
if (!this.state.idle && scrollTop > IDLE_THRESHOLD) {
this.setState({ idle: true });
}
return <ChatMessage
key={number}
unreadRef={this.unreadReference}
isFirstUnread={
unreadCount
&& mailboxSize - unreadCount === number
&& this.state.idle
}
msg={msg}
previousMsg={messages[number + 1]}
nextMsg={messages[number - 1]}
association={association}
group={group}
contacts={contacts}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
remoteContentPolicy={remoteContentPolicy}
className={number === mailboxSize + stationPendingMessages.length ? 'pb3' : ''}
/>
}}
/> : <div style={{height: '100%', width: '100%'}}></div>}
</Fragment>
data={messages}
size={mailboxSize + stationPendingMessages.length}
renderer={({ index, measure, scrollWindow }) => {
const msg = messages.get(index);
if (!msg) return null;
if (!this.state.initialized) {
return <MessagePlaceholder key={index} height="64px" index={index} />;
}
return (
<ChatMessage
measure={measure}
scrollWindow={scrollWindow}
key={index}
unreadRef={this.unreadReference}
isPending={msg && 'pending' in msg && Boolean(msg.pending)}
isFirstUnread={
Boolean(unreadCount
&& this.state.lastMessageNumber - unreadCount === index
&& !(unreadCount === 1 && msg.author === window.ship))
}
msg={msg}
previousMsg={messages.get(index + 1)}
nextMsg={messages.get(index - 1)}
association={association}
group={group}
contacts={contacts}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
remoteContentPolicy={remoteContentPolicy}
className={index === this.state.lastMessageNumber + stationPendingMessages.length ? 'pb3' : ''}
/>
);
}}
loadRows={(start, end) => {
this.fetchMessages(start, end);
}}
/>
</>
);
}
}

View File

@ -27,6 +27,21 @@ const DISABLED_INLINE_TOKENS = [
const MessageMarkdown = React.memo(props => (
<ReactMarkdown
{...props}
unwrapDisallowed={true}
allowNode={(node, index, parent) => {
if (
node.type === 'blockquote'
&& parent.type === 'root'
&& node.children.length
&& node.children[0].type === 'paragraph'
&& node.children[0].position.start.offset < 2
) {
node.children[0].children[0].value = '>' + node.children[0].children[0].value;
return false;
}
return true;
}}
plugins={[[
RemarkDisableTokenizers,
{ block: DISABLED_BLOCK_TOKENS, inline: DISABLED_INLINE_TOKENS }

View File

@ -28,19 +28,19 @@ export class GroupItem extends Component {
return bWhen - aWhen;
} else {
const aAssociation = a in props.chatMetadata ? props.chatMetadata[a] : {};
const bAssociation = b in props.chatMetadata ? props.chatMetadata[b] : {};
let aTitle = a;
let bTitle = b;
if (aAssociation.metadata && aAssociation.metadata.title) {
aTitle = (aAssociation.metadata.title !== '')
? aAssociation.metadata.title : a;
}
if (bAssociation.metadata && bAssociation.metadata.title) {
bTitle =
bAssociation.metadata.title !== '' ? bAssociation.metadata.title : b;
}
return aTitle.toLowerCase().localeCompare(bTitle.toLowerCase());
const aAssociation = a in props.chatMetadata ? props.chatMetadata[a] : {};
const bAssociation = b in props.chatMetadata ? props.chatMetadata[b] : {};
let aTitle = a;
let bTitle = b;
if (aAssociation.metadata && aAssociation.metadata.title) {
aTitle = (aAssociation.metadata.title !== '')
? aAssociation.metadata.title : a;
}
if (bAssociation.metadata && bAssociation.metadata.title) {
bTitle =
bAssociation.metadata.title !== '' ? bAssociation.metadata.title : b;
}
return aTitle.toLowerCase().localeCompare(bTitle.toLowerCase());
}
}).map((each, i) => {
const unread = props.unreads[each];

View File

@ -1,44 +0,0 @@
import React, { Component } from 'react';
import TextContent from './content/text';
import CodeContent from './content/code';
import RemoteContent from '~/views/components/RemoteContent';
export default class MessageContent extends Component {
render() {
const { props } = this;
const content = props.letter;
if ('code' in content) {
return <CodeContent content={content} />;
} else if ('url' in content) {
return (
<RemoteContent
url={content.url}
remoteContentPolicy={props.remoteContentPolicy}
imageProps={{style: {
maxWidth: '18rem'
}}}
videoProps={{style: {
maxWidth: '18rem'
}}}
/>
);
} else if ('me' in content) {
return (
<p className='f7 i lh-copy v-top'>
{content.me}
</p>
);
}
else if ('text' in content) {
return <TextContent content={content} />;
} else {
return null;
}
}
}

View File

@ -1,124 +0,0 @@
import React from 'react';
import { OverlaySigil } from './overlay-sigil';
import MessageContent from './message-content';
import { uxToHex, cite, writeText } from '~/logic/lib/util';
import moment from 'moment';
export const Message = (props) => {
const {
msg,
renderSigil,
remoteContentPolicy,
className = ''
} = props;
const pending = msg.pending ? ' o-40' : '';
const containerClass =
renderSigil
? `w-100 f7 pl3 pt4 pr3 cf flex lh-copy ${pending} ${className}`
: `w-100 pr3 cf hide-child flex ${pending} ${className}`;
const timestamp =
moment.unix(msg.when / 1000).format(
renderSigil ? 'hh:mm a' : 'hh:mm'
);
return (
<div className={containerClass}
style={{
minHeight: 'min-content'
}}>
{
renderSigil ? (
renderWithSigil(props, timestamp)
) : (
<div className="flex w-100">
<p className="child pt2 pl2 pr1 mono f9 gray2 dib">{timestamp}</p>
<div className="fr f7 clamp-message white-d pr3 lh-copy"
style={{ flexGrow: 1 }}>
<MessageContent letter={msg.letter} remoteContentPolicy={remoteContentPolicy}/>
</div>
</div>
)
}
</div>
);
};
const renderWithSigil = (props, timestamp) => {
const paddingTop = props.paddingTop ? { 'paddingTop': '6px' } : '';
const datestamp =
'~' + moment.unix(props.msg.when / 1000).format('YYYY.M.D');
const contact = props.msg.author in props.contacts
? props.contacts[props.msg.author] : false;
const showNickname = !props.hideNicknames && contact?.nickname;
let name = `~${props.msg.author}`;
let color = '#000000';
let sigilClass = 'mix-blend-diff';
if (contact) {
name = showNickname
? contact.nickname
: `~${props.msg.author}`;
color = `#${uxToHex(contact.color)}`;
sigilClass = '';
}
if (`~${props.msg.author}` === name) {
name = cite(props.msg.author);
}
let nameSpan = null;
const copyNotice = (saveName) => {
if (nameSpan !== null) {
nameSpan.innerText = 'Copied';
setTimeout(() => {
nameSpan.innerText = saveName;
}, 800);
}
};
return (
<div className="flex w-100">
<OverlaySigil
ship={props.msg.author}
contact={contact}
color={color}
sigilClass={sigilClass}
association={props.association}
group={props.group}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames}
className="fl pr3 v-top bg-white bg-gray0-d"
/>
<div className="fr clamp-message white-d"
style={{ flexGrow: 1, marginTop: -8 }}>
<div className="hide-child" style={paddingTop}>
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
<span
className={
'mw5 db truncate pointer ' +
(showNickname ? '' : 'mono')
}
ref={(e) => nameSpan = e}
onClick={() => {
writeText(`~${props.msg.author}`);
copyNotice(name);
}}
title={`~${props.msg.author}`}
>
{name}
</span>
</p>
<p className={`v-mid mono f9 gray2 dib`}>{timestamp}</p>
<p className={`v-mid mono f9 ml2 gray2 dib child dn-s`}>
{datestamp}
</p>
</div>
<MessageContent letter={props.msg.letter} remoteContentPolicy={props.remoteContentPolicy} />
</div>
</div>
);
};

View File

@ -1,11 +1,11 @@
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
import { Sigil } from '~/logic/lib/sigil';
import {
ProfileOverlay,
OVERLAY_HEIGHT
} from './profile-overlay';
export class OverlaySigil extends Component {
export class OverlaySigil extends PureComponent {
constructor() {
super();
this.state = {
@ -19,40 +19,29 @@ export class OverlaySigil extends Component {
this.profileShow = this.profileShow.bind(this);
this.profileHide = this.profileHide.bind(this);
this.updateContainerOffset = this.updateContainerOffset.bind(this);
this.updateContainerInterval = null;
}
profileShow() {
this.updateContainerOffset();
this.setState({ profileClicked: true });
this.updateContainerInterval = setInterval(
this.updateContainerOffset.bind(this),
1000
);
this.props.scrollWindow.addEventListener('scroll', this.updateContainerOffset);
}
profileHide() {
this.setState({ profileClicked: false });
if(this.updateContainerInterval) {
clearInterval(this.updateContainerInterval);
this.updateContainerInterval = null;
}
this.props.scrollWindow.removeEventListener('scroll', this.updateContainerOffset, true);
}
updateContainerOffset() {
if (this.containerRef && this.containerRef.current) {
const parent = this.containerRef.current.offsetParent;
const { offsetTop } = this.containerRef.current;
const container = this.containerRef.current;
const scrollWindow = this.props.scrollWindow;
let bottomSpace, topSpace;
const bottomSpace = scrollWindow.scrollHeight - container.offsetTop - scrollWindow.scrollTop;
const topSpace = scrollWindow.offsetHeight - bottomSpace - OVERLAY_HEIGHT;
if(navigator.userAgent.includes('Firefox')) {
topSpace = offsetTop - parent.scrollTop - OVERLAY_HEIGHT / 2;
bottomSpace = parent.clientHeight - topSpace - OVERLAY_HEIGHT;
} else {
topSpace = offsetTop + parent.scrollHeight - parent.clientHeight - parent.scrollTop;
bottomSpace = parent.clientHeight - topSpace - OVERLAY_HEIGHT;
}
this.setState({
topSpace,
bottomSpace
@ -60,6 +49,10 @@ export class OverlaySigil extends Component {
}
}
componentWillUnmount() {
this.props.scrollWindow?.removeEventListener('scroll', this.updateContainerOffset, true);
}
render() {
const { props, state } = this;
const { hideAvatars } = props;

View File

@ -1,11 +1,11 @@
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
import { Link } from 'react-router-dom';
import { cite } from '~/logic/lib/util';
import { Sigil } from '~/logic/lib/sigil';
export const OVERLAY_HEIGHT = 250;
export class ProfileOverlay extends Component {
export class ProfileOverlay extends PureComponent {
constructor() {
super();

View File

@ -1,17 +1,18 @@
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
import { Link } from 'react-router-dom';
import { Virtuoso as VirtualList } from 'react-virtuoso';
import { ContactItem } from './contact-item';
import { ShareSheet } from './share-sheet';
import { Sigil } from '~/logic/lib/sigil';
import { Spinner } from '~/views/components/Spinner';
import { cite } from '~/logic/lib/util';
import { roleForShip, resourceFromPath } from '~/logic/lib/group';
import { Path, PatpNoSig } from '~/types/noun';
import { Rolodex, Contacts, Contact } from '~/types/contact-update';
import { Contacts } from '~/types/contact-update';
import { Groups, Group } from '~/types/group-update';
import GlobalApi from '~/logic/api/global';
import VirtualScroller from '~/views/components/VirtualScroller';
import { ContactItem } from './contact-item';
import { ShareSheet } from './share-sheet';
interface ContactSidebarProps {
activeDrawer: 'contacts' | 'detail' | 'rightPanel';
@ -23,28 +24,25 @@ interface ContactSidebarProps {
defaultContacts: Contacts;
selectedContact?: PatpNoSig;
}
interface ContactSidebarState {
awaiting: boolean;
memberboxHeight: number;
}
export class ContactSidebar extends PureComponent<ContactSidebarProps, ContactSidebarState> {
private virtualList: VirtualScroller | null;
export class ContactSidebar extends Component<ContactSidebarProps, ContactSidebarState> {
constructor(props) {
super(props);
this.state = {
awaiting: false,
memberboxHeight: 0
awaiting: false
};
this.memberbox = this.memberbox.bind(this);
this.virtualList = null;
}
memberbox(element) {
if (element) {
this.setState({
memberboxHeight: element.getBoundingClientRect().height
})
componentDidUpdate(prevProps: ContactSidebarProps, prevState: ContactSidebarState) {
if (prevProps.path !== this.props.path && this.virtualList) {
this.virtualList.calculateVisibleItems();
}
}
@ -158,8 +156,16 @@ export class ContactSidebar extends Component<ContactSidebarProps, ContactSideba
const detailHref = `/~groups/detail${props.path}`;
const items = new Map();
groupItems.forEach((item, index) => {
items.set(index + 1, item);
});
contactItems.forEach((item, index) => {
items.set(index + groupItems.length + 1, item);
});
return (
<div ref={this.memberbox} className={'bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100 ' +
<div className={'bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100 ' +
'flex-basis-100-s flex-basis-30-ns mw5-m mw5-l mw5-xl relative ' +
'overflow-hidden flex-shrink-0 ' + responsiveClasses}
>
@ -180,18 +186,17 @@ export class ContactSidebar extends Component<ContactSidebarProps, ContactSideba
>Channels</Link>
{shareSheet}
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Members</h2>
<VirtualList
style={{ height: this.state.memberboxHeight, width: '100%' }}
className="flex-auto"
totalCount={contactItems.length + groupItems.length}
itemHeight={44} // We happen to know this
item={
(index) => index <= (contactItems.length - 1) // If the index is within the length of contact items,
? contactItems[index] // show a contact item
: groupItems[index - contactItems.length] // Otherwise show a group item
}
<VirtualScroller
ref={list => {this.virtualList = list}}
origin="top"
style={{ height: '100%' }}
loadRows={(start, end) => {}}
size={contactItems.length + groupItems.length}
data={items}
renderer={({ index, measure, scrollWindow }) => {
return <div key={index} onLoad={event => measure(event.target)}>{items.get(index)}</div>;
}}
/>
</div>
<Spinner awaiting={this.state.awaiting} text="Removing from group..." classes="pa2 ba absolute right-1 bottom-1 b--gray1-d" />
</div>

View File

@ -12,7 +12,7 @@ class ErrorComponent extends Component<ErrorProps> {
render () {
const { code, error, history, description } = this.props;
return (
<Col alignItems="center" justifyContent="center" height="100%" p="4" backgroundColor="white">
<Col alignItems="center" justifyContent="center" height="100%" p="4" backgroundColor="white" maxHeight="100%">
<Box mb={4}>
<Text fontSize={3}>
{code ? code : 'Error'}
@ -20,13 +20,13 @@ class ErrorComponent extends Component<ErrorProps> {
</Box>
{ description && (<Box mb={4}><Text>{description}</Text></Box>) }
{error && (
<Box mb={4}>
<Box mb={4} style={{maxWidth: '100%'}}>
<Box mb={2}>
<Text fontFamily="mono"><code>&ldquo;{error.message}&rdquo;</code></Text>
</Box>
<details>
<summary>Stack trace</summary>
<pre style={{ wordWrap: 'break-word' }} className="tl">{error.stack}</pre>
<pre style={{ wordWrap: 'break-word', overflowX: 'scroll' }} className="tl">{error.stack}</pre>
</details>
</Box>
)}

View File

@ -1,9 +1,11 @@
import React, { Component } from 'react';
import _, { capitalize } from 'lodash';
import { Virtuoso as VirtualList } from 'react-virtuoso';
import { cite, deSig } from '~/logic/lib/util';
import { roleForShip, resourceFromPath } from '~/logic/lib/group';
import VirtualScroller from '~/views/components/VirtualScroller';
import {
Group,
InvitePolicy,
@ -203,11 +205,12 @@ export class GroupView extends Component<
});
}
memberElements() {
memberElements(): Map<number, JSX.Element> {
const { group, permissions } = this.props;
const { members } = group;
const isAdmin = this.isAdmin();
return Array.from(members).map((ship) => {
const map: Map<number, JSX.Element> = new Map();
Array.from(members).sort((a, b) => b.localeCompare(a)).map((ship, index) => {
const role = roleForShip(group, deSig(ship));
const onRoleRemove =
role && isAdmin
@ -218,7 +221,7 @@ export class GroupView extends Component<
const [present, missing] = this.getAppTags(ship);
const options = this.optionsForShip(ship, missing);
return (
map.set(index, (
<GroupMember ship={ship} options={options}>
{((permissions && role) || present.length > 0) && (
<div className='flex mt1'>
@ -240,8 +243,9 @@ export class GroupView extends Component<
</div>
)}
</GroupMember>
);
})
));
});
return map;
}
setInvites(invites: Invites) {
@ -332,10 +336,15 @@ export class GroupView extends Component<
{'open' in group.policy && this.renderBanned(group.policy)}
<div className='flex flex-column'>
<div className='f9 gray2 mt6 mb3'>Members</div>
<VirtualList
<VirtualScroller
size={memberElements.size}
style={{ height: '500px', width: '100%' }}
totalCount={memberElements.length}
item={(index) => <div key={index} className='flex flex-column pv3'>{memberElements[index]}</div>}
origin="top"
loadRows={(start, end) => {}}
data={memberElements}
renderer={({ index, measure, scrollWindow }) => {
return <div key={index} onLoad={event => measure(event.target)} className='flex flex-column pv3'>{memberElements.get(index)}</div>;
}}
/>
</div>

View File

@ -1,18 +1,21 @@
import React, { Component, Fragment } from 'react';
import React, { PureComponent, Fragment } from 'react';
import { LocalUpdateRemoteContentPolicy } from "~/types/local-update";
import { Button } from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser';
import EmbedContainer from 'react-oembed-container';
import { memoize } from 'lodash';
interface RemoteContentProps {
url: string;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
unfold: boolean;
renderUrl: boolean;
imageProps: any;
audioProps: any;
videoProps: any;
oembedProps: any;
unfold?: boolean;
renderUrl?: boolean;
imageProps?: any;
audioProps?: any;
videoProps?: any;
oembedProps?: any;
style?: any;
onLoad?(): void;
}
interface RemoteContentState {
@ -24,7 +27,10 @@ const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
export default class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
const memoizedFetch = memoize(fetch);
export default class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState> {
private fetchController: AbortController | undefined;
constructor(props) {
super(props);
this.state = {
@ -36,20 +42,29 @@ export default class RemoteContent extends Component<RemoteContentProps, RemoteC
this.wrapInLink = this.wrapInLink.bind(this);
}
componentWillUnmount() {
if (this.fetchController) {
this.fetchController.abort();
}
}
unfoldEmbed() {
let unfoldState = this.state.unfold;
unfoldState = !unfoldState;
this.setState({ unfold: unfoldState });
setTimeout(this.props.onLoad, 500);
}
loadOembed() {
fetch(`https://noembed.com/embed?url=${this.props.url}`)
.then(response => response.json())
this.fetchController = new AbortController();
memoizedFetch(`https://noembed.com/embed?url=${this.props.url}`, {
signal: this.fetchController.signal
})
.then(response => response.clone().json())
.then((result) => {
this.setState({ embed: result });
}).catch((error) => {
this.setState({ embed: 'error' });
console.log('error fetching oembed', error);
});
}
@ -74,6 +89,8 @@ export default class RemoteContent extends Component<RemoteContentProps, RemoteC
audioProps = {},
videoProps = {},
oembedProps = {},
style = {},
onLoad = () => {},
...props
} = this.props;
const isImage = IMAGE_REGEX.test(url);
@ -85,6 +102,8 @@ export default class RemoteContent extends Component<RemoteContentProps, RemoteC
return this.wrapInLink(
<img
src={url}
style={style}
onLoad={onLoad}
{...imageProps}
{...props}
/>
@ -97,6 +116,7 @@ export default class RemoteContent extends Component<RemoteContentProps, RemoteC
controls
className="db"
src={url}
style={style}
{...audioProps}
{...props}
/>
@ -110,6 +130,8 @@ export default class RemoteContent extends Component<RemoteContentProps, RemoteC
controls
className="db"
src={url}
style={style}
onLoad={onLoad}
{...videoProps}
{...props}
/>
@ -133,6 +155,8 @@ export default class RemoteContent extends Component<RemoteContentProps, RemoteC
</Button> : null}
<div
className={'embed-container mb2 w-100 w-75-l w-50-xl ' + (this.state.unfold ? 'db' : 'dn')}
style={style}
onLoad={onLoad}
{...oembedProps}
{...props}
>

View File

@ -0,0 +1,290 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
interface VirtualScrollerProps {
origin: 'top' | 'bottom';
loadRows(start: number, end: number): void;
data: Map<number, any>;
renderer(index): JSX.Element | null;
onStartReached?(): void;
onEndReached?(): void;
size: number;
onCalculateVisibleItems?(visibleItems: Map<number, JSX.Element>): void;
onScroll?({ scrollTop, scrollHeight, windowHeight }): void;
style?: any;
}
interface VirtualScrollerState {
startgap: number | undefined;
visibleItems: Map<number, Element>;
endgap: number | undefined;
totalHeight: number;
averageHeight: number;
scrollTop: number;
}
export default class VirtualScroller extends PureComponent<VirtualScrollerProps, VirtualScrollerState> {
private scrollContainer: React.RefObject<HTMLDivElement>;
public window: HTMLDivElement | null;
private cache: Map<number, any>;
private pendingLoad: {
start: number;
end: number
timeout: ReturnType<typeof setTimeout>;
} | undefined;
OVERSCAN_SIZE = 100; // Minimum number of messages on either side before loadRows is called
constructor(props) {
super(props);
this.state = {
startgap: props.origin === 'top' ? 0 : undefined,
visibleItems: new Map(),
endgap: props.origin === 'bottom' ? 0 : undefined,
totalHeight: 0,
averageHeight: 64,
scrollTop: props.origin === 'top' ? 0 : Infinity
};
this.scrollContainer = React.createRef();
this.window = null;
this.cache = new Map();
this.recalculateTotalHeight = this.recalculateTotalHeight.bind(this);
this.calculateVisibleItems = this.calculateVisibleItems.bind(this);
this.estimateIndexFromScrollTop = this.estimateIndexFromScrollTop.bind(this);
this.heightOf = this.heightOf.bind(this);
this.setScrollTop = this.setScrollTop.bind(this);
this.scrollToData = this.scrollToData.bind(this);
this.loadRows = _.memoize(this.loadRows).bind(this);
}
componentDidMount() {
this.calculateVisibleItems();
}
componentDidUpdate(prevProps: VirtualScrollerProps, prevState: VirtualScrollerState) {
const {
scrollContainer, window,
props: { origin },
state: { totalHeight, scrollTop }
} = this;
}
scrollToData(targetIndex: number): Promise<void> {
if (!this.window) {
return new Promise((resolve, reject) => {reject()});
}
const { offsetHeight } = this.window;
let scrollTop = 0;
let itemHeight = 0;
new Map([...this.props.data].reverse()).forEach((datum, index) => {
const height = this.heightOf(index);
if (index >= targetIndex) {
scrollTop += height;
if (index === targetIndex) {
itemHeight = height;
}
}
});
return this.setScrollTop(scrollTop - (offsetHeight / 2) + itemHeight);
}
recalculateTotalHeight() {
let { averageHeight } = this.state;
let totalHeight = 0;
this.props.data.forEach((datum, index) => {
totalHeight += this.heightOf(index);
});
averageHeight = Number((totalHeight / this.props.data.size).toFixed());
totalHeight += (this.props.size - this.props.data.size) * averageHeight;
this.setState({ totalHeight, averageHeight });
}
estimateIndexFromScrollTop(targetScrollTop: number): number | void {
if (!this.window) return;
let index = this.props.size;
const { averageHeight } = this.state;
let height = 0;
while (height < targetScrollTop) {
const itemHeight = this.cache.has(index) ? this.cache.get(index).height : averageHeight;
height += itemHeight;
index--;
}
return index;
}
heightOf(index: number): number {
return this.cache.has(index) ? this.cache.get(index).height : this.state.averageHeight;
}
calculateVisibleItems() {
if (!this.window) return;
let startgap = 0, heightShown = 0, endgap = 0;
let startGapFilled = false;
let visibleItems = new Map();
let startBuffer = new Map();
let endBuffer = new Map();
const { scrollTop, offsetHeight: windowHeight } = this.window;
const { averageHeight } = this.state;
const { data, size: totalSize, onCalculateVisibleItems } = this.props;
const items = new Map([...data].reverse());
items.forEach((datum, index) => {
const height = this.heightOf(index);
if (startgap < scrollTop && !startGapFilled) {
startBuffer.set(index, datum);
startgap += height;
} else if (heightShown < windowHeight) {
startGapFilled = true;
visibleItems.set(index, datum);
heightShown += height;
} else if (endBuffer.size < visibleItems.size) {
endBuffer.set(index, data.get(index));
} else {
endgap += height;
}
});
endgap += Math.abs(totalSize - data.size) * averageHeight;
startBuffer = new Map([...startBuffer].reverse().slice(0, visibleItems.size));
startBuffer.forEach((datum, index) => {
startgap -= this.heightOf(index);
});
visibleItems = new Map([...visibleItems].reverse());
endBuffer = new Map([...endBuffer].reverse());
const firstVisibleKey = Array.from(visibleItems.keys())[0] ?? this.estimateIndexFromScrollTop(scrollTop);
const firstNeededKey = Math.max(firstVisibleKey - this.OVERSCAN_SIZE, 0);
if (!data.has(firstNeededKey + 1)) {
this.loadRows(firstNeededKey, firstVisibleKey - 1);
}
const lastVisibleKey = Array.from(visibleItems.keys())[visibleItems.size - 1] ?? this.estimateIndexFromScrollTop(scrollTop + windowHeight);
const lastNeededKey = Math.min(lastVisibleKey + this.OVERSCAN_SIZE, totalSize)
if (!data.has(lastNeededKey - 1)) {
this.loadRows(lastVisibleKey + 1, lastNeededKey);
}
onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null;
this.setState({
startgap: Number(startgap.toFixed()),
visibleItems: new Map([...endBuffer, ...visibleItems, ...startBuffer]),
endgap: Number(endgap.toFixed()),
});
}
loadRows(start, end) {
if (isNaN(start) || isNaN(end)) return;
if (this.pendingLoad?.timeout) {
clearTimeout(this.pendingLoad.timeout);
start = Math.min(start, this.pendingLoad.start);
end = Math.max(end, this.pendingLoad.end);
}
this.pendingLoad = {
timeout: setTimeout(() => {
if (!this.pendingLoad) return;
start = Math.max(this.pendingLoad.start, 0);
end = Math.min(Math.max(this.pendingLoad.end, 0), this.props.size);
if (start < end) {
this.props.loadRows(start, end);
}
clearTimeout(this.pendingLoad.timeout);
this.pendingLoad = undefined;
}, 500),
start, end
};
}
setWindow(element) {
if (this.window) return;
this.window = element;
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
event.preventDefault();
element.scrollBy(0, event.deltaY * -1);
return false;
}, { passive: false });
}
this.resetScroll();
}
resetScroll(): Promise<void> {
if (!this.window) return new Promise((resolve, reject) => {reject()});
return this.setScrollTop(0);
}
setScrollTop(distance: number, delay: number = 100): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (!this.window) {
reject();
return;
}
this.window.scrollTop = distance;
resolve();
}, delay);
});
}
onScroll(event) {
if (!this.window) return;
const { onStartReached, onEndReached, onScroll } = this.props;
const windowHeight = this.window.offsetHeight
const { scrollTop, scrollHeight } = this.window;
if (scrollTop !== scrollHeight) {
this.setState({ scrollTop });
}
this.calculateVisibleItems();
onScroll ? onScroll({ scrollTop, scrollHeight, windowHeight }) : null;
if (scrollTop === 0) {
if (onStartReached) onStartReached();
} else if (scrollTop + windowHeight >= scrollHeight) {
if (onEndReached) onEndReached();
}
}
render() {
const {
startgap,
endgap,
visibleItems
} = this.state;
const {
origin = 'top',
loadRows,
renderer,
style,
data
} = this.props;
const indexesToRender = Array.from(visibleItems.keys());
const transform = origin === 'top' ? 'scaleY(1)' : 'scaleY(-1)';
const render = (index) => {
const measure = (element) => {
if (element) {
this.cache.set(index, {
height: element.offsetHeight,
element
});
_.debounce(this.recalculateTotalHeight, 500)();
}
};
return renderer({ index, measure, scrollWindow: this.window });
};
return (
<div ref={this.setWindow.bind(this)} onScroll={this.onScroll.bind(this)} style={{...style, ...{overflowY: 'scroll', transform }}}>
<div ref={this.scrollContainer} style={{ transform }}>
<div style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></div>
{indexesToRender.map(render)}
<div style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></div>
</div>
</div>
);
}
}

View File

@ -101,7 +101,7 @@ export class OmniboxResult extends Component {
verticalAlign="middle"
color='white'
maxWidth="60%"
style={{ 'flex-shrink': 0 }}
style={{ flexShrink: 0 }}
mr='1'>
{text}
</Text>
@ -123,7 +123,7 @@ export class OmniboxResult extends Component {
display="inline-block"
verticalAlign="middle"
maxWidth="60%"
style={{ 'flex-shrink': 0 }}
style={{ flexShrink: 0 }}
>
{text}
</Text>