Merge pull request #2714 from liam-fitzgerald/lf/profile-overlay

chat: profile overlay and DMs
This commit is contained in:
matildepark 2020-04-22 18:32:53 -04:00 committed by GitHub
commit 8968190e55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 830 additions and 192 deletions

View File

@ -169,6 +169,15 @@ h2 {
border-radius: 100%; border-radius: 100%;
} }
.shadow-6 {
box-shadow: 2px 4px 20px rgba(0, 0, 0, 0.25);
}
.brt2 {
border-radius: 0.25rem 0.25rem 0 0;
}
.green3 { .green3 {
color: #7ea899; color: #7ea899;
} }
@ -363,6 +372,9 @@ pre.CodeMirror-placeholder.CodeMirror-line-like { color: var(--gray); }
.b--white-d { .b--white-d {
border-color: #fff; border-color: #fff;
} }
.b--green2-d {
border-color: #2aa779;
}
.bb-d { .bb-d {
border-bottom-width: 1px; border-bottom-width: 1px;
border-bottom-style: solid; border-bottom-style: solid;

View File

@ -288,6 +288,7 @@ export class ChatScreen extends Component {
paddingTop={paddingTop} paddingTop={paddingTop}
paddingBot={paddingBot} paddingBot={paddingBot}
pending={!!msg.pending} pending={!!msg.pending}
group={props.association}
/> />
); );
if(unread > 0 && i === unread) { if(unread > 0 && i === unread) {
@ -327,7 +328,7 @@ export class ChatScreen extends Component {
if (navigator.userAgent.includes("Firefox")) { if (navigator.userAgent.includes("Firefox")) {
return ( return (
<div className="overflow-y-scroll h-100" onScroll={this.onScroll} ref={e => { this.scrollContainer = e; }}> <div className="relative overflow-y-scroll h-100" onScroll={this.onScroll} ref={e => { this.scrollContainer = e; }}>
<div <div
className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse" className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
style={{ resize: "vertical" }} style={{ resize: "vertical" }}
@ -358,7 +359,7 @@ export class ChatScreen extends Component {
else { else {
return ( return (
<div <div
className="overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse" className="overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse relative"
style={{ height: "100%", resize: "vertical" }} style={{ height: "100%", resize: "vertical" }}
onScroll={this.onScroll} onScroll={this.onScroll}
> >

View File

@ -1,16 +1,15 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
import cn from 'classnames';
import { UnControlled as CodeEditor } from 'react-codemirror2'; import { UnControlled as CodeEditor } from 'react-codemirror2';
import CodeMirror from 'codemirror';
import 'codemirror/mode/markdown/markdown'; import 'codemirror/mode/markdown/markdown';
import 'codemirror/addon/display/placeholder'; import 'codemirror/addon/display/placeholder';
import { Sigil } from '/components/lib/icons/sigil'; import { Sigil } from '/components/lib/icons/sigil';
import { ShipSearch } from '/components/lib/ship-search';
import { uxToHex, hexToRgba } from '/lib/util'; import { uxToHex } from '/lib/util';
const MARKDOWN_CONFIG = { const MARKDOWN_CONFIG = {
name: 'markdown', name: 'markdown',
@ -32,78 +31,6 @@ const MARKDOWN_CONFIG = {
} }
}; };
function ChatInputSuggestion({ ship, contacts, selected, onSelect }) {
const contact = contacts[ship];
let color = '#000000';
let sigilClass = 'v-mid mix-blend-diff';
let nickname;
const nameStyle = {};
const isSelected = ship === selected;
if (contact) {
const hex = uxToHex(contact.color);
color = `#${hex}`;
nameStyle.color = hexToRgba(hex, .7);
nameStyle.textShadow = '0px 0px 0px #000';
nameStyle.filter = 'contrast(1.3) saturate(1.5)';
sigilClass = 'v-mid';
nickname = contact.nickname;
}
return (
<div
onClick={() => onSelect(ship)}
className={cn(
'f8 pv1 ph3 pointer hover-bg-gray1-d hover-bg-gray4 relative flex items-center',
{
'white-d bg-gray0-d bg-white': !isSelected,
'black-d bg-gray1-d bg-gray4': isSelected
}
)}
key={ship}
>
<Sigil
ship={'~' + ship}
size={24}
color={color}
classes={sigilClass}
/>
{ nickname && (
<p style={nameStyle} className="dib ml4 b" >{nickname}</p>)
}
<div className="mono gray2 ml4">
{'~' + ship}
</div>
<p className="nowrap ml4">
{status}
</p>
</div>
);
}
function ChatInputSuggestions({ suggestions, onSelect, selected, contacts }) {
return (
<div
style={{
bottom: '90%',
left: '48px'
}}
className={
'absolute black white-d bg-white bg-gray0-d ' +
'w7 pv3 z-1 mt1 ba b--gray1-d b--gray4'
}
>
{suggestions.map(ship =>
(<ChatInputSuggestion
onSelect={onSelect}
key={ship}
selected={selected}
contacts={contacts}
ship={ship}
/>)
)}
</div>
);
}
export class ChatInput extends Component { export class ChatInput extends Component {
constructor(props) { constructor(props) {
@ -111,8 +38,7 @@ export class ChatInput extends Component {
this.state = { this.state = {
message: '', message: '',
patpSuggestions: [], patpSearch: null
selectedSuggestion: null
}; };
this.textareaRef = React.createRef(); this.textareaRef = React.createRef();
@ -121,10 +47,8 @@ export class ChatInput extends Component {
this.messageChange = this.messageChange.bind(this); this.messageChange = this.messageChange.bind(this);
this.patpAutocomplete = this.patpAutocomplete.bind(this); this.patpAutocomplete = this.patpAutocomplete.bind(this);
this.nextAutocompleteSuggestion = this.nextAutocompleteSuggestion.bind(this);
this.completePatp = this.completePatp.bind(this); this.completePatp = this.completePatp.bind(this);
this.clearSearch = this.clearSearch.bind(this);
this.clearSuggestions = this.clearSuggestions.bind(this);
this.toggleCode = this.toggleCode.bind(this); this.toggleCode = this.toggleCode.bind(this);
@ -185,52 +109,20 @@ export class ChatInput extends Component {
this.setState({ selectedSuggestion: patpSuggestions[idx] }); this.setState({ selectedSuggestion: patpSuggestions[idx] });
} }
patpAutocomplete(message, fresh = false) { patpAutocomplete(message) {
const match = /~([a-zA-Z\-]*)$/.exec(message); const match = /~([a-zA-Z\-]*)$/.exec(message);
if (!match ) { if (!match ) {
this.setState({ patpSuggestions: [] }); this.setState({ patpSearch: null });
return; return;
} }
this.setState({ patpSearch: match[1].toLowerCase() });
const needle = match[1].toLowerCase();
const matchString = (hay) => {
hay = hay.toLowerCase();
return hay.startsWith(needle)
|| _.some(_.words(hay), s => s.startsWith(needle));
};
const contacts = _.chain(this.props.contacts)
.defaultTo({})
.map((details, ship) => ({ ...details, ship }))
.filter(({ nickname, ship }) => matchString(nickname) || matchString(ship))
.map('ship')
.value();
const suggestions = _.chain(this.props.envelopes)
.defaultTo([])
.map('author')
.uniq()
.reverse()
.filter(matchString)
.union(contacts)
.filter(s => s.length < 28) // exclude comets
.take(5)
.value();
const newState = {
patpSuggestions: suggestions,
selectedSuggestion: suggestions[0]
};
this.setState(newState);
} }
clearSuggestions() { clearSearch() {
this.setState({ this.setState({
patpSuggestions: [] patpSearch: null
}); });
} }
@ -247,13 +139,13 @@ export class ChatInput extends Component {
const lastCol = this.editor.getLineHandle(lastRow).text.length; const lastCol = this.editor.getLineHandle(lastRow).text.length;
this.editor.setCursor(lastRow, lastCol); this.editor.setCursor(lastRow, lastCol);
this.setState({ this.setState({
patpSuggestions: [] patpSearch: null
}); });
} }
messageChange(editor, data, value) { messageChange(editor, data, value) {
const { patpSuggestions } = this.state; const { patpSearch } = this.state;
if(patpSuggestions.length !== 0) { if(patpSearch !== null) {
this.patpAutocomplete(value, false); this.patpAutocomplete(value, false);
} }
} }
@ -384,7 +276,12 @@ export class ChatInput extends Component {
const sigilClass = props.ownerContact const sigilClass = props.ownerContact
? '' : 'mix-blend-diff'; ? '' : 'mix-blend-diff';
const completeActive = this.state.patpSuggestions.length !== 0; const candidates = _.chain(this.props.envelopes)
.defaultTo([])
.map('author')
.uniq()
.reverse()
.value();
const codeTheme = state.code ? ' code' : ''; const codeTheme = state.code ? ' code' : '';
@ -398,33 +295,11 @@ export class ChatInput extends Component {
placeholder: state.code ? 'Code...' : props.placeholder, placeholder: state.code ? 'Code...' : props.placeholder,
extraKeys: { extraKeys: {
Tab: cm => Tab: cm =>
completeActive this.patpAutocomplete(cm.getValue(), true),
? this.nextAutocompleteSuggestion()
: this.patpAutocomplete(cm.getValue(), true),
'Shift-Tab': cm =>
completeActive
? this.nextAutocompleteSuggestion(true)
: CodeMirror.Pass,
'Up': cm =>
completeActive
? this.nextAutocompleteSuggestion(true)
: CodeMirror.Pass,
'Escape': cm =>
completeActive
? this.clearSuggestions(true)
: CodeMirror.Pass,
'Down': cm =>
completeActive
? this.nextAutocompleteSuggestion()
: CodeMirror.Pass,
'Enter': cm => 'Enter': cm =>
completeActive this.messageSubmit(),
? this.completePatp(state.selectedSuggestion)
: this.messageSubmit(),
'Shift-3': cm => 'Shift-3': cm =>
cm.getValue().length === 0 this.toggleCode()
? this.toggleCode()
: CodeMirror.Pass
} }
}; };
@ -432,15 +307,15 @@ export class ChatInput extends Component {
<div className="pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white bg-gray0-d relative" <div className="pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white bg-gray0-d relative"
style={{ flexGrow: 1 }} style={{ flexGrow: 1 }}
> >
{state.patpSuggestions.length !== 0 && ( <ShipSearch
<ChatInputSuggestions popover
onSelect={this.completePatp} onSelect={this.completePatp}
suggestions={state.patpSuggestions} onClear={this.clearSearch}
selected={state.selectedSuggestion}
contacts={props.contacts} contacts={props.contacts}
candidates={candidates}
searchTerm={this.state.patpSearch}
cm={this.editor}
/> />
)}
<div <div
className="fl" className="fl"
style={{ style={{

View File

@ -22,7 +22,8 @@ export class Sigil extends Component {
patp: props.ship, patp: props.ship,
renderer: reactRenderer, renderer: reactRenderer,
size: props.size, size: props.size,
colors: [props.color, "white"] colors: [props.color, "white"],
class: props.svgClass
})} })}
</div> </div>
); );

View File

@ -289,7 +289,7 @@ export class InviteSearch extends Component {
render() { render() {
const { props, state } = this; const { props, state } = this;
let searchDisabled = false; let searchDisabled = props.disabled;
if (props.invites.groups) { if (props.invites.groups) {
if (props.invites.groups.length > 0) { if (props.invites.groups.length > 0) {
searchDisabled = true; searchDisabled = true;

View File

@ -1,5 +1,7 @@
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 { ProfileOverlay } from '/components/lib/profile-overlay';
import { OverlaySigil } from '/components/lib/overlay-sigil';
import classnames from 'classnames'; import classnames from 'classnames';
import { Route, Link } from 'react-router-dom' import { Route, Link } from 'react-router-dom'
import { uxToHex, cite, writeText } from '/lib/util'; import { uxToHex, cite, writeText } from '/lib/util';
@ -53,6 +55,7 @@ export class Message extends Component {
iframe.setAttribute('src', iframe.getAttribute('data-src')); iframe.setAttribute('src', iframe.getAttribute('data-src'));
} }
renderContent() { renderContent() {
const { props } = this; const { props } = this;
let letter = props.msg.letter; let letter = props.msg.letter;
@ -190,20 +193,20 @@ export class Message extends Component {
return ( return (
<div <div
ref={this.containerRef}
className={ className={
"w-100 f7 pl3 pt4 pr3 cf flex lh-copy " + " " + pending "w-100 f7 pl3 pt4 pr3 cf flex lh-copy " + " " + pending
} }
style={{ style={{
minHeight: "min-content" minHeight: "min-content"
}}> }}>
<div className="fl mr3 v-top bg-white bg-gray0-d"> <OverlaySigil
<Sigil
ship={props.msg.author} ship={props.msg.author}
size={24} contact={contact}
color={color} color={color}
classes={sigilClass} sigilClass={sigilClass}
/> group={props.group}
</div> className="fl pr3 v-top bg-white bg-gray0-d" />
<div <div
className="fr clamp-message white-d" className="fr clamp-message white-d"
style={{ flexGrow: 1, marginTop: -8 }}> style={{ flexGrow: 1, marginTop: -8 }}>

View File

@ -0,0 +1,99 @@
import React, { Component } from "react";
import { Sigil } from "/components/lib/icons/sigil";
import {
ProfileOverlay,
OVERLAY_HEIGHT
} from "/components/lib/profile-overlay";
export class OverlaySigil extends Component {
constructor() {
super();
this.state = {
clicked: false,
captured: false,
topSpace: 0,
bottomSpace: 0
};
this.containerRef = React.createRef();
this.profileShow = this.profileShow.bind(this);
this.profileHide = this.profileHide.bind(this);
this.updateContainerInterval = setInterval(
this.updateContainerOffset.bind(this),
1000
);
}
componentDidMount() {
this.updateContainerOffset();
}
componentWillUnmount() {
if (this.updateContainerInterval) {
clearInterval(this.updateContainerInterval);
this.updateContainerInterval = null;
}
}
profileShow() {
this.setState({ profileClicked: true });
}
profileHide() {
this.setState({ profileClicked: false });
}
updateContainerOffset() {
if (this.containerRef && this.containerRef.current) {
const parent = this.containerRef.current.offsetParent;
const { offsetTop } = this.containerRef.current;
let bottomSpace, topSpace;
if(navigator.userAgent.includes('Firefox')) {
topSpace = offsetTop - parent.scrollTop - OVERLAY_HEIGHT / 2;
bottomSpace = parent.clientHeight - topSpace - OVERLAY_HEIGHT;
} else {
topSpace = offsetTop + parent.scrollHeight - parent.clientHeight - parent.scrollTop;
bottomSpace = parent.clientHeight - topSpace - OVERLAY_HEIGHT;
}
this.setState({
topSpace,
bottomSpace
});
}
}
render() {
const { props, state } = this;
return (
<div
onClick={this.profileShow}
className={props.className + " pointer relative"}
ref={this.containerRef}
style={{ height: "24px" }}
>
{state.profileClicked && (
<ProfileOverlay
ship={props.ship}
contact={props.contact}
color={props.color}
topSpace={state.topSpace}
bottomSpace={state.bottomSpace}
group={props.group}
onDismiss={this.profileHide}
/>
)}
<Sigil
ship={props.ship}
size={24}
color={props.color}
classes={props.sigilClass}
/>
</div>
);
}
}

View File

@ -0,0 +1,97 @@
import React, { Component } from "react";
import { Link } from "react-router-dom";
import { cite } from "/lib/util";
import { Sigil } from "/components/lib/icons/sigil";
export const OVERLAY_HEIGHT = 250;
export class ProfileOverlay extends Component {
constructor() {
super();
this.popoverRef = React.createRef();
this.onDocumentClick = this.onDocumentClick.bind(this);
}
componentDidMount() {
document.addEventListener("mousedown", this.onDocumentClick);
document.addEventListener("touchstart", this.onDocumentClick);
}
componentWillUnmount() {
document.removeEventListener("mousedown", this.onDocumentClick);
document.removeEventListener("touchstart", this.onDocumentClick);
}
onDocumentClick(event) {
const { popoverRef } = this;
// Do nothing if clicking ref's element or descendent elements
if (!popoverRef.current || popoverRef.current.contains(event.target)) {
return;
}
this.props.onDismiss();
}
render() {
const { contact, ship, color, topSpace, bottomSpace, group } = this.props;
let top, bottom;
if (topSpace < OVERLAY_HEIGHT / 2) {
top = `0px`;
}
if (bottomSpace < OVERLAY_HEIGHT / 2) {
bottom = `0px`;
}
if (!(top || bottom)) {
bottom = `-${Math.round(OVERLAY_HEIGHT / 2)}px`;
}
const containerStyle = { top, bottom, left: "100%" };
const isOwn = window.ship === ship;
const identityHref = group["group-path"].startsWith("/~/")
? "/~groups/me"
: `/~groups/view${group["group-path"]}/${window.ship}`;
return (
<div
ref={this.popoverRef}
style={containerStyle}
className="flex-col shadow-6 br2 bg-white bg-gray0-d inter absolute z-1 f9 lh-solid"
>
<div style={{ height: "160px" }}>
<Sigil
ship={ship}
size={160}
color={color}
classes="brt2"
svgClass="brt2"
/>
</div>
<div className="pv3 pl3 pr2">
{contact && contact.nickname && (
<div className="b white-d">{contact.nickname}</div>
)}
<div className="mono gray2">{cite(`~${ship}`)}</div>
{!isOwn && (
<Link
to={`/~chat/new/dm/~${ship}`}
className="b--green0 b--green2-d b--solid ba green2 mt3 tc pa2 pointer db"
>
Send Message
</Link>
)}
{isOwn && (
<a
href={identityHref}
className="b--black b--white-d ba black white-d mt3 tc pa2 pointer db"
>
Edit Group Identity
</a>
)}
</div>
</div>
);
}
}

View File

@ -0,0 +1,370 @@
import React, { Component } from 'react';
import _ from 'lodash';
import urbitOb from 'urbit-ob';
import Mousetrap from 'mousetrap';
import cn from 'classnames';
import { Sigil } from '/components/lib/icons/sigil';
import { hexToRgba, uxToHex, deSig } from '/lib/util';
function ShipSearchItem({ ship, contacts, selected, onSelect }) {
const contact = contacts[ship];
let color = '#000000';
let sigilClass = 'v-mid mix-blend-diff';
let nickname;
const nameStyle = {};
const isSelected = ship === selected;
if (contact) {
const hex = uxToHex(contact.color);
color = `#${hex}`;
nameStyle.color = hexToRgba(hex, 0.7);
nameStyle.textShadow = '0px 0px 0px #000';
nameStyle.filter = 'contrast(1.3) saturate(1.5)';
sigilClass = 'v-mid';
nickname = contact.nickname;
}
return (
<div
onClick={() => onSelect(ship)}
className={cn(
'f8 pv1 ph3 pointer hover-bg-gray1-d hover-bg-gray4 relative flex items-center',
{
'white-d bg-gray0-d bg-white': !isSelected,
'black-d bg-gray1-d bg-gray4': isSelected
}
)}
key={ship}
>
<Sigil ship={'~' + ship} size={24} color={color} classes={sigilClass} />
{nickname && (
<p style={nameStyle} className="dib ml4 b">
{nickname}
</p>
)}
<div className="mono gray2 gray4-d ml4">{'~' + ship}</div>
<p className="nowrap ml4">{status}</p>
</div>
);
}
export class ShipSearch extends Component {
constructor() {
super();
this.state = {
selected: null,
suggestions: [],
bound: false
};
this.keymap = {
Tab: cm =>
this.nextAutocompleteSuggestion(),
'Shift-Tab': cm =>
this.nextAutocompleteSuggestion(true),
'Up': cm =>
this.nextAutocompleteSuggestion(true),
'Escape': cm =>
this.props.onClear(),
'Down': cm =>
this.nextAutocompleteSuggestion(),
'Enter': (cm) => {
if(this.props.searchTerm !== null) {
this.props.onSelect(this.state.selected);
}
},
'Shift-3': cm =>
this.toggleCode()
};
}
componentDidMount() {
if(this.props.searchTerm !== null) {
this.updateSuggestions(true);
}
}
componentDidUpdate(prevProps) {
const { props, state } = this;
if(!state.bound && props.inputRef) {
this.bindShortcuts();
}
if(props.searchTerm === null) {
if(state.suggestions.length > 0) {
this.setState({ suggestions: [] });
}
this.unbindShortcuts();
return;
}
if (
props.searchTerm === null &&
props.searchTerm !== prevProps.searchTerm &&
props.searchTerm.startsWith(prevProps.searchTerm)
) {
this.updateSuggestions();
} else if (prevProps.searchTerm !== props.searchTerm) {
this.updateSuggestions(true);
}
}
updateSuggestions(isStale = false) {
const needle = this.props.searchTerm;
const matchString = (hay) => {
hay = hay.toLowerCase();
return (
hay.startsWith(needle) ||
_.some(_.words(hay), s => s.startsWith(needle))
);
};
let candidates = this.state.suggestions;
if (isStale || this.state.suggestions.length === 0) {
const contacts = _.chain(this.props.contacts)
.defaultTo({})
.map((details, ship) => ({ ...details, ship }))
.filter(
({ nickname, ship }) => matchString(nickname) || matchString(ship)
)
.map('ship')
.value();
const exactMatch = urbitOb.isValidPatp(`~${needle}`) ? [needle] : [];
candidates = _.chain(this.props.candidates)
.defaultTo([])
.union(contacts)
.union(exactMatch)
.value();
}
const suggestions = _.chain(candidates)
.filter(matchString)
.filter(s => s.length < 28) // exclude comets
.value();
this.bindShortcuts();
this.setState({ suggestions, selected: suggestions[0] });
}
bindCmShortcuts() {
if(!this.props.cm) {
return;
}
this.props.cm.addKeyMap(this.keymap);
}
unbindCmShortcuts() {
if(!this.props.cm) {
return;
}
this.props.cm.removeKeyMap(this.keymap);
}
bindShortcuts() {
if (this.state.bound) {
return;
}
if (!this.props.inputRef) {
return this.bindCmShortcuts();
}
this.setState({ bound: true });
if (!this.mousetrap) {
this.mousetrap = new Mousetrap(this.props.inputRef);
}
this.mousetrap.bind('enter', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.state.selected) {
this.unbindShortcuts();
this.props.onSelect(this.state.selected);
}
});
this.mousetrap.bind('tab', (e) => {
e.preventDefault();
e.stopPropagation();
this.nextAutocompleteSuggestion(false);
});
this.mousetrap.bind(['up', 'shift+tab'], (e) => {
e.preventDefault();
e.stopPropagation();
this.nextAutocompleteSuggestion(true);
});
this.mousetrap.bind('down', (e) => {
e.preventDefault();
e.stopPropagation();
this.nextAutocompleteSuggestion(false);
});
this.mousetrap.bind('esc', (e) => {
e.preventDefault();
e.stopPropagation();
this.props.onClear();
});
}
unbindShortcuts() {
if(!this.props.inputRef) {
this.unbindCmShortcuts();
}
if (!this.state.bound) {
return;
}
this.setState({ bound: false });
this.mousetrap.unbind('enter');
this.mousetrap.unbind('tab');
this.mousetrap.unbind(['up', 'shift+tab']);
this.mousetrap.unbind('down');
this.mousetrap.unbind('esc');
}
nextAutocompleteSuggestion(backward = false) {
const { suggestions } = this.state;
let idx = suggestions.findIndex(s => s === this.state.selected);
idx = backward ? idx - 1 : idx + 1;
idx = idx % suggestions.length;
if (idx < 0) {
idx = suggestions.length - 1;
}
this.setState({ selected: suggestions[idx] });
}
render() {
const { onSelect, contacts, popover, className } = this.props;
const { selected, suggestions } = this.state;
if (suggestions.length === 0) {
return null;
}
const popoverClasses = (popover && ' absolute ') || ' ';
return (
<div
style={
popover
? {
bottom: '90%',
left: '48px'
}
: {}
}
className={
'black white-d bg-white bg-gray0-d ' +
'w7 pv3 z-1 mt1 ba b--gray1-d b--gray4' +
popoverClasses +
className || ''
}
>
{suggestions.slice(0, 5).map(ship => (
<ShipSearchItem
onSelect={onSelect}
key={ship}
selected={selected}
contacts={contacts}
ship={ship}
/>
))}
</div>
);
}
}
export class ShipSearchInput extends Component {
constructor() {
super();
this.state = {
searchTerm: ''
};
this.inputRef = null;
this.popoverRef = null;
this.search = this.search.bind(this);
this.onClick = this.onClick.bind(this);
this.setInputRef = this.setInputRef.bind(this);
}
onClick(event) {
const { popoverRef } = this;
// Do nothing if clicking ref's element or descendent elements
if (!popoverRef || popoverRef.contains(event.target)) {
return;
}
this.props.onClear();
}
componentDidMount() {
document.addEventListener('mousedown', this.onClick);
document.addEventListener('touchstart', this.onClick);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.onClick);
document.removeEventListener('touchstart', this.onClick);
}
setInputRef(ref) {
this.inputRef = ref;
if(ref) {
ref.focus();
}
// update this.inputRef prop
this.forceUpdate();
}
search(e) {
const searchTerm = e.target.value;
this.setState({ searchTerm });
}
render() {
const { state, props } = this;
return (
<div
ref={ref => (this.popoverRef = ref)}
style={{ top: '150%', left: '-80px' }}
className="b--gray2 b--solid ba absolute bg-white bg-gray0-d"
>
<textarea
style={{ resize: 'none', maxWidth: '200px' }}
className="ma2 pa2 b--gray4 ba b--solid w7 db bg-gray0-d white-d"
rows={1}
autocapitalise="none"
autoFocus={
/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(
navigator.userAgent
)
? false
: true
}
placeholder="Search for a ship"
value={state.searchTerm}
onChange={this.search}
ref={this.setInputRef}
/>
<ShipSearch
contacts={props.contacts}
candidates={props.candidates}
searchTerm={deSig(state.searchTerm)}
inputRef={this.inputRef}
onSelect={props.onSelect}
onClear={props.onClear}
/>
</div>
);
}
}

View File

@ -0,0 +1,125 @@
import React, { Component } from "react";
import classnames from "classnames";
import { InviteSearch } from "./lib/invite-search";
import { Spinner } from "./lib/icons/icon-spinner";
import { Route, Link } from "react-router-dom";
import { uuid, isPatTa, deSig } from "/lib/util";
import urbitOb from "urbit-ob";
export class NewDmScreen extends Component {
constructor(props) {
super(props);
this.state = {
ship: null,
idError: false,
inviteError: false,
allowHistory: true,
awaiting: false
};
this.setInvite = this.setInvite.bind(this);
this.onClickCreate = this.onClickCreate.bind(this);
}
componentDidMount() {
const { props } = this;
if (props.autoCreate && urbitOb.isValidPatp(props.autoCreate)) {
this.setState(
{
error: false,
success: true,
ship: props.autoCreate.slice(1),
awaiting: true
},
this.onClickCreate
);
}
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (prevProps !== props) {
let station = `/~${window.ship}/${state.idName}`;
if (station in props.inbox) {
props.history.push("/~chat/room" + station);
}
}
}
setInvite(value) {
this.setState({
groups: [],
ship: value.ships[0]
});
}
onClickCreate() {
const { props, state } = this;
let station = `/~/~${window.ship}/dm--${state.ship}`;
let theirStation = `/~/~${state.ship}/dm--${window.ship}`;
if (station in props.inbox) {
props.history.push(`/~chat/room${station}`);
return;
}
if (theirStation in props.inbox) {
props.history.push(`/~chat/room${theirStation}`);
return;
}
this.setState(
{
error: false,
success: true,
group: [],
ship: [],
awaiting: true
},
() => {
let groupPath = station;
let submit = props.api.chatView.create(
`~${window.ship} <-> ~${state.ship}`,
"",
station,
groupPath,
"village",
state.ship !== window.ship ? [`~${state.ship}`] : [],
true
);
submit.then(() => {
this.setState({ awaiting: false });
props.history.push(`/~chat/room${station}`);
});
}
);
}
render() {
const { props, state } = this;
return (
<div
className={
"h-100 w-100 mw6 pa3 pt4 overflow-x-hidden " +
"bg-gray0-d white-d flex flex-column"
}
>
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<Link to="/~chat/">{"⟵ All Chats"}</Link>
</div>
<h2 className="mb3 f8">New DM</h2>
<div className="w-100">
<Spinner
awaiting={this.state.awaiting}
classes="mt4"
text="Creating chat..."
/>
</div>
</div>
);
}
}

View File

@ -16,7 +16,7 @@ export class NewScreen extends Component {
idName: '', idName: '',
groups: [], groups: [],
ships: [], ships: [],
security: 'village', security: 'channel',
idError: false, idError: false,
inviteError: false, inviteError: false,
allowHistory: true, allowHistory: true,
@ -26,7 +26,6 @@ export class NewScreen extends Component {
this.titleChange = this.titleChange.bind(this); this.titleChange = this.titleChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this); this.descriptionChange = this.descriptionChange.bind(this);
this.securityChange = this.securityChange.bind(this);
this.allowHistoryChange = this.allowHistoryChange.bind(this); this.allowHistoryChange = this.allowHistoryChange.bind(this);
this.setInvite = this.setInvite.bind(this); this.setInvite = this.setInvite.bind(this);
this.createGroupChange = this.createGroupChange.bind(this); this.createGroupChange = this.createGroupChange.bind(this);
@ -65,17 +64,6 @@ export class NewScreen extends Component {
}); });
} }
securityChange(event) {
if (this.state.createGroup) {
return;
}
if (event.target.checked) {
this.setState({security: "village"});
} else if (!event.target.checked) {
this.setState({security: "channel"});
}
}
createGroupChange(event) { createGroupChange(event) {
if (event.target.checked) { if (event.target.checked) {
this.setState({ this.setState({
@ -85,6 +73,7 @@ export class NewScreen extends Component {
} else { } else {
this.setState({ this.setState({
createGroup: !!event.target.checked, createGroup: !!event.target.checked,
security: 'channel'
}); });
} }
} }
@ -124,6 +113,10 @@ export class NewScreen extends Component {
} }
}); });
if(state.ships.length === 1 && state.security === 'village' && !state.createGroup) {
props.history.push(`/~chat/new/dm/${aud[0]}`);
}
if (!isValid) { if (!isValid) {
this.setState({ this.setState({
inviteError: true, inviteError: true,
@ -279,18 +272,6 @@ export class NewScreen extends Component {
setInvite={this.setInvite} setInvite={this.setInvite}
/> />
{createGroupToggle} {createGroupToggle}
<div className="mv7">
<input
type="checkbox"
style={{ WebkitAppearance: "none", width: 28 }}
className={inviteSwitchClasses}
onChange={this.securityChange}
/>
<span className="dib f9 white-d inter ml3">Invite Only Chat</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
Chat participants must be invited to see chat content
</p>
</div>
<button <button
onClick={this.onClickCreate.bind(this)} onClick={this.onClickCreate.bind(this)}
className={createClasses}> className={createClasses}>

View File

@ -13,6 +13,7 @@ import { MemberScreen } from '/components/member';
import { SettingsScreen } from '/components/settings'; import { SettingsScreen } from '/components/settings';
import { NewScreen } from '/components/new'; import { NewScreen } from '/components/new';
import { JoinScreen } from '/components/join'; import { JoinScreen } from '/components/join';
import { NewDmScreen } from '/components/new-dm';
export class Root extends Component { export class Root extends Component {
@ -92,6 +93,34 @@ export class Root extends Component {
); );
}} }}
/> />
<Route
exact
path="/~chat/new/dm/:ship"
render={props => {
const ship = props.match.params.ship;
return (
<Skeleton
associations={associations}
invites={invites}
sidebarHideOnMobile={true}
sidebar={renderChannelSidebar(props)}
sidebarShown={state.sidebarShown}
>
<NewDmScreen
api={api}
inbox={state.inbox || {}}
permissions={state.permissions || {}}
contacts={state.contacts || {}}
associations={associations.contacts}
chatSynced={state.chatSynced || {}}
autoCreate={ship}
{...props}
/>
</Skeleton>
);
}}
/>
<Route <Route
exact exact
path="/~chat/new" path="/~chat/new"

View File

@ -5,17 +5,35 @@ import Welcome from '/components/lib/welcome.js';
import { alphabetiseAssociations } from '../lib/util'; import { alphabetiseAssociations } from '../lib/util';
import { SidebarInvite } from '/components/lib/sidebar-invite'; import { SidebarInvite } from '/components/lib/sidebar-invite';
import { GroupItem } from '/components/lib/group-item'; import { GroupItem } from '/components/lib/group-item';
import { ShipSearchInput } from '/components/lib/ship-search';
export class Sidebar extends Component { export class Sidebar extends Component {
constructor() {
super();
this.state = {
dmOverlay: false
};
}
onClickNew() { onClickNew() {
this.props.history.push('/~chat/new'); this.props.history.push('/~chat/new');
} }
onClickDm() {
this.setState(({ dmOverlay }) => ({ dmOverlay: !dmOverlay }) )
}
onClickJoin() { onClickJoin() {
this.props.history.push('/~chat/join') this.props.history.push('/~chat/join')
} }
goDm(ship) {
this.setState({ dmOverlay: false }, () => {
this.props.history.push(`/~chat/new/dm/~${ship}`)
});
}
render() { render() {
const { props, state } = this; const { props, state } = this;
@ -97,6 +115,14 @@ export class Sidebar extends Component {
/> />
) )
} }
const candidates = state.dmOverlay
? _.chain(this.props.contacts)
.values()
.map(_.keys)
.flatten()
.uniq()
.value()
: [];
return ( return (
<div <div
@ -108,6 +134,25 @@ export class Sidebar extends Component {
onClick={this.onClickNew.bind(this)}> onClick={this.onClickNew.bind(this)}>
New Chat New Chat
</a> </a>
<div className="dib relative mr4">
{ state.dmOverlay && (
<ShipSearchInput
className="absolute"
contacts={{}}
candidates={candidates}
onSelect={this.goDm.bind(this)}
onClear={this.onClickDm.bind(this)}
/>
)}
<a
className="f9 pointer green2 gray4-d"
onClick={this.onClickDm.bind(this)}>
DM
</a>
</div>
<a <a
className="dib f9 pointer gray4-d" className="dib f9 pointer gray4-d"
onClick={this.onClickJoin.bind(this)}> onClick={this.onClickJoin.bind(this)}>