chat-view: redesign of chat interface

This commit redesigns the front-end of chat-view for
Landscape, adding a collapsable sidebar, popout chats,
a streamlined join flow, and a general refresh of the Indigo
interface.
This commit is contained in:
Matilde Park 2019-11-20 20:17:07 -05:00
parent e8d34fe0ca
commit a6b4ed19b3
31 changed files with 986 additions and 656 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 B

View File

@ -5,8 +5,20 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" <meta name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"/> content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="stylesheet" href="/~chat/css/index.css" /> <link rel="stylesheet" href="/~chat/css/index.css" />
<link rel="icon" type="image/png" href="/~launch/img/Favicon.png"> <link rel="icon" type="image/png" href="/~launch/img/Favicon.png">
<link rel="manifest"
href='data:application/manifest+json,{
"name": "Chat",
"short_name": "Chat",
"description": "A%20Chat%20application%20for%20your%20Urbit%20ship.",
"display": "standalone",
"background_color": "%23FFFFFF",
"theme_color": "%23000000"}' />
</head> </head>
<body> <body>
<div id="root" /> <div id="root" />

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,8 @@
* {
-webkit-font-smoothing: antialiased;
-webkit-touch-callout: none;
}
p, h1, h2, h3, h4, h5, h6, a, input, textarea, button { p, h1, h2, h3, h4, h5, h6, a, input, textarea, button {
margin-block-end: unset; margin-block-end: unset;
margin-block-start: unset; margin-block-start: unset;
@ -14,25 +19,22 @@ textarea, input, button {
background-color: #fff; background-color: #fff;
} }
input[type=checkbox] { .dropdown::after {
-webkit-appearance: checkbox; content: "⌃";
transform: rotate(180deg);
position: absolute;
right: 12px;
top: 8px;
} }
a { a {
color: #000 !important; color: #000;
font-weight: 400 !important; font-weight: 400;
text-decoration: none;
} }
h2 { h2 {
font-size: 32px; font-weight: 400;
line-height: 48px;
font-weight: bold;
}
.body-regular {
font-size: 16px;
line-height: 24px;
font-weight: 600;
} }
.body-large { .body-large {
@ -53,67 +55,6 @@ h2 {
.label-regular { .label-regular {
font-size: 14px; font-size: 14px;
line-height: 24px;
}
.label-small {
font-size: 14px;
line-height: 24px;
}
.label-small-mono {
font-size: 12px;
line-height: 24px;
font-family: "Source Code Pro", monospace;
}
.body-regular-400 {
font-size: 16px;
line-height: 24px;
font-weight: 400;
}
.plus-font {
font-size: 32px;
line-height: 24px;
}
.btn-font {
font-size: 14px;
line-height: 16px;
font-weight: 600 !important;
}
.fw-normal {
font-weight: 400;
}
.fw-bold {
font-weight: bold;
}
.fs-italic {
font-style: italic;
}
.td-underline {
text-decoration: underline;
}
.bg-v-light-gray {
background-color: #f9f9f9;
}
.nice-green {
color: #2AA779 !important;
}
.bg-nice-green {
background: #2ED196;
}
.nice-red {
color: #EE5432 !important;
} }
.inter { .inter {
@ -146,6 +87,54 @@ h2 {
font-family: "Source Code Pro", monospace; font-family: "Source Code Pro", monospace;
} }
.label-small-mono.list-ship { .list-ship {
line-height: 29px; line-height: 2.2;
} }
.c-default {
cursor: default;
}
/* responsive */
@media all and (max-width: 34.375em) {
.dn-s {
display: none;
}
.flex-basis-full-s {
flex-basis: 100%;
}
.h-100-minus-48-s {
height: calc(100% - 48px);
}
.h-100-minus-96-s {
height: calc(100% - 96px);
}
}
@media all and (min-width: 34.375em) and (max-width: 46.875em) {
.flex-basis-300-m {
flex-basis: 300px;
}
.h-100-minus-48-m {
height: calc(100% - 48px);
}
}
@media all and (min-width: 46.875em) and (max-width: 60em) {
.flex-basis-300-l {
flex-basis: 300px;
}
.h-100-minus-48-l {
height: calc(100% - 48px);
}
}
@media all and (min-width: 60em) {
.flex-basis-300-xl {
flex-basis: 300px;
}
.h-100-minus-48-xl {
height: calc(100% - 48px);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
@import "css/tachyons.css"; @import "css/indigo-static.css";
@import "css/fonts.css"; @import "css/fonts.css";
@import "css/spinner.css"; @import "css/spinner.css";
@import "css/custom.css"; @import "css/custom.css";

View File

@ -164,7 +164,7 @@ class UrbitApi {
ship: `~${window.ship}`, ship: `~${window.ship}`,
recipient: ship, recipient: ship,
app: 'chat-hook', app: 'chat-hook',
text: `You have been invited to /${window.ship}${path}`, text: `~${window.ship}${path}`,
}, },
uid: uuid() uid: uuid()
} }

View File

@ -2,6 +2,10 @@ import React, { Component } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import _ from 'lodash'; import _ from 'lodash';
import { Route, Link } from "react-router-dom";
import { store } from "/store";
import { Message } from '/components/lib/message'; import { Message } from '/components/lib/message';
import { ChatTabBar } from '/components/lib/chat-tabbar'; import { ChatTabBar } from '/components/lib/chat-tabbar';
import { ChatInput } from '/components/lib/chat-input'; import { ChatInput } from '/components/lib/chat-input';
@ -9,213 +13,271 @@ import { deSig } from '/lib/util';
export class ChatScreen extends Component { export class ChatScreen extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
station: `/${props.match.params.ship}/${props.match.params.station}`, station: `/${props.match.params.ship}/${props.match.params.station}`,
numPages: 1, numPages: 1,
scrollLocked: false, scrollLocked: false
}; };
this.hasAskedForMessages = false; this.hasAskedForMessages = false;
this.onScroll = this.onScroll.bind(this); this.onScroll = this.onScroll.bind(this);
this.updateReadInterval = setInterval( this.updateReadInterval = setInterval(
this.updateReadNumber.bind(this), this.updateReadNumber.bind(this),
1000 1000
); );
} }
componentDidMount() { componentDidMount() {
this.updateReadNumber(); this.updateReadNumber();
} }
componentWillUnmount() { componentWillUnmount() {
if (this.updateReadInterval) { if (this.updateReadInterval) {
clearInterval(this.updateReadInterval); clearInterval(this.updateReadInterval);
this.updateReadInterval = null; this.updateReadInterval = null;
} }
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const { props, state } = this; const { props, state } = this;
if ((prevProps.match.params.station !== props.match.params.station) || if (
(prevProps.match.params.ship !== props.match.params.ship)) { prevProps.match.params.station !== props.match.params.station ||
this.hasAskedForMessages = false; prevProps.match.params.ship !== props.match.params.ship
) {
this.hasAskedForMessages = false;
clearInterval(this.updateReadInterval); clearInterval(this.updateReadInterval);
this.setState({ this.setState(
station: `/${props.match.params.ship}/${props.match.params.station}`, {
scrollLocked: false station: `/${props.match.params.ship}/${props.match.params.station}`,
}, () => { scrollLocked: false
this.scrollToBottom(); },
this.updateReadInterval = setInterval( () => {
this.updateReadNumber.bind(this), this.scrollToBottom();
1000 this.updateReadInterval = setInterval(
); this.updateReadNumber.bind(this),
this.updateReadNumber(); 1000
}); );
} else if (Object.keys(props.inbox).length === 0) { this.updateReadNumber();
props.history.push('/~chat'); }
} else if (props.envelopes.length - prevProps.envelopes.length >= 200) { );
this.hasAskedForMessages = false; } else if (Object.keys(props.inbox).length === 0) {
} props.history.push("/~chat");
} } else if (
props.envelopes.length - prevProps.envelopes.length >=
200
) {
this.hasAskedForMessages = false;
}
}
updateReadNumber() { updateReadNumber() {
const { props, state } = this; const { props, state } = this;
if (props.read < props.length) { if (props.read < props.envelopes.length) {
props.api.chat.read(state.station); props.api.chat.read(state.station);
} }
} }
askForMessages() { askForMessages() {
const { props, state } = this; const { props, state } = this;
if (state.numPages * 100 < props.envelopes.length - 400 ||
this.hasAskedForMessages) {
return;
}
if (props.envelopes.length > 0) { if (
let end = props.envelopes[0].number; state.numPages * 100 < props.envelopes.length - 400 ||
if (end > 0) { this.hasAskedForMessages
let start = ((end - 400) > 0) ? end - 400 : 0; ) {
return;
}
if (start === 0 && end === 1) { if (props.envelopes.length > 0) {
return; let end = props.envelopes[0].number;
} if (end > 0) {
let start = end - 400 > 0 ? end - 400 : 0;
this.hasAskedForMessages = true; if (start === 0 && end === 1) {
return;
}
props.subscription.fetchMessages(start, end - 1, state.station); this.hasAskedForMessages = true;
}
}
}
scrollToBottom() { props.subscription.fetchMessages(start, end - 1, state.station);
if (!this.state.scrollLocked && this.scrollElement) { }
this.scrollElement.scrollIntoView({ behavior: 'smooth' }); }
} }
}
onScroll(e) { scrollToBottom() {
if (navigator.userAgent.includes('Safari') && if (!this.state.scrollLocked && this.scrollElement) {
navigator.userAgent.includes('Chrome')) { this.scrollElement.scrollIntoView({ behavior: "smooth" });
// Google Chrome }
if (e.target.scrollTop === 0) { }
this.setState({
numPages: this.state.numPages + 1,
scrollLocked: true
}, () => {
this.askForMessages();
});
} else if (
(e.target.scrollHeight - Math.round(e.target.scrollTop)) ===
(e.target.clientHeight)
) {
this.setState({
numPages: 1,
scrollLocked: false
});
}
} else if (navigator.userAgent.includes('Safari')) {
// Safari
if (e.target.scrollTop === 0) {
this.setState({
numPages: 1,
scrollLocked: false
});
} else if (
(e.target.scrollHeight + Math.round(e.target.scrollTop)) <=
(e.target.clientHeight + 10)
) {
this.setState({
numPages: this.state.numPages + 1,
scrollLocked: true
}, () => {
this.askForMessages();
});
}
} else {
console.log('Your browser is not supported.');
}
}
render() { onScroll(e) {
const { props, state } = this; if (
navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")
) {
// Google Chrome
if (e.target.scrollTop === 0) {
this.setState(
{
numPages: this.state.numPages + 1,
scrollLocked: true
},
() => {
this.askForMessages();
}
);
} else if (
e.target.scrollHeight - Math.round(e.target.scrollTop) ===
e.target.clientHeight
) {
this.setState({
numPages: 1,
scrollLocked: false
});
}
} else if (navigator.userAgent.includes("Safari")) {
// Safari
if (e.target.scrollTop === 0) {
this.setState({
numPages: 1,
scrollLocked: false
});
} else if (
e.target.scrollHeight + Math.round(e.target.scrollTop) <=
e.target.clientHeight + 10
) {
this.setState(
{
numPages: this.state.numPages + 1,
scrollLocked: true
},
() => {
this.askForMessages();
}
);
}
} else {
console.log("Your browser is not supported.");
}
}
let messages = props.envelopes.slice(0); render() {
const { props, state } = this;
let lastMsgNum = (messages.length > 0) ?
messages.length : 0;
if (messages.length > 100 * state.numPages) { let messages = props.envelopes.slice(0);
messages = messages
.slice(messages.length - (100 * state.numPages), messages.length);
}
let pendingMessages = let lastMsgNum = messages.length > 0 ? messages.length : 0;
props.pendingMessages.has(state.station)
? props.pendingMessages.get(state.station) : [];
pendingMessages.map(function(value) {
return value.pending = true;
})
let reversedMessages = messages.concat(pendingMessages);
reversedMessages = reversedMessages.reverse();
reversedMessages = reversedMessages.map((msg, i) => { if (messages.length > 100 * state.numPages) {
// Render sigil if previous message is not by the same sender messages = messages.slice(
let aut = ['author']; messages.length - 100 * state.numPages,
let renderSigil = messages.length
_.get(reversedMessages[i + 1], aut) !== _.get(msg, aut, msg.author); );
let paddingTop = renderSigil; }
let paddingBot =
_.get(reversedMessages[i - 1], aut) !== _.get(msg, aut, msg.author);
return ( let pendingMessages = props.pendingMessages.has(state.station)
<Message ? props.pendingMessages.get(state.station)
key={msg.uid} : [];
msg={msg}
renderSigil={renderSigil}
paddingTop={paddingTop}
paddingBot={paddingBot}
pending={!!msg.pending} />
);
});
let group = Array.from(props.group.values()); pendingMessages.map(function(value) {
return (value.pending = true);
return ( });
<div key={state.station}
className="h-100 w-100 overflow-hidden flex flex-column"> let reversedMessages = messages.concat(pendingMessages);
<div className='pl3 pt2 bb'> reversedMessages = reversedMessages.reverse();
<h2>{state.station.substr(1)}</h2>
<ChatTabBar {...props} reversedMessages = reversedMessages.map((msg, i) => {
station={state.station} // Render sigil if previous message is not by the same sender
numPeers={group.length} let aut = ["author"];
isOwner={deSig(props.match.params.ship) === window.ship} /> let renderSigil =
</div> _.get(reversedMessages[i + 1], aut) !==
<div _.get(msg, aut, msg.author);
className="overflow-y-scroll pt3 pb2 flex flex-column-reverse" let paddingTop = renderSigil;
style={{ height: 'calc(100% - 157px)', resize: 'vertical' }} let paddingBot =
onScroll={this.onScroll}> _.get(reversedMessages[i - 1], aut) !==
<div ref={ el => { this.scrollElement = el; }}></div> _.get(msg, aut, msg.author);
{reversedMessages}
</div> return (
<ChatInput <Message
api={props.api} key={msg.uid}
numMsgs={lastMsgNum} msg={msg}
station={state.station} renderSigil={renderSigil}
owner={deSig(props.match.params.ship)} paddingTop={paddingTop}
permissions={props.permissions} paddingBot={paddingBot}
placeholder='Message...' /> pending={!!msg.pending}
</div> />
) );
} });
}
let group = Array.from(props.group.values());
let popoutSwitcher = this.props.popout ? "dn-m dn-l dn-xl" : "dib-m dib-l dib-xl";
let isinPopout = this.props.popout ? "popout/" : "";
return (
<div
key={state.station}
className="h-100 w-100 overflow-hidden flex flex-column">
<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="pl3 pt4 bb b--gray4 flex relative overflow-x-scroll flex-shrink-0"
style={{ height: 48 }}>
<a className="pointer flex-shrink-0"
onClick={() => {
store.setState(previousState => ({
sidebarShown: !previousState.sidebarShown
}));
}}>
<img className={`v-btm pr3 dn ` + popoutSwitcher}
src={
this.props.sidebarShown
? "/~chat/img/ChatSwitcherLink.png"
: "/~chat/img/ChatSwitcherClosed.png"
}
height="16"
width="16"
/>
</a>
<Link to={`/~chat/` + isinPopout + `room` + state.station}>
<h2
className="mono dib f7 fw4 v-top"
style={{ width: "max-content" }}>
{state.station.substr(1)}
</h2>
</Link>
<ChatTabBar
{...props}
station={state.station}
numPeers={group.length}
isOwner={deSig(props.match.params.ship) === window.ship}
popout={this.props.popout}
/>
</div>
<div className="overflow-y-scroll pt3 pb2 flex flex-column-reverse"
style={{ height: "100%", resize: "vertical" }}
onScroll={this.onScroll}>
<div ref={el => {
this.scrollElement = el;
}}></div>
{reversedMessages}
</div>
<ChatInput
api={props.api}
numMsgs={lastMsgNum}
station={state.station}
owner={deSig(props.match.params.ship)}
permissions={props.permissions}
placeholder="Message..."
/>
</div>
);
}
}

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { Route, Link } from 'react-router-dom';
import urbitOb from 'urbit-ob'; import urbitOb from 'urbit-ob';
@ -16,6 +17,24 @@ export class JoinScreen extends Component {
this.stationChange = this.stationChange.bind(this); this.stationChange = this.stationChange.bind(this);
} }
componentDidMount() {
if (this.props.autoJoin !== "undefined/undefined") {
let station = this.props.autoJoin.split('/');
let ship = station[0];
station.splice(0, 1);
station = '/' + station.join('/');
if (station.length < 2 || !urbitOb.isValidPatp(ship)) {
this.setState({
error: true,
});
return;
}
this.props.api.chatView.join(ship, station);
this.props.history.push('/~chat');
}
}
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const { props, state } = this; const { props, state } = this;
if (state.station in props.inbox) { if (state.station in props.inbox) {
@ -61,15 +80,15 @@ export class JoinScreen extends Component {
render() { render() {
const { props } = this; const { props } = this;
let joinClasses = "db label-regular mt4 btn-font pointer underline bn"; let joinClasses = "db f9 green2 ba pa2 b--green2";
if (!this.state.station) { if ((!this.state.station) || (this.state.station === "/")) {
joinClasses = joinClasses + ' gray'; joinClasses = 'db f9 gray2 ba pa2 b--gray3';
} }
let errElem = (<span />); let errElem = (<span />);
if (this.state.error) { if (this.state.error) {
errElem = ( errElem = (
<span className="body-small inter nice-red db"> <span className="f9 inter red2 db">
Chat must have a valid name. Chat must have a valid name.
</span> </span>
); );
@ -77,16 +96,17 @@ export class JoinScreen extends Component {
return ( return (
<div className="h-100 w-100 pa3 pt2 overflow-x-hidden flex flex-column"> <div className="h-100 w-100 pa3 pt2 overflow-x-hidden flex flex-column">
<h2 className="mb3">Join</h2> <div
<div className="w-50"> className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<p className="body-medium mt3 db">Chatroom</p> <Link to="/~chat/">{"⟵ All Chats"}</Link>
<p className="body-small db mt2 mb3"> </div>
Join an existing chatroom. <h2 className="mb3 f8">Join Existing Chat</h2>
Chatrooms follow the format ~shipname/chat-name. <div className="w-100">
</p> <p className="f8 lh-copy mt3 db">Enter a <span className="mono">~ship/chat-name</span></p>
<p className="f9 gray2 mb4">Chat names use lowercase, hyphens, and slashes.</p>
<textarea <textarea
ref={ e => { this.textarea = e; } } ref={ e => { this.textarea = e; } }
className="body-regular mono fw-normal ba pa2 mb2 db w-100" className="f7 mono ba b--gray3 pa3 mb2 db"
placeholder="~zod/chatroom" placeholder="~zod/chatroom"
spellCheck="false" spellCheck="false"
rows={1} rows={1}
@ -99,8 +119,7 @@ export class JoinScreen extends Component {
<button <button
onClick={this.onClickJoin.bind(this)} onClick={this.onClickJoin.bind(this)}
className={joinClasses} className={joinClasses}
style={{ fontSize: '18px' }} >Join Chat</button>
>-> Join</button>
</div> </div>
</div> </div>
); );

View File

@ -5,7 +5,6 @@ import Mousetrap from 'mousetrap';
import classnames from 'classnames'; import classnames from 'classnames';
import { Sigil } from '/components/lib/icons/sigil'; import { Sigil } from '/components/lib/icons/sigil';
import { IconSend } from '/components/lib/icons/icon-send';
import { uuid } from '/lib/util'; import { uuid } from '/lib/util';
@ -155,16 +154,16 @@ export class ChatInput extends Component {
readOnlyRender() { readOnlyRender() {
return ( return (
<div className="mt2 pa3 cf flex black bt o-50"> <div className="pa3 cf flex black bt b--gray4 o-50">
<div className="fl" style={{ <div className="fl" style={{
marginTop: 4, marginTop: 4,
flexBasis: 32, flexBasis: 24,
height: 36 height: 24
}}> }}>
<Sigil ship={window.ship} size={32} /> <Sigil ship={window.ship} size={24} color="#4330FC" />
</div> </div>
<div className="fr h-100 flex pa2" style={{ flexGrow: 1, height: 40 }}> <div className="fr h-100 flex" style={{ flexGrow: 1, height: 28, paddingTop: 6, resize: "none" }}>
<p style={{paddingTop: 3}}>This chat is read only and you cannot post.</p> <p className="pl3">This chat is read only and you cannot post.</p>
</div> </div>
</div> </div>
); );
@ -176,27 +175,26 @@ export class ChatInput extends Component {
this.bindShortcuts(); this.bindShortcuts();
return ( return (
<div className="pa3 cf flex black bt b--black-30" style={{ flexGrow: 1 }}> <div className="pa3 cf flex black bt b--gray4" style={{ flexGrow: 1 }}>
<div className="fl" style={{ <div
marginTop: 4, className="fl"
flexBasis: 32, style={{
height: 36 marginTop: 4,
}}> flexBasis: 24,
<Sigil ship={window.ship} size={32} /> height: 24
}}
>
<Sigil ship={window.ship} size={24} color="#4330FC" />
</div> </div>
<div className="fr h-100 flex" style={{ flexGrow: 1 }}> <div className="fr h-100 flex" style={{ flexGrow: 1 }}>
<textarea <textarea
className={'ml2 mt2 mr2 bn'} className={"pl3 bn"}
style={{ flexGrow: 1, height: 40, paddingTop: 3, resize: 'none' }} style={{ flexGrow: 1, height: 28, paddingTop: 6, resize: "none" }}
ref={this.textareaRef} ref={this.textareaRef}
placeholder={props.placeholder} placeholder={props.placeholder}
value={state.message} value={state.message}
onChange={this.messageChange} onChange={this.messageChange}
autoFocus={true}
/> />
<div className="pointer" onClick={this.messageSubmit}>
<IconSend />
</div>
</div> </div>
</div> </div>
); );

View File

@ -8,54 +8,59 @@ export class ChatTabBar extends Component {
render() { render() {
let props = this.props; let props = this.props;
let bbStream = '', let memColor = '',
bbMembers = '', setColor = '',
bbSettings = ''; popout = '';
let strColor = '',
memColor = '',
setColor = '';
if (props.location.pathname.includes('/settings')) { if (props.location.pathname.includes('/settings')) {
bbSettings = ' bb'; memColor = 'gray3';
strColor = 'gray';
memColor = 'gray';
setColor = 'black'; setColor = 'black';
} else if (props.location.pathname.includes('/members')) { } else if (props.location.pathname.includes('/members')) {
bbMembers = ' bb';
strColor = 'gray';
memColor = 'black'; memColor = 'black';
setColor = 'gray'; setColor = 'gray3';
} else { } else {
bbStream = ' bb'; memColor = 'gray3';
strColor = 'black'; setColor = 'gray3';
memColor = 'gray';
setColor = 'gray';
} }
let membersText = props.numPeers === 1 (props.location.pathname.includes('/popout'))
? '1 Member' : `${props.numPeers} Members`; ? popout = "popout/"
: popout = "";
let hidePopoutIcon = (this.props.popout)
? "dn-m dn-l dn-xl"
: "dib-m dib-l dib-xl";
return ( return (
<div className="w-100" style={{ height:28 }}> <div className="dib flex-shrink-0-m flex-grow-1">
<div className={"dib h-100" + bbStream} style={{width:'160px'}}> {!!props.isOwner ? (
<Link <div className={"dib f8 pl6"}>
className={'no-underline label-regular v-mid ' + strColor}
to={'/~chat/room' + props.station}>Stream</Link>
</div>
{ !!props.isOwner ? (
<div className={"dib h-100" + bbMembers} style={{width:'160px'}}>
<Link <Link
className={'no-underline label-regular v-mid ' + memColor} className={"no-underline v-top " + memColor}
to={'/~chat/members' + props.station}>{membersText}</Link> to={`/~chat/` + popout + `members` + props.station}>
Members
</Link>
</div> </div>
) : <div className="dib" style={{width:0}}></div> ) : (
} <div className="dib" style={{ width: 0 }}></div>
<div className={"dib h-100" + bbSettings} style={{width:'160px'}}> )}
<div className={"dib f8 pl6 pr6"}>
<Link <Link
className={'no-underline label-regular v-mid ' + setColor} className={"no-underline v-top " + setColor}
to={'/~chat/settings' + props.station}>Settings</Link> to={`/~chat/` + popout + `settings` + props.station}>
Settings
</Link>
</div> </div>
<a href={`/~chat/popout/room` + props.station} target="_blank"
className="dib fr">
<img
className={`v-btm flex-shrink-0 pr2 dn ` + hidePopoutIcon}
src="/~chat/img/popout.png"
height="16"
width="16"
style={{ paddingTop: "2px" }}/>
</a>
</div> </div>
); );
} }

View File

@ -12,8 +12,12 @@ export class HeaderBar extends Component {
</div> </div>
: null; : null;
let popoutHide = (this.props.popout)
? "dn dn-m dn-l dn-xl"
: "dn db-m db-l db-xl";
return ( return (
<div className="bg-black w-100 justify-between" <div className={`bg-black w-100 justify-between ` + popoutHide}
style={{ height: 48, padding: 8}}> style={{ height: 48, padding: 8}}>
<a className="db" <a className="db"
style={{ background: '#1A1A1A', style={{ background: '#1A1A1A',

View File

@ -1,9 +0,0 @@
import React, { Component } from 'react';
export class IconSend extends Component {
render() {
return (
<img src="/~chat/img/Send.png" width={40} height={40} />
);
}
}

View File

@ -13,17 +13,13 @@ export class Sigil extends Component {
); );
} else { } else {
return ( return (
<div <div style={{ flexBasis: 32, backgroundColor: props.color }}>
className="bg-black" {sigil({
style={{ flexBasis: 32 }}>
{
sigil({
patp: props.ship, patp: props.ship,
renderer: reactRenderer, renderer: reactRenderer,
size: props.size, size: props.size,
colors: ['black', 'white'], colors: [props.color, "white"]
}) })}
}
</div> </div>
); );
} }

View File

@ -69,39 +69,40 @@ export class InviteElement extends Component {
render() { render() {
const { props, state} = this; const { props, state} = this;
let errorElem = !!state.error ? ( let errorElem = !!state.error ? (
<p className="pt2 nice-red label-regular">Invalid ship name.</p> <p className="pt2 red2 f8">Invalid ship name.</p>
) : ( ) : (
<div></div> <div></div>
); );
let successElem = !!state.success ? ( let successElem = !!state.success ? (
<p className="pt2 nice-green label-regular">Success!</p> <p className="pt2 green2 f8">Success!</p>
) : ( ) : (
<div></div> <div></div>
); );
let modifyButtonClasses = "label-regular black underline btn-font pointer"; let modifyButtonClasses = "db f9 ba pa2 b--black pointer";
if (!state.error) { if (state.error) {
modifyButtonClasses = modifyButtonClasses + ' black'; modifyButtonClasses = modifyButtonClasses + ' gray3';
} }
let buttonText = ''; let buttonText = '';
if (props.permissions.kind === 'black') { if (props.permissions.kind === 'black') {
buttonText = '-> Ban'; buttonText = 'Ban';
} else if (props.permissions.kind === 'white') { } else if (props.permissions.kind === 'white') {
buttonText = '-> Invite'; buttonText = 'Invite';
} }
return ( return (
<div> <div>
<textarea <textarea
ref={ e => { this.textarea = e; } } ref={ e => { this.textarea = e; } }
className="w-90 db ba overflow-y-hidden mono gray mb2" className="f7 mono ba b--gray3 pa3 mb4 db w-100"
style={{ style={{
resize: 'none', resize: 'none',
height: 150 height: 50
}} }}
spellCheck="false" spellCheck="false"
placeholder="~zod, ~bus"
onChange={this.modifyMembersChange.bind(this)}></textarea> onChange={this.modifyMembersChange.bind(this)}></textarea>
<button <button
onClick={this.modifyMembers.bind(this)} onClick={this.modifyMembers.bind(this)}

View File

@ -16,15 +16,15 @@ export class MemberElement extends Component {
let actionElem; let actionElem;
if (props.ship === props.owner) { if (props.ship === props.owner) {
actionElem = ( actionElem = (
<p className="dib w-20 underline black label-small-mono label-regular"> <p className="w-20 dib list-ship black f8 c-default">
Host Host
</p> </p>
); );
} else if (window.ship !== props.ship && window.ship === props.owner) { } else if (window.ship !== props.ship && window.ship === props.owner) {
actionElem = ( actionElem = (
<a onClick={this.onRemove.bind(this)} <a onClick={this.onRemove.bind(this)}
className="w-20 dib list-ship black underline label-small-mono pointer"> className="w-20 dib list-ship black f8 pointer">
Remove Ban
</a> </a>
); );
} else { } else {
@ -38,9 +38,9 @@ export class MemberElement extends Component {
<Sigil ship={props.ship} size={32} /> <Sigil ship={props.ship} size={32} />
<p <p
className={ className={
"w-70 dib v-mid black ml2 nowrap label-small-mono list-ship label-regular" "w-70 mono list-ship dib v-mid black ml2 nowrap f8"
}> }>
{props.ship} ~{props.ship}
</p> </p>
{actionElem} {actionElem}
</div> </div>

View File

@ -1,6 +1,8 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Sigil } from '/components/lib/icons/sigil'; import { Sigil } from '/components/lib/icons/sigil';
import classnames from 'classnames'; import classnames from 'classnames';
import { Route, Link } from 'react-router-dom'
import urbitOb from 'urbit-ob';
import moment from 'moment'; import moment from 'moment';
import _ from 'lodash'; import _ from 'lodash';
@ -45,7 +47,7 @@ export class Message extends Component {
); );
} }
return ( return (
<a className="body-regular-400 v-top" <a className="f7 lh-copy v-top bb b--black"
href={letter.url} href={letter.url}
target="_blank" target="_blank"
rel="noopener noreferrer"> rel="noopener noreferrer">
@ -54,47 +56,73 @@ export class Message extends Component {
); );
} else if ('me' in letter) { } else if ('me' in letter) {
return ( return (
<p className='body-regular-400 v-top'> <p className='f7 lh-copy v-top'>
{letter.me} {letter.me}
</p> </p>
); );
} else { } else {
return ( let chatroom = letter.text.match(
<p className='body-regular-400 v-top'> /(~[a-z]{3,6})(-[a-z]{6})?([/])(([a-z])+([/-])?)+/
{letter.text} );
</p> if ((chatroom !== null)
); && (chatroom[1].length > 2)
} && (urbitOb.isValidPatp(chatroom[1]))) {
return (
<Link
className="bb b--black f7 mono lh-copy v-top"
to={"/~chat/join/" + chatroom.input}>
{letter.text}
</Link>
);
}
else {
return (
<p className='f7 lh-copy v-top'>
{letter.text}
</p>
);
}
}
} }
render() { render() {
const { props } = this; const { props } = this;
let pending = !!props.msg.pending ? ' o-40' : ''; let pending = !!props.msg.pending ? ' o-40' : '';
let datestamp = moment.unix(props.msg.when / 1000).format('LL'); let datestamp = "~" + moment.unix(props.msg.when / 1000).format('YYYY.MM.D');
let paddingTop = props.paddingTop ? 'pt3' : ''; let paddingTop = props.paddingTop ? {'paddingTop': '6px'} : '';
let paddingBot = props.paddingBot ? 'pb2' : 'pb1';
if (props.renderSigil) { if (props.renderSigil) {
let timestamp = moment.unix(props.msg.when / 1000).format('hh:mm a'); let timestamp = moment.unix(props.msg.when / 1000).format('hh:mm a');
return ( return (
<div className={"w-100 pl3 pr3 cf flex " + paddingTop + " " + paddingBot + pending} <div
style={{ className={
minHeight: 'min-content' "w-100 f8 pl3 pt4 pr3 cf flex lh-copy " + " " + pending
}}> }
<div className="fl mr2"> style={{
<Sigil ship={props.msg.author} size={36} /> minHeight: "min-content"
}}>
<div className="fl mr3 v-top">
<Sigil
ship={props.msg.author}
size={24}
color={((props.msg.author === window.ship)
|| (props.msg.author.substr(1) === window.ship))
? "#4330FC"
: "#000000"}
/>
</div> </div>
<div className="fr clamp-message" style={{ flexGrow: 1, marginTop: -8 }}> <div
<div className="hide-child"> className="fr clamp-message"
<p className="v-top label-small-mono gray dib mr3"> style={{ flexGrow: 1, marginTop: -8 }}>
<div className="hide-child" style={paddingTop}>
<p className="v-mid mono f9 gray dib mr3">
{props.msg.author.slice(0, 1) === "~" ? "" : "~"}
{props.msg.author} {props.msg.author}
</p> </p>
<p className="v-top label-small-mono gray dib">{timestamp}</p> <p className="v-mid mono f9 gray dib">{timestamp}</p>
<p className="v-top label-small-mono ml2 gray dib child"> <p className="v-mid mono f9 ml2 gray dib child">{datestamp}</p>
{datestamp}
</p>
</div> </div>
{this.renderContent()} {this.renderContent()}
</div> </div>
@ -104,16 +132,17 @@ export class Message extends Component {
let timestamp = moment.unix(props.msg.when / 1000).format('hh:mm'); let timestamp = moment.unix(props.msg.when / 1000).format('hh:mm');
return ( return (
<div className={"w-100 pr3 pb1 cf hide-child flex" + pending} <div
style={{ className={"w-100 pr3 cf hide-child flex" + pending}
minHeight: 'min-content' style={{
}}> minHeight: "min-content"
<p className="child pl3 pr2 label-small-mono gray dib">{timestamp}</p> }}>
<div className="fr clamp-message" style={{ flexGrow: 1 }}> <p className="child pt2 pl2 pr1 mono f9 gray dib">{timestamp}</p>
<div className="fr f7 clamp-message" style={{ flexGrow: 1 }}>
{this.renderContent()} {this.renderContent()}
</div> </div>
</div> </div>
) );
} }
} }
} }

View File

@ -18,17 +18,12 @@ export class SidebarInvite extends Component {
return ( return (
<div className='pa3'> <div className='pa3'>
<div className='w-100 v-mid'> <div className='w-100 v-mid'>
<div className="dib mr2 bg-nice-green" style={{ <p className="dib f8 mono">
borderRadius: 12,
width: 12,
height: 12
}}></div>
<p className="dib body-regular fw-normal">
{props.invite.text} {props.invite.text}
</p> </p>
</div> </div>
<a className="dib w-50 pointer btn-font nice-green underline" onClick={this.onAccept.bind(this)}>Accept</a> <a className="dib pointer pa2 f9 bg-green2 white mt4" onClick={this.onAccept.bind(this)}>Accept Invite</a>
<a className="dib w-50 tr pointer btn-font nice-red underline" onClick={this.onDecline.bind(this)}>Decline</a> <a className="dib pointer ml4 pa2 f9 bg-black white mt4" onClick={this.onDecline.bind(this)}>Decline</a>
</div> </div>
) )
} }

View File

@ -50,30 +50,37 @@ export class SidebarItem extends Component {
render() { render() {
const { props, state } = this; const { props, state } = this;
let unreadElem = !!props.unread ? ( let unreadElem = !!props.unread
<div ? "fw7 green2"
className="bg-nice-green dib mr2" : "";
style={{ borderRadius: 6, width: 12, height: 12 }}>
</div> let title = props.title.substr(1);
) : (
<div className="dib"></div>
);
let description = this.getLetter(props.description); let description = this.getLetter(props.description);
let selectedCss = !!props.selected ? 'bg-light-gray' : 'bg-white pointer'; let selectedCss = !!props.selected ? 'bg-gray5' : 'bg-white pointer';
return ( return (
<div className={'pa3 ' + selectedCss} onClick={this.onClick.bind(this)}> <div
<div className='w-100 v-mid'> className={"z1 pa3 pt4 pb4 bb b--gray4 " + selectedCss}
{unreadElem} onClick={this.onClick.bind(this)}>
<p className="dib body-regular lh-16">{props.title.substr(1)}</p> <div className="w-100 v-mid">
<p className={"dib mono f8 " + unreadElem }>
<span className={(unreadElem === "") ? "gray3" : ""}>
{title.substr(0, title.indexOf("/"))}/
</span>
{title.substr(title.indexOf("/") + 1)}
</p>
</div> </div>
<div className="w-100"> <div className="w-100 pt1">
<p className='dib gray label-small-mono mr3 lh-16'>{props.ship}</p> <p className="dib mono f9 mr3">
<p className='dib gray label-small-mono lh-16'>{state.timeSinceNewestMessage}</p> {props.ship === "" ? "" : "~"}
{props.ship}
</p>
<p className="dib mono f9 gray3">{state.timeSinceNewestMessage}</p>
</div> </div>
<p className='label-small gray clamp-3 lh-16 pt1'>{description}</p> <p className="f8 clamp-3 pt2">{description}</p>
</div> </div>
) );
} }
} }

View File

@ -1,6 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { Route, Link } from "react-router-dom";
import { store } from "/store";
import urbitOb from 'urbit-ob'; import urbitOb from 'urbit-ob';
import { deSig } from '/lib/util'; import { deSig } from '/lib/util';
import { ChatTabBar } from '/components/lib/chat-tabbar'; import { ChatTabBar } from '/components/lib/chat-tabbar';
@ -69,56 +72,86 @@ export class MemberScreen extends Component {
); );
}); });
let popoutSwitcher = this.props.popout
? "dn-m dn-l dn-xl"
: "dib-m dib-l dib-xl";
let isinPopout = this.props.popout ? "popout/" : "";
return ( return (
<div className="h-100 w-100 overflow-x-hidden flex flex-column"> <div className="h-100 w-100 overflow-x-hidden flex flex-column">
<div className='pl3 pt2 bb mb3'> <div className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
<h2>{state.station.substr(1)}</h2> style={{ height: "1rem" }}>
<Link to="/~chat/">{"⟵ All Chats"}</Link>
</div>
<div className="pl3 pt4 bb b--gray4 flex relative overflow-x-scroll flex-shrink-0"
style={{ height: 48 }}>
<a className="pointer"
onClick={() => {
store.setState(previousState => ({
sidebarShown: !previousState.sidebarShown
}));
}}>
<img className={`v-btm pr3 dn ` + popoutSwitcher}
src={
this.props.sidebarShown
? "/~chat/img/ChatSwitcherLink.png"
: "/~chat/img/ChatSwitcherClosed.png"
}
height="16"
width="16"/>
</a>
<Link to={`/~chat/` + isinPopout + `room` + state.station}>
<h2
className="mono dib f7 fw4 v-top"
style={{ width: "max-content" }}>
{state.station.substr(1)}
</h2>
</Link>
<ChatTabBar <ChatTabBar
{...props} {...props}
station={state.station} station={state.station}
numPeers={writeGroup.length} numPeers={writeGroup.length}
isOwner={deSig(props.match.params.ship) === window.ship} /> isOwner={deSig(props.match.params.ship) === window.ship}
popout={this.props.popout}
/>
</div> </div>
<div className="w-100 cf"> <div className="w-100 pl3 mt4 cf pr6">
<div className="w-50 fl pa2 pr3"> <div className="w-100 w-50-l w-50-xl fl pa2 pr3 pt3 pt0-l pt0-xl">
<p className="body-regular mb3">Members</p> <p className="f8 pb2">Members</p>
<p className="label-regular gray mb3">{writeText}</p> <p className="f9 gray2 mb3">{writeText}</p>
{writeListMembers} {writeListMembers}
</div> </div>
<div className="w-50 fr pa2 pl3"> <div className="w-100 w-50-l w-50-xl fl pa2 pr3 pt3 pt0-l pt0-xl">
<p className="body-regular mb3">Modify Permissions</p> <p className="f8 pb2">Modify Permissions</p>
<p className="label-regular gray mb3"> <p className="f9 gray2 mb3">{modWriteText}</p>
{modWriteText} {window.ship === deSig(props.match.params.ship) ? (
</p>
{ window.ship === deSig(props.match.params.ship) ? (
<InviteElement <InviteElement
path={`/chat${state.station}/write`} path={`/chat${state.station}/write`}
station={`/${props.match.params.station}`} station={`/${props.match.params.station}`}
permissions={props.write} permissions={props.write}
api={props.api} /> api={props.api}
) : null } />
) : null}
</div> </div>
</div> </div>
<div className="w-100 cf mt2"> <div className="w-100 pl3 mt4 cf pr6">
<div className="w-50 fl pa2 pr3"> <div className="w-100 w-50-l w-50-xl fl pa2 pr3 pt3 pt0-l pt0-xl">
<p className="label-regular gray mb3">{readText}</p> <p className="f9 gray2 db mb3">{readText}</p>
{readListMembers} {readListMembers}
</div> </div>
<div className="w-50 fr pa2 pl3"> <div className="w-100 w-50-l w-50-xl fl pa2 pr3 pt3 pt0-l pt0-xl">
<p className="label-regular gray mb3"> <p className="f9 gray2 db mb3">{modReadText}</p>
{modReadText} {window.ship === deSig(props.match.params.ship) ? (
</p> <InviteElement
{ window.ship === deSig(props.match.params.ship) ? path={`/chat${state.station}/read`}
( <InviteElement station={`/${props.match.params.station}`}
path={`/chat${state.station}/read`} permissions={props.read}
station={`/${props.match.params.station}`} api={props.api}
permissions={props.read} />
api={props.api}/> ) : null}
) : null
}
</div> </div>
</div> </div>
</div> </div>
) );
} }
} }

View File

@ -1,9 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { Route, Link } from 'react-router-dom';
import { uuid, isPatTa, deSig } from '/lib/util'; import { uuid, isPatTa, deSig } from '/lib/util';
import urbitOb from 'urbit-ob'; import urbitOb from 'urbit-ob';
export class NewScreen extends Component { export class NewScreen extends Component {
constructor(props) { constructor(props) {
@ -13,6 +13,7 @@ export class NewScreen extends Component {
idName: '', idName: '',
invites: '', invites: '',
security: 'village', security: 'village',
securityDescription: 'Invite-only chat. Default membership administration.',
idError: false, idError: false,
inviteError: false, inviteError: false,
allowHistory: true allowHistory: true
@ -33,6 +34,27 @@ export class NewScreen extends Component {
props.history.push('/~chat/room' + station); props.history.push('/~chat/room' + station);
} }
} }
if (prevState.security !== this.state.security) {
let securityText = '';
switch (this.state.security) {
case 'village':
securityText = 'Invite-only chat. Default membership administration.';
break;
case 'channel':
securityText = 'Completely public chat. Default membership administration.';
break;
case 'journal':
securityText = 'Similar to a blog. Publicly readable/subscribable, invited members can write to journal.'
break;
case 'mailbox':
securityText = 'Similar to email. Anyone can write to the mailbox, invited members can read messages.'
break;
}
this.setState({ securityDescription: securityText });
}
} }
idChange(event) { idChange(event) {
@ -138,15 +160,15 @@ export class NewScreen extends Component {
} }
render() { render() {
let createClasses = "db label-regular mt4 btn-font pointer underline bn"; let createClasses = "pointer db f9 green2 ba pa2 b--green2";
if (!this.state.idName) { if (!this.state.idName) {
createClasses = createClasses + ' gray'; createClasses = 'pointer db f9 gray2 ba pa2 b--gray3';
} }
let idErrElem = (<span />); let idErrElem = (<span />);
if (this.state.idError) { if (this.state.idError) {
idErrElem = ( idErrElem = (
<span className="body-small inter nice-red db"> <span className="f9 inter red2 db">
Chat must have a valid name. Chat must have a valid name.
</span> </span>
); );
@ -155,22 +177,25 @@ export class NewScreen extends Component {
let invErrElem = (<span />); let invErrElem = (<span />);
if (this.state.inviteError) { if (this.state.inviteError) {
invErrElem = ( invErrElem = (
<span className="body-small inter nice-red db"> <span className="f9 inter red2 db">
Invites must be validly formatted ship names. Invites must be validly formatted ship names.
</span> </span>
); );
} }
return ( return (
<div className="h-100 w-100 pa3 pt2 overflow-x-hidden flex flex-column"> <div className="h-100 w-100 w-50-l w-50-xl pa3 pt2 overflow-x-hidden flex flex-column">
<h2 className="mb3">Create</h2> <div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<div className="w-50"> <Link to="/~chat/">{"⟵ All Chats"}</Link>
<p className="body-medium db">Chat Name</p> </div>
<p className="body-small db mt2 mb3"> <h2 className="mb3 f8">Create New Chat</h2>
Name this chat. Names must be lowercase and only contain letters, numbers, and dashes. <div className="w-100">
<p className="f8 mt3 lh-copy db">Chat Name</p>
<p className="f9 gray2 db mb4">
Alphanumeric characters, dashes, and slashes only
</p> </p>
<textarea <textarea
className="body-regular fw-normal ba pa2 db w-100" className="f7 ba b--gray3 pa3 db w-100"
placeholder="secret-chat" placeholder="secret-chat"
rows={1} rows={1}
style={{ style={{
@ -178,13 +203,28 @@ export class NewScreen extends Component {
}} }}
onChange={this.idChange} /> onChange={this.idChange} />
{idErrElem} {idErrElem}
<p className="body-medium mt3 db">Invites</p> <p className="f8 mt6 lh-copy db">Chat Type</p>
<p className="body-small db mt2 mb3"> <p className="f9 gray2 db mb4">Change the chat's visibility and type</p>
Invite new participants to this chat. <div className="dropdown relative">
<select
style={{WebkitAppearance: "none"}}
className="pa3 f8 bg-white br0 w-100 inter"
value={this.state.securityValue}
onChange={this.securityChange}>
<option value="village">Village</option>
<option value="channel">Channel</option>
<option value="journal">Journal</option>
<option value="mailbox">Mailbox</option>
</select>
</div>
<p className="f9 gray2 db lh-copy pt2 mb4">{this.state.securityDescription}</p>
<p className="f8 mt4 lh-copy db">Invites</p>
<p className="f9 gray2 db mb4">
Invite participants to this chat
</p> </p>
<textarea <textarea
ref={ e => { this.textarea = e; } } ref={e => { this.textarea = e; }}
className="body-regular mono fw-normal ba pa2 mb2 db w-100" className="f7 mono ba b--gray3 pa3 mb4 db w-100"
placeholder="~zod, ~bus" placeholder="~zod, ~bus"
spellCheck="false" spellCheck="false"
style={{ style={{
@ -193,31 +233,10 @@ export class NewScreen extends Component {
}} }}
onChange={this.invChange} /> onChange={this.invChange} />
{invErrElem} {invErrElem}
<select
value={this.state.securityValue}
onChange={this.securityChange}>
<option value="village">Village</option>
<option value="channel">Channel</option>
<option value="journal">Journal</option>
<option value="mailbox">Mailbox</option>
</select>
<p className="body-medium mt3 db">Chat History</p>
<div className="db mt2">
<input
type="checkbox"
checked={this.state.allowHistory}
onChange={this.allowHistoryChange.bind(this)}
className="dib mr2"
/>
<p className="body-small db mt2 mb3 dib">
Allow participants to download the chat history upon joining.
</p>
</div>
<button <button
onClick={this.onClickCreate.bind(this)} onClick={this.onClickCreate.bind(this)}
className={createClasses} className={createClasses}
style={{ fontSize: '18px' }} >Start Chat</button>
>-> Create</button>
</div> </div>
</div> </div>
); );

View File

@ -65,128 +65,176 @@ export class Root extends Component {
return ( return (
<BrowserRouter> <BrowserRouter>
<div> <div>
<Route exact path="/~chat" <Route
render={ (props) => { exact
return ( path="/~chat"
<Skeleton sidebar={renderChannelSidebar(props)}> render={props => {
<div className="h-100 w-100 overflow-x-hidden flex flex-column"> return (
<div className="pl3 pr3 pt2 pb3"> <Skeleton
<h2>Home</h2> chatHideonMobile={true}
<p className="body-regular-400 pt3"> sidebarShown={state.sidebarShown}
<Link to="/~chat/new">Create a new chat</Link> or&nbsp; sidebar={renderChannelSidebar(props)}
<Link to="/~chat/join">join an existing one.</Link> >
</p> <div className="h-100 w-100 overflow-x-hidden flex flex-column bg-gray0">
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
<p className="f8 pt3 gray2 w-100 h-100 dtc v-mid tc">
Select, create, or join a chat to begin.
</p>
</div>
</div> </div>
</div> </Skeleton>
</Skeleton> );
); }}
}} /> />
<Route exact path="/~chat/new" <Route
render={ (props) => { exact
return ( path="/~chat/new"
<Skeleton render={props => {
spinner={this.state.spinner} return (
sidebar={renderChannelSidebar(props)}> <Skeleton
<NewScreen sidebarHideOnMobile={true}
setSpinner={this.setSpinner} spinner={this.state.spinner}
api={api} sidebar={renderChannelSidebar(props)}
inbox={state.inbox || {}} sidebarShown={state.sidebarShown}
{...props} >
/> <NewScreen
</Skeleton> setSpinner={this.setSpinner}
); api={api}
}} /> inbox={state.inbox || {}}
<Route exact path="/~chat/join" {...props}
render={ (props) => { />
return ( </Skeleton>
<Skeleton sidebar={renderChannelSidebar(props)}> );
<JoinScreen }}
api={api} />
inbox={state.inbox} <Route
{...props} exact
/> path="/~chat/join/:ship?/:station?"
</Skeleton> render={props => {
); let station =
}} /> props.match.params.ship
<Route exact path="/~chat/room/:ship/:station+" + "/" +
render={ (props) => { props.match.params.station;
let station = return (
`/${props.match.params.ship}/${props.match.params.station}`; <Skeleton
let mailbox = state.inbox[station] || { sidebarHideOnMobile={true}
config: { sidebar={renderChannelSidebar(props)}
read: 0, sidebarShown={state.sidebarShown}
length: 0 >
}, <JoinScreen api={api} inbox={state.inbox} autoJoin={station} {...props} />
envelopes: [] </Skeleton>
}; );
}}
/>
<Route
exact
path="/~chat/(popout)?/room/:ship/:station+"
render={props => {
let station = `/${props.match.params.ship}/${props.match.params.station}`;
let mailbox = state.inbox[station] || {
config: {
read: -1,
length: 0
},
envelopes: []
};
let write = state.groups[`/chat${station}/write`] || new Set([]); let write = state.groups[`/chat${station}/write`] || new Set([]);
return ( let popout = props.match.url.includes("/popout/");
<Skeleton sidebar={renderChannelSidebar(props) }>
<ChatScreen
api={api}
subscription={subscription}
read={mailbox.config.read}
length={mailbox.config.length}
envelopes={mailbox.envelopes}
inbox={state.inbox}
group={write}
permissions={state.permissions}
pendingMessages={state.pendingMessages}
{...props}
/>
</Skeleton>
);
}} />
<Route exact path="/~chat/members/:ship/:station+"
render={ (props) => {
let station =
`/${props.match.params.ship}/${props.match.params.station}`;
let read = state.permissions[`/chat${station}/read`] || {
kind: '',
who: new Set([])
};
let write = state.permissions[`/chat${station}/write`] || {
kind: '',
who: new Set([])
};
return ( return (
<Skeleton sidebar={renderChannelSidebar(props) }> <Skeleton
<MemberScreen sidebarHideOnMobile={true}
{...props} popout={popout}
api={api} sidebarShown={state.sidebarShown}
read={read} sidebar={renderChannelSidebar(props)}
write={write} >
permissions={state.permissions} <ChatScreen
/> api={api}
</Skeleton> subscription={subscription}
); read={mailbox.config.read}
}} /> envelopes={mailbox.envelopes}
<Route exact path="/~chat/settings/:ship/:station+" inbox={state.inbox}
render={ (props) => { group={write}
let station = permissions={state.permissions}
`/${props.match.params.ship}/${props.match.params.station}`; pendingMessages={state.pendingMessages}
let write = state.groups[`/chat${station}/write`] || new Set([]); popout={popout}
sidebarShown={state.sidebarShown}
{...props}
/>
</Skeleton>
);
}}
/>
<Route
exact
path="/~chat/(popout)?/members/:ship/:station+"
render={props => {
let station = `/${props.match.params.ship}/${props.match.params.station}`;
let read = state.permissions[`/chat${station}/read`] || {
kind: "",
who: new Set([])
};
let write = state.permissions[`/chat${station}/write`] || {
kind: "",
who: new Set([])
};
let popout = props.match.url.includes("/popout/");
return ( return (
<Skeleton <Skeleton
spinner={this.state.spinner} sidebarHideOnMobile={true}
sidebar={renderChannelSidebar(props) }> sidebarShown={state.sidebarShown}
<SettingsScreen popout={popout}
{...props} sidebar={renderChannelSidebar(props)}
setSpinner={this.setSpinner} >
api={api} <MemberScreen
group={write} {...props}
inbox={state.inbox} api={api}
/> read={read}
</Skeleton> write={write}
); permissions={state.permissions}
}} /> popout={popout}
sidebarShown={state.sidebarShown}
/>
</Skeleton>
);
}}
/>
<Route
exact
path="/~chat/(popout)?/settings/:ship/:station+"
render={props => {
let station = `/${props.match.params.ship}/${props.match.params.station}`;
let write = state.groups[`/chat${station}/write`] || new Set([]);
let popout = props.match.url.includes("/popout/");
return (
<Skeleton
sidebarHideOnMobile={true}
spinner={this.state.spinner}
popout={popout}
sidebarShown={state.sidebarShown}
sidebar={renderChannelSidebar(props)}
>
<SettingsScreen
{...props}
setSpinner={this.setSpinner}
api={api}
group={write}
inbox={state.inbox}
popout={popout}
sidebarShown={state.sidebarShown}
/>
</Skeleton>
);
}}
/>
</div> </div>
</BrowserRouter> </BrowserRouter>
) );
} }
} }

View File

@ -1,6 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { deSig } from '/lib/util'; import { deSig } from '/lib/util';
import { Route, Link } from "react-router-dom";
import { store } from "/store";
import { ChatTabBar } from '/components/lib/chat-tabbar'; import { ChatTabBar } from '/components/lib/chat-tabbar';
@ -43,22 +46,25 @@ export class SettingsScreen extends Component {
renderDelete() { renderDelete() {
const { props, state } = this; const { props, state } = this;
let titleText = "Delete Chat"; let chatOwner = (deSig(props.match.params.ship) === window.ship);
let descriptionText = "Permanently delete this chat.";
let buttonText = "-> Delete";
if (deSig(props.match.params.ship) !== window.ship) { let deleteButtonClasses = (chatOwner) ? 'b--red2 red2 pointer' : 'b--grey3 grey3 c-default';
titleText = "Leave Chat" let leaveButtonClasses = (!chatOwner) ? "pointer" : "c-default";
descriptionText = "You will no longer have access to this chat."
buttonText = "-> Leave";
}
return ( return (
<div className="w-50 fl pl2 mt3"> <div>
<p className="body-regular">{titleText}</p> <div className={"w-100 fl mt3 " + ((chatOwner) ? 'o-30' : '')}>
<p className="label-regular gray mb3">{descriptionText}</p> <p className="f8 mt3 lh-copy db">Leave Chat</p>
<a onClick={this.deleteChat.bind(this)} <p className="f9 gray2 db mb4">Remove this chat from your chat list. You will need to request for access again.</p>
className="pointer btn-font underline nice-red">{buttonText}</a> <a onClick={(!chatOwner) ? this.deleteChat.bind(this) : null}
className={"dib f9 black ba pa2 b--black " + leaveButtonClasses}>Leave this chat</a>
</div>
<div className={"w-100 fl mt3 " + ((!chatOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Delete Chat</p>
<p className="f9 gray2 db mb4">Permenantly delete this chat. (All current members will no longer see this chat)</p>
<a onClick={(chatOwner) ? this.deleteChat.bind(this) : null}
className={"dib f9 ba pa2 " + deleteButtonClasses}>Delete this chat</a>
</div>
</div> </div>
); );
} }
@ -76,35 +82,112 @@ export class SettingsScreen extends Component {
return ( return (
<div className="h-100 w-100 overflow-x-hidden flex flex-column"> <div className="h-100 w-100 overflow-x-hidden flex flex-column">
<div className='pl3 pt2 bb mb3'> <div
<h2>{state.station.substr(1)}</h2> 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="pl3 pt4 bb b--gray4 flex relative overflow-x-scroll flex-shrink-0"
style={{ height: 48 }}
>
<a
className="pointer"
onClick={() => {
store.setState(previousState => ({
sidebarShown: !previousState.sidebarShown
}));
}}
>
<img
className={`v-btm pr3 dn ` + popoutSwitcher}
src={
this.props.sidebarShown
? "/~chat/img/ChatSwitcherLink.png"
: "/~chat/img/ChatSwitcherClosed.png"
}
height="16"
width="16"
/>
</a>
<Link to={`/~chat/` + isinPopout + `room` + state.station}>
<h2
className="mono dib f7 fw4 v-top"
style={{ width: "max-content" }}
>
{state.station.substr(1)}
</h2>
</Link>
<ChatTabBar <ChatTabBar
{...props} {...props}
station={state.station} station={state.station}
numPeers={writeGroup.length} /> numPeers={writeGroup.length} />
</div> </div>
<div className="w-100 cf pa3"> <div className="w-100 pl3 mt4 cf">
<h2>{text}</h2> <h2 className="f8 pb2">{text}</h2>
</div> </div>
</div> </div>
); );
} }
let popoutSwitcher = this.props.popout
? "dn-m dn-l dn-xl"
: "dib-m dib-l dib-xl";
let isinPopout = this.props.popout ? "popout/" : "";
return ( return (
<div className="h-100 w-100 overflow-x-hidden flex flex-column"> <div className="h-100 w-100 overflow-x-hidden flex flex-column">
<div className='pl3 pt2 bb mb3'> <div
<h2>{state.station.substr(1)}</h2> 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="pl3 pt4 bb b--gray4 flex relative overflow-x-scroll flex-shrink-0"
style={{ height: 48 }}
>
<a
className="pointer"
onClick={() => {
store.setState(previousState => ({
sidebarShown: !previousState.sidebarShown
}));
}}
>
<img
className={`v-btm pr3 dn ` + popoutSwitcher}
src={
this.props.sidebarShown
? "/~chat/img/ChatSwitcherLink.png"
: "/~chat/img/ChatSwitcherClosed.png"
}
height="16"
width="16"
/>
</a>
<Link to={`/~chat/` + isinPopout + `room` + state.station}>
<h2
className="mono dib f7 fw4 v-top"
style={{ width: "max-content" }}
>
{state.station.substr(1)}
</h2>
</Link>
<ChatTabBar <ChatTabBar
{...props} {...props}
station={state.station} station={state.station}
numPeers={writeGroup.length} numPeers={writeGroup.length}
isOwner={deSig(props.match.params.ship) === window.ship} /> isOwner={deSig(props.match.params.ship) === window.ship}
popout={this.props.popout}
/>
</div> </div>
<div className="w-100 cf pa3"> <div className="w-100 pl3 mt4 cf">
<h2>Settings</h2> <h2 className="f8 pb2">Chat Settings</h2>
{this.renderDelete()} {this.renderDelete()}
</div> </div>
</div> </div>
) );
} }
} }

View File

@ -13,6 +13,10 @@ export class Sidebar extends Component {
this.props.history.push('/~chat/new'); this.props.history.push('/~chat/new');
} }
onClickJoin() {
this.props.history.push('/~chat/join')
}
render() { render() {
const { props, state } = this; const { props, state } = this;
let station = `/${props.match.params.ship}/${props.match.params.station}`; let station = `/${props.match.params.ship}/${props.match.params.station}`;
@ -67,20 +71,23 @@ export class Sidebar extends Component {
}); });
return ( return (
<div className="h-100 w-100 overflow-x-hidden flex flex-column"> <div className="h-100-minus-96-s h-100 w-100 overflow-x-hidden flex flex-column relative z1">
<div className="pl3 pr3 pt2 pb3 cf bb b--black-30" style={{height: '88px'}}> <div className="overflow-y-auto h-100">
<h2 className="dib w-50 gray"><Link to="/~chat">Chat</Link></h2>
<a
className="dib tr w-50 pointer plus-font"
onClick={this.onClickNew.bind(this)}>+</a>
</div>
<div className="overflow-y-auto" style={{
height: 'calc(100vh - 60px - 48px)'
}}>
{sidebarInvites} {sidebarInvites}
{sidebarItems} {sidebarItems}
</div> </div>
<div className="absolute z2 tc w-100 bg-transparent"
style={{ bottom: 10 }}>
<a className="dib f9 pa3 bt bb bl br tc pointer bg-white"
onClick={this.onClickNew.bind(this)}>
Create New Chat
</a>
<a className="dib f9 pa3 bt bb br tl pointer bg-white"
onClick={this.onClickJoin.bind(this)}>
Join Existing Chat
</a>
</div>
</div> </div>
) );
} }
} }

View File

@ -5,20 +5,53 @@ import { HeaderBar } from '/components/lib/header-bar.js';
export class Skeleton extends Component { export class Skeleton extends Component {
render() { render() {
let sidebarHide = (!this.props.sidebarShown || this.props.popout)
? "dn"
: "";
let sidebarHideOnMobile = this.props.sidebarHideOnMobile
? "dn-s"
: "";
let chatHideOnMobile = this.props.chatHideonMobile
? "dn-s"
: "";
return ( return (
<div className="h-100 w-100 absolute"> <div className="h-100 w-100 absolute">
<HeaderBar spinner={this.props.spinner}/> <HeaderBar spinner={this.props.spinner} popout={this.props.popout} />
<div className="cf w-100 absolute flex" <div className={`cf w-100 absolute flex ` +
style={{ ((this.props.chatHideonMobile)
height: 'calc(100% - 48px)' ? "h-100 "
}}> : "h-100-minus-48-s ") +
<div className="fl h-100 br b--black-30 overflow-x-hidden" style={{ flexBasis: 320 }}> ((this.props.popout)
? "h-100"
: "h-minus-48-m h-100-minus-48-l h-100-minus-48-xl")}>
<div className={
`fl h-100 br b--gray4 overflow-x-hidden
flex-basis-full-s flex-basis-300-m flex-basis-300-l
flex-basis-300-xl ` +
sidebarHide + " " +
sidebarHideOnMobile
}>
<div className={
chatHideOnMobile === ""
? "dn"
: "db dn-m dn-l dn-xl w-100 inter pt4 f8"
}>
<a className="pl3 pb6" href="/">
{"⟵ Landscape"}
</a>
<div className="bb b--gray4 inter f8 pl3 pt6 pb3">All Chats</div>
</div>
{this.props.sidebar} {this.props.sidebar}
</div> </div>
<div className="h-100 fr" style={{ <div className={"h-100 fr " + chatHideOnMobile}
flexGrow: 1, style={{
width: 'calc(100% - 320px)' flexGrow: 1,
}}> width: "calc(100% - 300px)"
}}>
{this.props.children} {this.props.children}
</div> </div>
</div> </div>

View File

@ -13,6 +13,7 @@ class Store {
permissions: {}, permissions: {},
invites: {}, invites: {},
spinner: false, spinner: false,
sidebarShown: true,
pendingMessages: new Map([]) pendingMessages: new Map([])
}; };