mirror of
https://github.com/ilyakooo0/urbit.git
synced 2025-01-07 07:30:23 +03:00
Merge pull request #3242 from urbit/la/chat-window
interface: refactored chat window into header, window, and scroll container
This commit is contained in:
commit
004bf024db
14
pkg/interface/package-lock.json
generated
14
pkg/interface/package-lock.json
generated
@ -7590,6 +7590,20 @@
|
|||||||
"memoize-one": ">=3.1.1 <6"
|
"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": {
|
"readable-stream": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||||
|
@ -176,5 +176,6 @@ class App extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default process.env.NODE_ENV === 'production' ? App : hot(App);
|
export default process.env.NODE_ENV === 'production' ? App : hot(App);
|
||||||
|
|
||||||
|
@ -1,16 +1,11 @@
|
|||||||
import React, { Component, Fragment } from "react";
|
import React, { Component, Fragment } from "react";
|
||||||
import _ from "lodash";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
import { Link, RouteComponentProps } from "react-router-dom";
|
import { Link, RouteComponentProps } from "react-router-dom";
|
||||||
|
|
||||||
import { ResubscribeElement } from "./lib/resubscribe-element";
|
import { ChatWindow } from './lib/chat-window';
|
||||||
import { BacklogElement } from "./lib/backlog-element";
|
import { ChatHeader } from './lib/chat-header';
|
||||||
import { Message } from "./lib/message";
|
|
||||||
import { SidebarSwitcher } from "../../../components/SidebarSwitch";
|
|
||||||
import { ChatTabBar } from "./lib/chat-tabbar";
|
|
||||||
import { ChatInput } from "./lib/chat-input";
|
import { ChatInput } from "./lib/chat-input";
|
||||||
import { UnreadNotice } from "./lib/unread-notice";
|
|
||||||
import { deSig } from "../../../lib/util";
|
import { deSig } from "../../../lib/util";
|
||||||
import { ChatHookUpdate } from "../../../types/chat-hook-update";
|
import { ChatHookUpdate } from "../../../types/chat-hook-update";
|
||||||
import ChatApi from "../../../api/chat";
|
import ChatApi from "../../../api/chat";
|
||||||
@ -21,52 +16,6 @@ import GlobalApi from "../../../api/global";
|
|||||||
import { Association } from "../../../types/metadata-update";
|
import { Association } from "../../../types/metadata-update";
|
||||||
import {Group} from "../../../types/group-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<{
|
type ChatScreenProps = RouteComponentProps<{
|
||||||
ship: Patp;
|
ship: Patp;
|
||||||
@ -90,47 +39,20 @@ type ChatScreenProps = RouteComponentProps<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ChatScreenState {
|
interface ChatScreenState {
|
||||||
numPages: number;
|
|
||||||
scrollLocked: boolean;
|
|
||||||
read: number;
|
|
||||||
active: boolean;
|
|
||||||
messages: Map<string, string>;
|
messages: Map<string, string>;
|
||||||
lastScrollHeight: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||||
hasAskedForMessages = false;
|
|
||||||
lastNumPending = 0;
|
lastNumPending = 0;
|
||||||
|
|
||||||
scrollContainer: HTMLElement | null = null;
|
|
||||||
|
|
||||||
unreadMarker = null;
|
|
||||||
scrolledToMarker = false;
|
|
||||||
|
|
||||||
activityTimeout: NodeJS.Timeout | null = null;
|
activityTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
scrollElement: HTMLElement | null = null;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
numPages: 1,
|
|
||||||
scrollLocked: false,
|
|
||||||
read: props.read,
|
|
||||||
active: true,
|
|
||||||
messages: new Map(),
|
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", {
|
moment.updateLocale("en", {
|
||||||
calendar: {
|
calendar: {
|
||||||
sameDay: "[Today]",
|
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() {
|
render() {
|
||||||
const { props, state } = this;
|
const { props, state } = this;
|
||||||
|
|
||||||
const messages = props.envelopes.slice(0);
|
const lastMsgNum = props.envelopes.length > 0 ? props.envelopes.length : 0;
|
||||||
|
|
||||||
const lastMsgNum = messages.length > 0 ? messages.length : 0;
|
|
||||||
|
|
||||||
const group = Array.from(props.group.members);
|
|
||||||
|
|
||||||
const isinPopout = props.popout ? "popout/" : "";
|
|
||||||
|
|
||||||
const ownerContact =
|
const ownerContact =
|
||||||
window.ship in props.contacts ? props.contacts[window.ship] : false;
|
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) {
|
const isChatMissing =
|
||||||
title =
|
props.chatInitialized &&
|
||||||
props.association.metadata.title !== ""
|
!(props.station in props.inbox) &&
|
||||||
? props.association.metadata.title
|
props.chatSynced &&
|
||||||
: props.station.substr(1);
|
!(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 isChatUnsynced =
|
||||||
|
props.chatSynced &&
|
||||||
const showUnreadNotice =
|
!(props.station in props.chatSynced) &&
|
||||||
props.length !== props.read && props.read === state.read;
|
props.envelopes.length > 0;
|
||||||
|
|
||||||
|
const unreadCount = props.length - props.read;
|
||||||
|
const unreadMsg = unreadCount > 0 && props.envelopes[unreadCount - 1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={props.station}
|
key={props.station}
|
||||||
className="h-100 w-100 overflow-hidden flex flex-column relative"
|
className="h-100 w-100 overflow-hidden flex flex-column relative">
|
||||||
>
|
<ChatHeader
|
||||||
<div
|
match={props.match}
|
||||||
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
|
location={props.location}
|
||||||
style={{ height: "1rem" }}
|
api={props.api}
|
||||||
>
|
group={props.group}
|
||||||
<Link to="/~chat/">{"⟵ All Chats"}</Link>
|
association={props.association}
|
||||||
</div>
|
station={props.station}
|
||||||
|
sidebarShown={props.sidebarShown}
|
||||||
<div
|
popout={props.popout} />
|
||||||
className={
|
<ChatWindow
|
||||||
"pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative " +
|
isChatMissing={isChatMissing}
|
||||||
"overflow-x-auto overflow-y-hidden flex-shrink-0 "
|
isChatLoading={isChatLoading}
|
||||||
}
|
isChatUnsynced={isChatUnsynced}
|
||||||
style={{ height: 48 }}
|
unreadCount={unreadCount}
|
||||||
>
|
unreadMsg={unreadMsg}
|
||||||
<SidebarSwitcher
|
pendingMessages={pendingMessages}
|
||||||
sidebarShown={props.sidebarShown}
|
messages={props.envelopes}
|
||||||
popout={props.popout}
|
length={props.length}
|
||||||
api={props.api}
|
contacts={props.contacts}
|
||||||
/>
|
association={props.association}
|
||||||
<Link
|
group={props.group}
|
||||||
to={"/~chat/" + isinPopout + "room" + props.station}
|
ship={props.match.params.ship}
|
||||||
className="pt2 white-d"
|
station={props.station}
|
||||||
>
|
api={props.api} />
|
||||||
<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)}
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
api={props.api}
|
api={props.api}
|
||||||
numMsgs={lastMsgNum}
|
numMsgs={lastMsgNum}
|
||||||
@ -595,7 +134,6 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
|||||||
ownerContact={ownerContact}
|
ownerContact={ownerContact}
|
||||||
envelopes={props.envelopes}
|
envelopes={props.envelopes}
|
||||||
contacts={props.contacts}
|
contacts={props.contacts}
|
||||||
onEnter={() => this.setState({ scrollLocked: false })}
|
|
||||||
onUnmount={(msg: string) => this.setState({
|
onUnmount={(msg: string) => this.setState({
|
||||||
messages: this.state.messages.set(props.station, msg)
|
messages: this.state.messages.set(props.station, msg)
|
||||||
})}
|
})}
|
||||||
|
@ -1,21 +1,22 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
export class BacklogElement extends Component {
|
export const BacklogElement = (props) => {
|
||||||
render() {
|
if (!props.isChatLoading) {
|
||||||
return (
|
return null;
|
||||||
<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>
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
65
pkg/interface/src/apps/chat/components/lib/chat-header.js
Normal file
65
pkg/interface/src/apps/chat/components/lib/chat-header.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -181,7 +181,7 @@ export class ChatInput extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={
|
<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"
|
"bg-gray0-d relative"
|
||||||
}
|
}
|
||||||
style={{ flexGrow: 1 }}>
|
style={{ flexGrow: 1 }}>
|
||||||
|
84
pkg/interface/src/apps/chat/components/lib/chat-message.tsx
Normal file
84
pkg/interface/src/apps/chat/components/lib/chat-message.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
191
pkg/interface/src/apps/chat/components/lib/chat-window.tsx
Normal file
191
pkg/interface/src/apps/chat/components/lib/chat-window.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
|
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
|
||||||
import urbitOb from 'urbit-ob';
|
import urbitOb from 'urbit-ob';
|
||||||
|
@ -5,49 +5,41 @@ import { uxToHex, cite, writeText } from '../../../../lib/util';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
|
|
||||||
export class Message extends Component {
|
export const Message = (props) => {
|
||||||
constructor() {
|
const pending = props.msg.pending ? ' o-40' : '';
|
||||||
super();
|
const containerClass =
|
||||||
this.state = {
|
props.renderSigil ?
|
||||||
copied: false
|
`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ` + pending :
|
||||||
};
|
'w-100 pr3 cf hide-child flex' + pending;
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
const timestamp =
|
||||||
const { props, state } = this;
|
moment.unix(props.msg.when / 1000).format(
|
||||||
|
props.renderSigil ? 'hh:mm a' : 'hh:mm'
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
renderWithSigil(timestamp) {
|
return (
|
||||||
const { props, state } = this;
|
<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 paddingTop = props.paddingTop ? { 'paddingTop': '6px' } : '';
|
||||||
const datestamp =
|
const datestamp =
|
||||||
'~' + moment.unix(props.msg.when / 1000).format('YYYY.M.D');
|
'~' + moment.unix(props.msg.when / 1000).format('YYYY.M.D');
|
||||||
@ -86,18 +78,14 @@ export class Message extends Component {
|
|||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
'mw5 dib truncate pointer ' +
|
'mw5 dib truncate pointer ' +
|
||||||
(contact.nickname || state.copied ? '' : 'mono')
|
(contact.nickname ? '' : 'mono')
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
writeText(props.msg.author);
|
writeText(props.msg.author);
|
||||||
this.setState({ copied: true });
|
|
||||||
setTimeout(() => {
|
|
||||||
this.setState({ copied: false });
|
|
||||||
}, 800);
|
|
||||||
}}
|
}}
|
||||||
title={`~${props.msg.author}`}
|
title={`~${props.msg.author}`}
|
||||||
>
|
>
|
||||||
{state.copied && 'Copied' || name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className={`v-mid mono f9 gray2 dib`}>{timestamp}</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -9,19 +9,24 @@ export class ResubscribeElement extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
const { props } = this;
|
||||||
<div className="db pa3 ma3 ba b--yellow2 bg-yellow0">
|
if (props.isChatUnsynced) {
|
||||||
<p className="lh-copy db">
|
return (
|
||||||
Your ship has been disconnected from the chat's host.
|
<div className="db pa3 ma3 ba b--yellow2 bg-yellow0">
|
||||||
This may be due to a bad connection, going offline, lack of permission,
|
<p className="lh-copy db">
|
||||||
or an over-the-air update.
|
Your ship has been disconnected from the chat's host.
|
||||||
</p>
|
This may be due to a bad connection, going offline, lack of permission,
|
||||||
<a onClick={this.onClickResubscribe.bind(this)}
|
or an over-the-air update.
|
||||||
className="db underline black pointer mt3"
|
</p>
|
||||||
>
|
<a onClick={this.onClickResubscribe.bind(this)}
|
||||||
Reconnect to this chat
|
className="db underline black pointer mt3"
|
||||||
</a>
|
>
|
||||||
</div>
|
Reconnect to this chat
|
||||||
);
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,41 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
export class UnreadNotice extends Component {
|
export const UnreadNotice = (props) => {
|
||||||
render() {
|
const { unreadCount, unreadMsg, dismissUnread } = props;
|
||||||
const { unread, unreadMsg, onRead } = this.props;
|
|
||||||
|
|
||||||
let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D');
|
if (!unreadMsg || (unreadCount === 0)) {
|
||||||
const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm');
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (datestamp === moment().format('YYYY.M.D')) {
|
let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D');
|
||||||
datestamp = null;
|
const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm');
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
if (datestamp === moment().format('YYYY.M.D')) {
|
||||||
<div
|
datestamp = null;
|
||||||
style={{ left: '0px' }}
|
}
|
||||||
className="pa4 w-100 absolute z-1 unread-notice"
|
|
||||||
>
|
return (
|
||||||
<div className="ba b--green2 green2 bg-white bg-gray0-d flex items-center pa2 f9 justify-between br1">
|
<div style={{ left: '0px' }}
|
||||||
<p className="lh-copy db">
|
className="pa4 w-100 absolute z-1 unread-notice">
|
||||||
{unread} new messages since{' '}
|
<div className={
|
||||||
{datestamp && (
|
"ba b--green2 green2 bg-white bg-gray0-d flex items-center " +
|
||||||
<>
|
"pa2 f9 justify-between br1"
|
||||||
<span className="green3">~{datestamp}</span> at{' '}
|
}>
|
||||||
</>
|
<p className="lh-copy db">
|
||||||
)}
|
{unreadCount} new messages since{' '}
|
||||||
<span className="green3">{timestamp}</span>
|
{datestamp && (
|
||||||
</p>
|
<>
|
||||||
<div onClick={onRead} className="ml4 inter b--green2 pointer tr lh-copy">
|
<span className="green3">~{datestamp}</span> at{' '}
|
||||||
Mark as Read
|
</>
|
||||||
</div>
|
)}
|
||||||
|
<span className="green3">{timestamp}</span>
|
||||||
|
</p>
|
||||||
|
<div onClick={dismissUnread}
|
||||||
|
className="ml4 inter b--green2 pointer tr lh-copy">
|
||||||
|
Mark as Read
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
@ -275,3 +275,38 @@ export function stringToSymbol(str) {
|
|||||||
}
|
}
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -50,7 +50,6 @@ export default class LaunchReducer<S extends LaunchState> {
|
|||||||
|
|
||||||
changeIsShown(json: LaunchUpdate, state: S) {
|
changeIsShown(json: LaunchUpdate, state: S) {
|
||||||
const data = _.get(json, 'changeIsShown', false);
|
const data = _.get(json, 'changeIsShown', false);
|
||||||
console.log(json, data);
|
|
||||||
if (data) {
|
if (data) {
|
||||||
let tile = state.launch.tiles[data.name];
|
let tile = state.launch.tiles[data.name];
|
||||||
console.log(tile);
|
console.log(tile);
|
||||||
|
Loading…
Reference in New Issue
Block a user