Merge pull request #3242 from urbit/la/chat-window

interface: refactored chat window into header, window, and scroll container
This commit is contained in:
L 2020-08-06 14:57:25 -07:00 committed by GitHub
commit 004bf024db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 688 additions and 633 deletions

View File

@ -7590,6 +7590,20 @@
"memoize-one": ">=3.1.1 <6"
}
},
"react-window-dynamic": {
"version": "1.8.0-alpha.2",
"resolved": "https://registry.npmjs.org/react-window-dynamic/-/react-window-dynamic-1.8.0-alpha.2.tgz",
"integrity": "sha512-PYR1nu5kzEr+PDg9/+19uTqO1OqUCckYE5dDpjFpEGBtVTcq4smxE3jXhk+zLi4AOZlLVr9pJIjwPL68zwq5Ww==",
"requires": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
}
},
"react-window-infinite-loader": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.5.tgz",
"integrity": "sha512-IcPIq8lADK3zsAcqoLqQGyduicqR6jWkiK2VUX5sKSI9X/rou6OWlOEexnGyujdNTG7hSG8OVBFEhLSDs4qrxg=="
},
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",

View File

@ -176,5 +176,6 @@ class App extends React.Component {
}
}
export default process.env.NODE_ENV === 'production' ? App : hot(App);

View File

@ -1,16 +1,11 @@
import React, { Component, Fragment } from "react";
import _ from "lodash";
import moment from "moment";
import { Link, RouteComponentProps } from "react-router-dom";
import { ResubscribeElement } from "./lib/resubscribe-element";
import { BacklogElement } from "./lib/backlog-element";
import { Message } from "./lib/message";
import { SidebarSwitcher } from "../../../components/SidebarSwitch";
import { ChatTabBar } from "./lib/chat-tabbar";
import { ChatWindow } from './lib/chat-window';
import { ChatHeader } from './lib/chat-header';
import { ChatInput } from "./lib/chat-input";
import { UnreadNotice } from "./lib/unread-notice";
import { deSig } from "../../../lib/util";
import { ChatHookUpdate } from "../../../types/chat-hook-update";
import ChatApi from "../../../api/chat";
@ -21,52 +16,6 @@ import GlobalApi from "../../../api/global";
import { Association } from "../../../types/metadata-update";
import {Group} from "../../../types/group-update";
function getNumPending(props: any) {
const result = props.pendingMessages.has(props.station)
? props.pendingMessages.get(props.station).length
: 0;
return result;
}
const ACTIVITY_TIMEOUT = 60000; // a minute
const DEFAULT_BACKLOG_SIZE = 300;
const MAX_BACKLOG_SIZE = 1000;
function scrollIsAtTop(container) {
if (
(navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
return container.scrollTop === 0;
} else if (navigator.userAgent.includes("Safari")) {
return (
container.scrollHeight + Math.round(container.scrollTop) <=
container.clientHeight + 10
);
} else {
return false;
}
}
function scrollIsAtBottom(container) {
if (
(navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
return (
container.scrollHeight - Math.round(container.scrollTop) <=
container.clientHeight + 10
);
} else if (navigator.userAgent.includes("Safari")) {
return container.scrollTop === 0;
} else {
return false;
}
}
type IMessage = Envelope & { pending?: boolean };
type ChatScreenProps = RouteComponentProps<{
ship: Patp;
@ -90,47 +39,20 @@ type ChatScreenProps = RouteComponentProps<{
};
interface ChatScreenState {
numPages: number;
scrollLocked: boolean;
read: number;
active: boolean;
messages: Map<string, string>;
lastScrollHeight: number | null;
}
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
hasAskedForMessages = false;
lastNumPending = 0;
scrollContainer: HTMLElement | null = null;
unreadMarker = null;
scrolledToMarker = false;
activityTimeout: NodeJS.Timeout | null = null;
scrollElement: HTMLElement | null = null;
constructor(props) {
super(props);
this.state = {
numPages: 1,
scrollLocked: false,
read: props.read,
active: true,
messages: new Map(),
// only for FF
lastScrollHeight: null,
};
this.onScroll = this.onScroll.bind(this);
this.setUnreadMarker = this.setUnreadMarker.bind(this);
this.handleActivity = this.handleActivity.bind(this);
this.setInactive = this.setInactive.bind(this);
moment.updateLocale("en", {
calendar: {
sameDay: "[Today]",
@ -143,450 +65,67 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
});
}
componentDidMount() {
document.addEventListener("mousemove", this.handleActivity, false);
document.addEventListener("mousedown", this.handleActivity, false);
document.addEventListener("keypress", this.handleActivity, false);
document.addEventListener("touchmove", this.handleActivity, false);
this.activityTimeout = setTimeout(this.setInactive, ACTIVITY_TIMEOUT);
}
componentWillUnmount() {
document.removeEventListener("mousemove", this.handleActivity, false);
document.removeEventListener("mousedown", this.handleActivity, false);
document.removeEventListener("keypress", this.handleActivity, false);
document.removeEventListener("touchmove", this.handleActivity, false);
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
}
}
handleActivity() {
if (!this.state.active) {
this.setState({ active: true });
}
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
}
this.activityTimeout = setTimeout(this.setInactive, ACTIVITY_TIMEOUT);
}
setInactive() {
this.activityTimeout = null;
this.setState({ active: false, scrollLocked: true });
}
receivedNewChat() {
const { props } = this;
this.hasAskedForMessages = false;
this.unreadMarker = null;
this.scrolledToMarker = false;
this.setState({ read: props.read });
const unread = props.length - props.read;
const unreadUnloaded = unread - props.envelopes.length;
const excessUnread = unreadUnloaded > MAX_BACKLOG_SIZE;
if (!excessUnread && unreadUnloaded + 20 > DEFAULT_BACKLOG_SIZE) {
this.askForMessages(unreadUnloaded + 20);
} else {
this.askForMessages(DEFAULT_BACKLOG_SIZE);
}
if (excessUnread || props.read === props.length) {
this.scrolledToMarker = true;
this.setState(
{
scrollLocked: false,
},
() => {
this.scrollToBottom();
}
);
} else {
this.setState({ scrollLocked: true, numPages: Math.ceil(unread / 100) });
}
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (
prevProps.match.params.station !== props.match.params.station ||
prevProps.match.params.ship !== props.match.params.ship
) {
this.receivedNewChat();
} else if (
props.chatInitialized &&
!(props.station in props.inbox) &&
Boolean(props.chatSynced) &&
!(props.station in props.chatSynced)
) {
props.history.push("/~chat");
} else if (props.envelopes.length >= prevProps.envelopes.length + 10) {
this.hasAskedForMessages = false;
} else if (
props.length !== prevProps.length &&
prevProps.length === prevState.read &&
state.active
) {
this.setState({ read: props.length });
this.props.api.chat.read(this.props.station);
}
if (!prevProps.chatInitialized && props.chatInitialized) {
this.receivedNewChat();
}
if (
props.length !== prevProps.length ||
props.envelopes.length !== prevProps.envelopes.length ||
getNumPending(props) !== this.lastNumPending ||
state.numPages !== prevState.numPages
) {
this.scrollToBottom();
if (navigator.userAgent.includes("Firefox")) {
this.recalculateScrollTop();
}
this.lastNumPending = getNumPending(props);
}
}
askForMessages(size) {
const { props, state } = this;
if (
props.envelopes.length >= props.length ||
this.hasAskedForMessages ||
props.length <= 0
) {
return;
}
const start =
props.length - props.envelopes[props.envelopes.length - 1].number;
if (start > 0) {
const end = start + size < props.length ? start + size : props.length;
this.hasAskedForMessages = true;
props.api.chat.fetchMessages(start + 1, end, props.station);
}
}
scrollToBottom() {
if (!this.state.scrollLocked && this.scrollElement) {
this.scrollElement.scrollIntoView();
}
}
// Restore chat position on FF when new messages come in
recalculateScrollTop() {
const { lastScrollHeight } = this.state;
if (!this.scrollContainer || !lastScrollHeight) {
return;
}
const target = this.scrollContainer;
const newScrollTop = this.scrollContainer.scrollHeight - lastScrollHeight;
if (target.scrollTop !== 0 || newScrollTop === target.scrollTop) {
return;
}
target.scrollTop = target.scrollHeight - lastScrollHeight;
}
onScroll(e) {
if (scrollIsAtTop(e.target)) {
// Save scroll position for FF
if (navigator.userAgent.includes("Firefox")) {
this.setState({
lastScrollHeight: e.target.scrollHeight,
});
}
this.setState(
{
numPages: this.state.numPages + 1,
scrollLocked: true,
},
() => {
this.askForMessages(DEFAULT_BACKLOG_SIZE);
}
);
} else if (scrollIsAtBottom(e.target)) {
this.dismissUnread();
this.setState({
numPages: 1,
scrollLocked: false,
});
}
}
setUnreadMarker(ref) {
if (ref && !this.scrolledToMarker) {
this.setState({ scrollLocked: true }, () => {
ref.scrollIntoView({ block: "center" });
if (ref.offsetParent && scrollIsAtBottom(ref.offsetParent)) {
this.dismissUnread();
this.setState({
numPages: 1,
scrollLocked: false,
});
}
});
this.scrolledToMarker = true;
}
this.unreadMarker = ref;
}
dismissUnread() {
this.props.api.chat.read(this.props.station);
}
chatWindow(unread) {
// Replace with just the "not Firefox" implementation
// when Firefox #1042151 is patched.
const { props, state } = this;
let messages: IMessage[] = props.envelopes.slice(0);
const lastMsgNum = messages.length > 0 ? messages.length : 0;
if (messages.length > 100 * state.numPages) {
messages = messages.slice(0, 100 * state.numPages);
}
const pendingMessages: IMessage[] = (
props.pendingMessages.get(props.station) || []
).map((value) => ({ ...value, pending: true }));
if(unread !== 0) {
unread += pendingMessages.length;
}
messages = pendingMessages.concat(messages);
const messageElements = messages.map((msg, i) => {
// Render sigil if previous message is not by the same sender
const aut = ["author"];
const renderSigil =
_.get(messages[i + 1], aut) !== _.get(msg, aut, msg.author);
const paddingTop = renderSigil;
const paddingBot =
_.get(messages[i - 1], aut) !== _.get(msg, aut, msg.author);
const when = ["when"];
const dayBreak =
moment(_.get(messages[i + 1], when)).format("YYYY.MM.DD") !==
moment(_.get(messages[i], when)).format("YYYY.MM.DD");
const messageElem = (
<Message
key={msg.uid}
msg={msg}
contacts={props.contacts}
renderSigil={renderSigil}
paddingTop={paddingTop}
paddingBot={paddingBot}
pending={Boolean(msg.pending)}
group={props.group}
association={props.association}
/>
);
if (unread > 0 && i === unread - 1) {
return (
<Fragment key={msg.uid}>
{messageElem}
<div
ref={this.setUnreadMarker}
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(messages[i], 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}>
{messageElem}
<div
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
>
<p>{moment(_.get(messages[i], when)).calendar()}</p>
</div>
</Fragment>
);
} else {
return messageElem;
}
});
if (navigator.userAgent.includes("Firefox")) {
return (
<div
className="relative overflow-y-scroll h-100"
onScroll={this.onScroll}
ref={(e) => {
this.scrollContainer = e;
}}
>
<div
className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
style={{ resize: "vertical" }}
>
<div
ref={(el) => {
this.scrollElement = el;
}}
></div>
{props.chatInitialized && !(props.station in props.inbox) && (
<BacklogElement />
)}
{props.chatSynced &&
!(props.station in props.chatSynced) &&
messages.length > 0 ? (
<ResubscribeElement
api={props.api}
host={props.match.params.ship}
station={props.station}
/>
) : (
<div />
)}
{messageElements}
</div>
</div>
);
} else {
return (
<div
className="overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse relative"
style={{ height: "100%", resize: "vertical" }}
onScroll={this.onScroll}
>
<div
ref={(el) => {
this.scrollElement = el;
}}
></div>
{props.chatInitialized && !(props.station in props.inbox) && (
<BacklogElement />
)}
{props.chatSynced &&
!(props.station in props.chatSynced) &&
messages.length > 0 ? (
<ResubscribeElement
api={props.api}
host={props.match.params.ship}
station={props.station}
/>
) : (
<div />
)}
{messageElements}
</div>
);
}
}
render() {
const { props, state } = this;
const messages = props.envelopes.slice(0);
const lastMsgNum = messages.length > 0 ? messages.length : 0;
const group = Array.from(props.group.members);
const isinPopout = props.popout ? "popout/" : "";
const lastMsgNum = props.envelopes.length > 0 ? props.envelopes.length : 0;
const ownerContact =
window.ship in props.contacts ? props.contacts[window.ship] : false;
let title = props.station.substr(1);
const pendingMessages = (props.pendingMessages.get(props.station) || [])
.map((value) => ({
...value,
pending: true
}));
if (props.association && "metadata" in props.association) {
title =
props.association.metadata.title !== ""
? props.association.metadata.title
: props.station.substr(1);
}
const isChatMissing =
props.chatInitialized &&
!(props.station in props.inbox) &&
props.chatSynced &&
!(props.station in props.chatSynced);
const unread = props.length - state.read;
const isChatLoading =
props.chatInitialized &&
!(props.station in props.inbox) &&
props.chatSynced &&
(props.station in props.chatSynced);
const unreadMsg = unread > 0 && messages[unread - 1];
const showUnreadNotice =
props.length !== props.read && props.read === state.read;
const isChatUnsynced =
props.chatSynced &&
!(props.station in props.chatSynced) &&
props.envelopes.length > 0;
const unreadCount = props.length - props.read;
const unreadMsg = unreadCount > 0 && props.envelopes[unreadCount - 1];
return (
<div
key={props.station}
className="h-100 w-100 overflow-hidden flex flex-column relative"
>
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: "1rem" }}
>
<Link to="/~chat/">{"⟵ All Chats"}</Link>
</div>
<div
className={
"pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative " +
"overflow-x-auto overflow-y-hidden flex-shrink-0 "
}
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
api={props.api}
/>
<Link
to={"/~chat/" + isinPopout + "room" + props.station}
className="pt2 white-d"
>
<h2
className={
"dib f9 fw4 lh-solid v-top " +
(title === props.station.substr(1) ? "mono" : "")
}
style={{ width: "max-content" }}
>
{title}
</h2>
</Link>
<ChatTabBar
{...props}
station={props.station}
numPeers={group.length}
isOwner={deSig(props.match.params.ship) === window.ship}
popout={props.popout}
api={props.api}
/>
</div>
{!!unreadMsg && showUnreadNotice && (
<UnreadNotice
unread={unread}
unreadMsg={unreadMsg}
onRead={() => this.dismissUnread()}
/>
)}
{this.chatWindow(unread)}
className="h-100 w-100 overflow-hidden flex flex-column relative">
<ChatHeader
match={props.match}
location={props.location}
api={props.api}
group={props.group}
association={props.association}
station={props.station}
sidebarShown={props.sidebarShown}
popout={props.popout} />
<ChatWindow
isChatMissing={isChatMissing}
isChatLoading={isChatLoading}
isChatUnsynced={isChatUnsynced}
unreadCount={unreadCount}
unreadMsg={unreadMsg}
pendingMessages={pendingMessages}
messages={props.envelopes}
length={props.length}
contacts={props.contacts}
association={props.association}
group={props.group}
ship={props.match.params.ship}
station={props.station}
api={props.api} />
<ChatInput
api={props.api}
numMsgs={lastMsgNum}
@ -595,7 +134,6 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
ownerContact={ownerContact}
envelopes={props.envelopes}
contacts={props.contacts}
onEnter={() => this.setState({ scrollLocked: false })}
onUnmount={(msg: string) => this.setState({
messages: this.state.messages.set(props.station, msg)
})}

View File

@ -1,21 +1,22 @@
import React, { Component } from 'react';
export class BacklogElement extends Component {
render() {
return (
<div className="center mw6">
<div className="db pa3 ma3 ba b--gray4 bg-gray5 b--gray2-d bg-gray1-d white-d flex items-center">
<img className="invert-d spin-active v-mid"
src="/~chat/img/Spinner.png"
width={16}
height={16}
/>
<p className="lh-copy db ml3">
Past messages are being restored
</p>
</div>
</div>
);
export const BacklogElement = (props) => {
if (!props.isChatLoading) {
return null;
}
return (
<div className="center mw6">
<div className={
"db pa3 ma3 ba b--gray4 bg-gray5 b--gray2-d bg-gray1-d " +
"white-d flex items-center"
}>
<img className="invert-d spin-active v-mid"
src="/~chat/img/Spinner.png"
width={16}
height={16}
/>
<p className="lh-copy db ml3">Past messages are being restored</p>
</div>
</div>
);
}

View File

@ -0,0 +1,65 @@
import React, { Component, Fragment } from "react";
import { Link } from "react-router-dom";
import { ChatTabBar } from "./chat-tabbar";
import { SidebarSwitcher } from "../../../../components/SidebarSwitch";
import { deSig } from "../../../../lib/util";
export class ChatHeader extends Component {
render() {
const { props } = this;
const isinPopout = props.popout ? "popout/" : "";
const group = Array.from(props.group.members);
let title = props.station.substr(1);
if (props.association &&
"metadata" in props.association &&
props.association.metadata.tile !== "") {
title = props.association.metadata.title
}
return (
<Fragment>
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: "1rem" }}>
<Link to="/~chat/">{"⟵ All Chats"}</Link>
</div>
<div
className={
"pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative " +
"overflow-x-auto overflow-y-hidden flex-shrink-0 "
}
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
api={props.api}
/>
<Link
to={"/~chat/" + isinPopout + "room" + props.station}
className="pt2 white-d"
>
<h2
className={
"dib f9 fw4 lh-solid v-top " +
(title === props.station.substr(1) ? "mono" : "")
}
style={{ width: "max-content" }}
>
{title}
</h2>
</Link>
<ChatTabBar
location={props.location}
station={props.station}
isOwner={deSig(props.match.params.ship) === window.ship}
popout={props.popout}
/>
</div>
</Fragment>
);
}
}

View File

@ -181,7 +181,7 @@ export class ChatInput extends Component {
return (
<div className={
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white" +
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white " +
"bg-gray0-d relative"
}
style={{ flexGrow: 1 }}>

View File

@ -0,0 +1,84 @@
import React, { PureComponent, Fragment } from "react";
import moment from "moment";
import { Message } from "./message";
type IMessage = Envelope & { pending?: boolean };
export const ChatMessage = (props) => {
const {
msg,
previousMsg,
nextMsg,
isLastUnread,
group,
association,
contacts,
unreadRef
} = 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}
/>
);
if (props.isLastUnread) {
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}>
{messageElem}
<div
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
>
<p>{moment(_.get(msg, when)).calendar()}</p>
</div>
</Fragment>
);
} else {
return messageElem;
}
};

View File

@ -0,0 +1,143 @@
import React, { Component, Fragment } from "react";
import { scrollIsAtTop, scrollIsAtBottom } from "../../../../lib/util";
// Restore chat position on FF when new messages come in
const recalculateScrollTop = (lastScrollHeight, scrollContainer) => {
if (!scrollContainer || !lastScrollHeight) {
return;
}
const newScrollTop = scrollContainer.scrollHeight - lastScrollHeight;
if (scrollContainer.scrollTop !== 0 ||
scrollContainer.scrollTop === newScrollTop) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight - lastScrollHeight;
};
export class ChatScrollContainer extends Component {
constructor(props) {
super(props);
// only for FF
this.state = {
lastScrollHeight: null
};
this.isTriggeredScroll = false;
this.isAtBottom = true;
this.isAtTop = false;
this.containerDidScroll = this.containerDidScroll.bind(this);
this.containerRef = React.createRef();
this.scrollRef = React.createRef();
}
containerDidScroll(e) {
const { props } = this;
if (scrollIsAtTop(e.target)) {
// Save scroll position for FF
if (navigator.userAgent.includes("Firefox")) {
this.setState({
lastScrollHeight: e.target.scrollHeight,
});
}
if (!this.isAtTop) {
props.scrollIsAtTop();
}
this.isTriggeredScroll = false;
this.isAtBottom = false;
this.isAtTop = true;
} else if (scrollIsAtBottom(e.target) && !this.isTriggeredScroll) {
if (!this.isAtBottom) {
props.scrollIsAtBottom();
}
this.isTriggeredScroll = false;
this.isAtBottom = true;
this.isAtTop = false;
} else {
this.isAtBottom = false;
this.isAtTop = false;
this.isTriggeredScroll = false;
}
}
render() {
// Replace with just the "not Firefox" implementation
// when Firefox #1042151 is patched.
if (navigator.userAgent.includes("Firefox")) {
return this.firefoxScrollContainer();
} else {
return this.normalScrollContainer();
}
}
firefoxScrollContainer() {
return (
<div
className="relative overflow-y-scroll h-100"
onScroll={this.containerDidScroll}
ref={this.containerRef}>
<div
className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
style={{ resize: "vertical" }}>
<div ref={this.scrollRef}></div>
{this.props.children}
</div>
</div>
);
}
normalScrollContainer() {
return (
<div
className={
"overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex " +
"flex-column-reverse relative"
}
style={{ height: "100%", resize: "vertical" }}
onScroll={this.containerDidScroll}>
<div ref={this.scrollRef}></div>
{this.props.children}
</div>
);
}
scrollToBottom() {
this.isTriggeredScroll = true;
if (this.scrollRef.current) {
this.scrollRef.current.scrollIntoView(false);
}
if (navigator.userAgent.includes("Firefox")) {
recalculateScrollTop(
this.state.lastScrollHeight,
this.scrollContainer
);
}
}
scrollToReference(ref) {
this.isTriggeredScroll = true;
if (this.scrollRef.current && ref.current) {
ref.current.scrollIntoView({ block: 'center' });
}
if (navigator.userAgent.includes("Firefox")) {
recalculateScrollTop(
this.state.lastScrollHeight,
this.scrollContainer
);
}
}
}

View File

@ -0,0 +1,191 @@
import React, { Component, Fragment } from "react";
import { ChatMessage } from './chat-message';
import { ChatScrollContainer } from "./chat-scroll-container";
import { UnreadNotice } from "./unread-notice";
import { ResubscribeElement } from "./resubscribe-element";
import { BacklogElement } from "./backlog-element";
const MAX_BACKLOG_SIZE = 1000;
const DEFAULT_BACKLOG_SIZE = 200;
const PAGE_SIZE = 50;
const INITIAL_LOAD = 20;
export class ChatWindow extends Component {
constructor(props) {
super(props);
this.state = {
numPages: 1,
};
this.hasAskedForMessages = false;
this.dismissUnread = this.dismissUnread.bind(this);
this.scrollIsAtBottom = this.scrollIsAtBottom.bind(this);
this.scrollIsAtTop = this.scrollIsAtTop.bind(this);
this.scrollReference = React.createRef();
this.unreadReference = React.createRef();
}
componentDidMount() {
this.initialFetch();
if (this.state.numPages === 1 && this.props.unreadCount < INITIAL_LOAD) {
this.dismissUnread();
this.scrollToBottom();
}
}
initialFetch() {
const { props } = this;
if (props.messages.length > 0) {
const unreadUnloaded = props.unreadCount - props.messages.length;
if (unreadUnloaded <= MAX_BACKLOG_SIZE &&
unreadUnloaded + INITIAL_LOAD > DEFAULT_BACKLOG_SIZE) {
this.fetchBacklog(unreadUnloaded + INITIAL_LOAD);
} else {
this.fetchBacklog(DEFAULT_BACKLOG_SIZE);
}
} else {
setTimeout(() => {
this.initialFetch();
}, 2000);
}
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (props.isChatMissing) {
props.history.push("/~chat");
} else if (props.messages.length >= prevProps.messages.length + 10) {
this.hasAskedForMessages = false;
let numPages = props.unreadCount > 0 ?
Math.ceil(props.unreadCount / PAGE_SIZE) : this.state.numPages;
if (this.state.numPages === numPages) {
if (props.unreadCount > 20) {
this.scrollToUnread();
}
} else {
this.setState({ numPages }, () => {
if (props.unreadCount > 20) {
this.scrollToUnread();
}
});
}
} else if (
state.numPages === 1 &&
this.props.unreadCount < INITIAL_LOAD &&
this.props.unreadCount > 0
) {
this.dismissUnread();
this.scrollToBottom();
}
}
scrollIsAtTop() {
const { props, state } = this;
this.setState({ numPages: state.numPages + 1 }, () => {
if (state.numPages * PAGE_SIZE < props.length) {
this.fetchBacklog(DEFAULT_BACKLOG_SIZE);
}
});
}
scrollIsAtBottom() {
if (this.state.numPages !== 1) {
this.setState({ numPages: 1 });
}
}
scrollToBottom() {
if (this.scrollReference.current) {
this.scrollReference.current.scrollToBottom();
}
if (this.state.numPages !== 1) {
this.setState({ numPages: 1 });
}
}
scrollToUnread() {
if (this.scrollReference.current && this.unreadReference.current) {
this.scrollReference.current.scrollToReference(this.unreadReference);
}
}
dismissUnread() {
this.props.api.chat.read(this.props.station);
}
fetchBacklog(size) {
const { props } = this;
if (
props.messages.length >= props.length ||
this.hasAskedForMessages ||
props.length <= 0
) {
return;
}
const start =
props.length - props.messages[props.messages.length - 1].number;
if (start > 0) {
const end = start + size < props.length ? start + size : props.length;
props.api.chat.fetchMessages(start + 1, end, props.station);
this.hasAskedForMessages = true;
}
}
render() {
const { props, state } = this;
const sliceLength = Math.min(
state.numPages * PAGE_SIZE,
props.messages.length + props.pendingMessages.length
);
const messages =
props.pendingMessages
.concat(props.messages)
.slice(0, sliceLength);
return (
<Fragment>
<UnreadNotice
unreadCount={props.unreadCount}
unreadMsg={props.unreadMsg}
dismissUnread={this.dismissUnread} />
<ChatScrollContainer
ref={this.scrollReference}
scrollIsAtBottom={this.scrollIsAtBottom}
scrollIsAtTop={this.scrollIsAtTop}>
<BacklogElement isChatLoading={props.isChatLoading} />
<ResubscribeElement
api={props.api}
host={props.ship}
station={props.station}
isChatUnsynced={props.isChatUnsynced}
/>
{ messages.map((msg, i) => (
<ChatMessage
unreadRef={this.unreadReference}
isLastUnread={
props.unreadCount > 0 && i === props.unreadCount - 1
}
msg={msg}
previousMsg={messages[i - 1]}
nextMsg={messages[i + 1]}
association={props.association}
group={props.group}
contacts={props.contacts} />
))
}
</ChatScrollContainer>
</Fragment>
);
}
}

View File

@ -1,4 +1,5 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
import urbitOb from 'urbit-ob';

View File

@ -5,49 +5,41 @@ import { uxToHex, cite, writeText } from '../../../../lib/util';
import moment from 'moment';
export class Message extends Component {
constructor() {
super();
this.state = {
copied: false
};
}
export const Message = (props) => {
const pending = props.msg.pending ? ' o-40' : '';
const containerClass =
props.renderSigil ?
`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ` + pending :
'w-100 pr3 cf hide-child flex' + pending;
render() {
const { props, state } = this;
const pending = props.msg.pending ? ' o-40' : '';
const containerClass =
props.renderSigil ?
`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ` + pending :
'w-100 pr3 cf hide-child flex' + pending;
const timestamp =
moment.unix(props.msg.when / 1000).format(
props.renderSigil ? 'hh:mm a' : 'hh:mm'
);
return (
<div
ref={this.containerRef}
className={containerClass}
style={{
minHeight: 'min-content'
}}
>
{
props.renderSigil ? (
this.renderWithSigil(timestamp)
) : (
this.renderWithoutSigil(timestamp)
)
}
</div>
const timestamp =
moment.unix(props.msg.when / 1000).format(
props.renderSigil ? 'hh:mm a' : 'hh:mm'
);
}
renderWithSigil(timestamp) {
const { props, state } = this;
return (
<div className={containerClass}
style={{
minHeight: 'min-content'
}}>
{
props.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={props.msg.letter} />
</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');
@ -86,18 +78,14 @@ export class Message extends Component {
<span
className={
'mw5 dib truncate pointer ' +
(contact.nickname || state.copied ? '' : 'mono')
(contact.nickname ? '' : 'mono')
}
onClick={() => {
writeText(props.msg.author);
this.setState({ copied: true });
setTimeout(() => {
this.setState({ copied: false });
}, 800);
}}
title={`~${props.msg.author}`}
>
{state.copied && 'Copied' || name}
{name}
</span>
</p>
<p className={`v-mid mono f9 gray2 dib`}>{timestamp}</p>
@ -111,17 +99,3 @@ export class Message extends Component {
);
}
renderWithoutSigil(timestamp) {
const { props } = this;
return (
<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={props.msg.letter} />
</div>
</div>
);
}
}

View File

@ -9,19 +9,24 @@ export class ResubscribeElement extends Component {
}
render() {
return (
<div className="db pa3 ma3 ba b--yellow2 bg-yellow0">
<p className="lh-copy db">
Your ship has been disconnected from the chat's host.
This may be due to a bad connection, going offline, lack of permission,
or an over-the-air update.
</p>
<a onClick={this.onClickResubscribe.bind(this)}
className="db underline black pointer mt3"
>
Reconnect to this chat
</a>
</div>
);
const { props } = this;
if (props.isChatUnsynced) {
return (
<div className="db pa3 ma3 ba b--yellow2 bg-yellow0">
<p className="lh-copy db">
Your ship has been disconnected from the chat's host.
This may be due to a bad connection, going offline, lack of permission,
or an over-the-air update.
</p>
<a onClick={this.onClickResubscribe.bind(this)}
className="db underline black pointer mt3"
>
Reconnect to this chat
</a>
</div>
);
} else {
return null;
}
}
}

View File

@ -1,37 +1,41 @@
import React, { Component } from 'react';
import moment from 'moment';
export class UnreadNotice extends Component {
render() {
const { unread, unreadMsg, onRead } = this.props;
export const UnreadNotice = (props) => {
const { unreadCount, unreadMsg, dismissUnread } = props;
let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D');
const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm');
if (!unreadMsg || (unreadCount === 0)) {
return null;
}
if (datestamp === moment().format('YYYY.M.D')) {
datestamp = null;
}
let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D');
const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm');
return (
<div
style={{ left: '0px' }}
className="pa4 w-100 absolute z-1 unread-notice"
>
<div className="ba b--green2 green2 bg-white bg-gray0-d flex items-center pa2 f9 justify-between br1">
<p className="lh-copy db">
{unread} new messages since{' '}
{datestamp && (
<>
<span className="green3">~{datestamp}</span> at{' '}
</>
)}
<span className="green3">{timestamp}</span>
</p>
<div onClick={onRead} className="ml4 inter b--green2 pointer tr lh-copy">
Mark as Read
</div>
if (datestamp === moment().format('YYYY.M.D')) {
datestamp = null;
}
return (
<div style={{ left: '0px' }}
className="pa4 w-100 absolute z-1 unread-notice">
<div className={
"ba b--green2 green2 bg-white bg-gray0-d flex items-center " +
"pa2 f9 justify-between br1"
}>
<p className="lh-copy db">
{unreadCount} new messages since{' '}
{datestamp && (
<>
<span className="green3">~{datestamp}</span> at{' '}
</>
)}
<span className="green3">{timestamp}</span>
</p>
<div onClick={dismissUnread}
className="ml4 inter b--green2 pointer tr lh-copy">
Mark as Read
</div>
</div>
);
}
</div>
);
}

View File

@ -275,3 +275,38 @@ export function stringToSymbol(str) {
}
return result;
}
export function scrollIsAtTop(container) {
if (
(navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
return container.scrollTop === 0;
} else if (navigator.userAgent.includes("Safari")) {
return (
container.scrollHeight + Math.round(container.scrollTop) <=
container.clientHeight + 10
);
} else {
return false;
}
}
export function scrollIsAtBottom(container) {
if (
(navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
return (
container.scrollHeight - Math.round(container.scrollTop) <=
container.clientHeight + 10
);
} else if (navigator.userAgent.includes("Safari")) {
return container.scrollTop === 0;
} else {
return false;
}
}

View File

@ -50,7 +50,6 @@ export default class LaunchReducer<S extends LaunchState> {
changeIsShown(json: LaunchUpdate, state: S) {
const data = _.get(json, 'changeIsShown', false);
console.log(json, data);
if (data) {
let tile = state.launch.tiles[data.name];
console.log(tile);