diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index 27a526c50..ef5edcf44 100644 --- a/pkg/interface/package-lock.json +++ b/pkg/interface/package-lock.json @@ -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", diff --git a/pkg/interface/src/App.js b/pkg/interface/src/App.js index 94dc9d076..1f970dbcd 100644 --- a/pkg/interface/src/App.js +++ b/pkg/interface/src/App.js @@ -176,5 +176,6 @@ class App extends React.Component { } } + export default process.env.NODE_ENV === 'production' ? App : hot(App); diff --git a/pkg/interface/src/apps/chat/components/chat.tsx b/pkg/interface/src/apps/chat/components/chat.tsx index a8a94e62b..e57d81ccf 100644 --- a/pkg/interface/src/apps/chat/components/chat.tsx +++ b/pkg/interface/src/apps/chat/components/chat.tsx @@ -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; - lastScrollHeight: number | null; } export class ChatScreen extends Component { - 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 { }); } - 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 = ( - - ); - if (unread > 0 && i === unread - 1) { - return ( - - {messageElem} -
-
-

New messages below

-
- {dayBreak && ( -

- {moment(_.get(messages[i], when)).calendar()} -

- )} -
-
-
- ); - } else if (dayBreak) { - return ( - - {messageElem} -
-

{moment(_.get(messages[i], when)).calendar()}

-
-
- ); - } else { - return messageElem; - } - }); - - if (navigator.userAgent.includes("Firefox")) { - return ( -
{ - this.scrollContainer = e; - }} - > -
-
{ - this.scrollElement = el; - }} - >
- {props.chatInitialized && !(props.station in props.inbox) && ( - - )} - {props.chatSynced && - !(props.station in props.chatSynced) && - messages.length > 0 ? ( - - ) : ( -
- )} - {messageElements} -
-
- ); - } else { - return ( -
-
{ - this.scrollElement = el; - }} - >
- {props.chatInitialized && !(props.station in props.inbox) && ( - - )} - {props.chatSynced && - !(props.station in props.chatSynced) && - messages.length > 0 ? ( - - ) : ( -
- )} - {messageElements} -
- ); - } - } - 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 (
-
- {"⟵ All Chats"} -
- -
- - -

- {title} -

- - -
- {!!unreadMsg && showUnreadNotice && ( - this.dismissUnread()} - /> - )} - {this.chatWindow(unread)} + className="h-100 w-100 overflow-hidden flex flex-column relative"> + + { 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) })} diff --git a/pkg/interface/src/apps/chat/components/lib/backlog-element.js b/pkg/interface/src/apps/chat/components/lib/backlog-element.js index 6e16882ff..64f8b26ca 100644 --- a/pkg/interface/src/apps/chat/components/lib/backlog-element.js +++ b/pkg/interface/src/apps/chat/components/lib/backlog-element.js @@ -1,21 +1,22 @@ import React, { Component } from 'react'; -export class BacklogElement extends Component { - render() { - return ( -
-
- -

- Past messages are being restored -

-
-
- - ); +export const BacklogElement = (props) => { + if (!props.isChatLoading) { + return null; } + return ( +
+
+ +

Past messages are being restored

+
+
+ ); } diff --git a/pkg/interface/src/apps/chat/components/lib/chat-header.js b/pkg/interface/src/apps/chat/components/lib/chat-header.js new file mode 100644 index 000000000..bab82652b --- /dev/null +++ b/pkg/interface/src/apps/chat/components/lib/chat-header.js @@ -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 ( + +
+ {"⟵ All Chats"} +
+
+ + +

+ {title} +

+ + +
+
+ ); + } +} diff --git a/pkg/interface/src/apps/chat/components/lib/chat-input.js b/pkg/interface/src/apps/chat/components/lib/chat-input.js index 2fa71da74..ee2446f3c 100644 --- a/pkg/interface/src/apps/chat/components/lib/chat-input.js +++ b/pkg/interface/src/apps/chat/components/lib/chat-input.js @@ -181,7 +181,7 @@ export class ChatInput extends Component { return (
diff --git a/pkg/interface/src/apps/chat/components/lib/chat-message.tsx b/pkg/interface/src/apps/chat/components/lib/chat-message.tsx new file mode 100644 index 000000000..84b985417 --- /dev/null +++ b/pkg/interface/src/apps/chat/components/lib/chat-message.tsx @@ -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 = ( + + ); + + if (props.isLastUnread) { + return ( + + {messageElem} +
+
+

New messages below

+
+ {dayBreak && ( +

+ {moment(_.get(msg, when)).calendar()} +

+ )} +
+
+
+ ); + } else if (dayBreak) { + return ( + + {messageElem} +
+

{moment(_.get(msg, when)).calendar()}

+
+
+ ); + } else { + return messageElem; + } +}; + diff --git a/pkg/interface/src/apps/chat/components/lib/chat-scroll-container.js b/pkg/interface/src/apps/chat/components/lib/chat-scroll-container.js new file mode 100644 index 000000000..68b0872c4 --- /dev/null +++ b/pkg/interface/src/apps/chat/components/lib/chat-scroll-container.js @@ -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 ( +
+
+
+ {this.props.children} +
+
+ ); + } + + normalScrollContainer() { + return ( +
+
+ {this.props.children} +
+ ); + } + + 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 + ); + } + } + +} diff --git a/pkg/interface/src/apps/chat/components/lib/chat-window.tsx b/pkg/interface/src/apps/chat/components/lib/chat-window.tsx new file mode 100644 index 000000000..7e6302735 --- /dev/null +++ b/pkg/interface/src/apps/chat/components/lib/chat-window.tsx @@ -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 ( + + + + + + { messages.map((msg, i) => ( + 0 && i === props.unreadCount - 1 + } + msg={msg} + previousMsg={messages[i - 1]} + nextMsg={messages[i + 1]} + association={props.association} + group={props.group} + contacts={props.contacts} /> + )) + } + + + ); + } +} + diff --git a/pkg/interface/src/apps/chat/components/lib/content/text.js b/pkg/interface/src/apps/chat/components/lib/content/text.js index b6bde96cb..f44d9bd6a 100644 --- a/pkg/interface/src/apps/chat/components/lib/content/text.js +++ b/pkg/interface/src/apps/chat/components/lib/content/text.js @@ -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'; diff --git a/pkg/interface/src/apps/chat/components/lib/message.js b/pkg/interface/src/apps/chat/components/lib/message.js index 898b8a8ee..6a416e368 100644 --- a/pkg/interface/src/apps/chat/components/lib/message.js +++ b/pkg/interface/src/apps/chat/components/lib/message.js @@ -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 ( -
- { - props.renderSigil ? ( - this.renderWithSigil(timestamp) - ) : ( - this.renderWithoutSigil(timestamp) - ) - } -
+ const timestamp = + moment.unix(props.msg.when / 1000).format( + props.renderSigil ? 'hh:mm a' : 'hh:mm' ); - } - renderWithSigil(timestamp) { - const { props, state } = this; + return ( +
+ { + props.renderSigil ? ( + renderWithSigil(props, timestamp) + ) : ( +
+

{timestamp}

+
+ +
+
+ ) + } +
+ ); +}; + +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 { { writeText(props.msg.author); - this.setState({ copied: true }); - setTimeout(() => { - this.setState({ copied: false }); - }, 800); }} title={`~${props.msg.author}`} > - {state.copied && 'Copied' || name} + {name}

{timestamp}

@@ -111,17 +99,3 @@ export class Message extends Component { ); } - renderWithoutSigil(timestamp) { - const { props } = this; - - return ( -
-

{timestamp}

-
- -
-
- ); - } -} diff --git a/pkg/interface/src/apps/chat/components/lib/resubscribe-element.js b/pkg/interface/src/apps/chat/components/lib/resubscribe-element.js index 61186a375..6ac875705 100644 --- a/pkg/interface/src/apps/chat/components/lib/resubscribe-element.js +++ b/pkg/interface/src/apps/chat/components/lib/resubscribe-element.js @@ -9,19 +9,24 @@ export class ResubscribeElement extends Component { } render() { - return ( -
-

- 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. -

- - Reconnect to this chat - -
- ); + const { props } = this; + if (props.isChatUnsynced) { + return ( +
+

+ 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. +

+ + Reconnect to this chat + +
+ ); + } else { + return null; + } } } diff --git a/pkg/interface/src/apps/chat/components/lib/unread-notice.js b/pkg/interface/src/apps/chat/components/lib/unread-notice.js index e18f612ef..2724d23c9 100644 --- a/pkg/interface/src/apps/chat/components/lib/unread-notice.js +++ b/pkg/interface/src/apps/chat/components/lib/unread-notice.js @@ -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 ( -
-
-

- {unread} new messages since{' '} - {datestamp && ( - <> - ~{datestamp} at{' '} - - )} - {timestamp} -

-
- Mark as Read -
+ if (datestamp === moment().format('YYYY.M.D')) { + datestamp = null; + } + + return ( +
+
+

+ {unreadCount} new messages since{' '} + {datestamp && ( + <> + ~{datestamp} at{' '} + + )} + {timestamp} +

+
+ Mark as Read
- ); - } +
+ ); } diff --git a/pkg/interface/src/lib/util.js b/pkg/interface/src/lib/util.js index 0b20a8f43..79dafbe9c 100644 --- a/pkg/interface/src/lib/util.js +++ b/pkg/interface/src/lib/util.js @@ -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; + } +} + diff --git a/pkg/interface/src/reducers/launch-update.ts b/pkg/interface/src/reducers/launch-update.ts index 5e74af793..7790be761 100644 --- a/pkg/interface/src/reducers/launch-update.ts +++ b/pkg/interface/src/reducers/launch-update.ts @@ -50,7 +50,6 @@ export default class LaunchReducer { 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);