mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-15 01:52:42 +03:00
Merge pull request #3476 from tylershuster/scroll-behavior
chat: Scroll behavior
This commit is contained in:
commit
e35bd1a2c9
14
pkg/interface/package-lock.json
generated
14
pkg/interface/package-lock.json
generated
@ -7989,15 +7989,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz",
|
||||
"integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg=="
|
||||
},
|
||||
"react-virtuoso": {
|
||||
"version": "0.20.0",
|
||||
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-0.20.0.tgz",
|
||||
"integrity": "sha512-h+U6t/+m91AzfUe6bBfaacdLLJl1y8v7CfcXwPgQ/Dic+vNlgQmi6cIKTq18zuF+kI8Q7QN0ojIeqPHWbU8TZA==",
|
||||
"requires": {
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||
@ -8276,11 +8267,6 @@
|
||||
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
|
||||
"dev": true
|
||||
},
|
||||
"resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
|
||||
|
@ -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",
|
||||
|
@ -69,7 +69,7 @@ export interface Envelope {
|
||||
uid: string;
|
||||
number: number;
|
||||
author: Patp;
|
||||
when: string;
|
||||
when: number;
|
||||
letter: Letter;
|
||||
}
|
||||
|
||||
|
@ -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 });
|
||||
}
|
||||
}}
|
||||
|
@ -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"
|
||||
|
@ -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 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 when = ["when"];
|
||||
const dayBreak =
|
||||
moment(_.get(nextMsg, when)).format("YYYY.MM.DD") !==
|
||||
moment(_.get(msg, when)).format("YYYY.MM.DD");
|
||||
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 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}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
className={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>
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
onLoad={measure}
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
@ -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 (lastMessageNumber !== this.state.lastMessageNumber) {
|
||||
this.setState({ lastMessageNumber });
|
||||
}
|
||||
|
||||
if (!idle && idle !== prevState.idle) {
|
||||
setTimeout(() => {
|
||||
this.virtualList.current?.scrollToIndex(mailboxSize);
|
||||
}, 500)
|
||||
}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
|
@ -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];
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>“{error.message}”</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>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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}
|
||||
>
|
||||
|
290
pkg/interface/src/views/components/VirtualScroller.tsx
Normal file
290
pkg/interface/src/views/components/VirtualScroller.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user