chat-fe: move new DMs to ship select popover

Refactored the patp autocomplete into a reusable component, and then
used that to add the DM popover interface. Also introduces some
performance improvements for the popover.
This commit is contained in:
Liam Fitzgerald 2020-04-11 17:43:30 +10:00
parent 803c7b4816
commit 966155088c
5 changed files with 422 additions and 202 deletions

View File

@ -2,9 +2,9 @@ import React, { Component } from 'react';
import _ from 'lodash';
import moment from 'moment';
import Mousetrap from 'mousetrap';
import cn from 'classnames';
import { Sigil } from '/components/lib/icons/sigil';
import { ShipSearch } from '/components/lib/ship-search';
import { uuid, uxToHex, hexToRgba } from '/lib/util';
@ -25,77 +25,6 @@ function getAdvance(a, b) {
return res;
}
function ChatInputSuggestion({ ship, contacts, selected, onSelect }) {
let contact = contacts[ship];
let color = "#000000";
let sigilClass = "v-mid mix-blend-diff"
let nickname;
let 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 {
constructor(props) {
@ -104,8 +33,7 @@ export class ChatInput extends Component {
this.state = {
message: '',
textareaHeight: DEFAULT_INPUT_HEIGHT,
patpSuggestions: [],
selectedSuggestion: null
patpSearch: ''
};
this.textareaRef = React.createRef();
@ -113,13 +41,10 @@ export class ChatInput extends Component {
this.messageSubmit = this.messageSubmit.bind(this);
this.messageChange = this.messageChange.bind(this);
this.onEnter = this.onEnter.bind(this);
this.patpAutocomplete = this.patpAutocomplete.bind(this);
this.nextAutocompleteSuggestion = this.nextAutocompleteSuggestion.bind(this);
this.completePatp = this.completePatp.bind(this);
this.clearSuggestions = this.clearSuggestions.bind(this);
// Call once per frame @ 60hz
this.textareaInput = _.debounce(this.textareaInput.bind(this), 16);
@ -170,139 +95,77 @@ export class ChatInput extends Component {
this.bindShortcuts();
}
nextAutocompleteSuggestion(backward = false) {
const { patpSuggestions } = this.state;
let idx = patpSuggestions.findIndex(s => s === this.state.selectedSuggestion);
idx = backward ? idx - 1 : idx + 1;
idx = idx % patpSuggestions.length;
if(idx < 0) {
idx = patpSuggestions.length - 1;
}
this.setState({ selectedSuggestion: patpSuggestions[idx] });
}
patpAutocomplete(message, fresh = false) {
const match = /~([a-zA-Z\-]*)$/.exec(message);
if (!match ) {
this.setState({ patpSuggestions: [] })
this.bindShortcuts();
this.setState({ patpSearch: '' })
return;
}
this.unbindShortcuts();
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();
let newState = {
patpSuggestions: suggestions,
selectedSuggestion: suggestions[0]
};
this.setState(newState);
}
clearSuggestions() {
clearSearch() {
this.setState({
patpSuggestions: []
patpSearch: ''
})
}
completePatp(suggestion) {
this.bindShortcuts();
this.setState({
message: this.state.message.replace(
/[a-zA-Z\-]*$/,
suggestion
),
patpSuggestions: []
patpSearch: ''
});
}
onEnter(e) {
if (this.state.patpSuggestions.length !== 0) {
this.completePatp(this.state.selectedSuggestion);
} else {
this.messageSubmit(e);
}
}
bindShortcuts() {
let mousetrap = Mousetrap(this.textareaRef.current);
mousetrap.bind('enter', e => {
if(!this.mousetrap) {
this.mousetrap = new Mousetrap(this.textareaRef.current);
}
this.mousetrap.bind('enter', e => {
e.preventDefault();
e.stopPropagation();
this.onEnter(e);
if(this.state.patpSearch.length === 0) {
this.messageSubmit();
}
});
mousetrap.bind('tab', e => {
this.mousetrap.bind('tab', e => {
e.preventDefault();
e.stopPropagation();
if(this.state.patpSuggestions.length === 0) {
if(this.state.patpSearch.length === 0) {
this.patpAutocomplete(this.state.message, true);
} else {
this.nextAutocompleteSuggestion(false);
}
});
mousetrap.bind(['up', 'shift+tab'], e => {
if(this.state.patpSuggestions.length !== 0) {
e.preventDefault();
e.stopPropagation();
this.nextAutocompleteSuggestion(true)
}
});
mousetrap.bind('down', e => {
if(this.state.patpSuggestions.length !== 0) {
e.preventDefault();
e.stopPropagation();
this.nextAutocompleteSuggestion(false)
}
});
mousetrap.bind('esc', e => {
if(this.state.patpSuggestions.length !== 0) {
e.preventDefault();
e.stopPropagation();
this.clearSuggestions();
}})
}
unbindShortcuts() {
if(!this.mousetrap) {
return;
}
this.mousetrap.unbind('enter')
this.mousetrap.unbind('tab')
}
messageChange(event) {
const message = event.target.value;
this.setState({
message
});
const { patpSuggestions } = this.state;
if(patpSuggestions.length !== 0) {
const { patpSearch } = this.state;
if(patpSearch.length !== 0) {
this.patpAutocomplete(message, false);
}
@ -431,17 +294,24 @@ export class ChatInput extends Component {
let sigilClass = !!props.ownerContact
? "" : "mix-blend-diff";
const candidates = _.chain(this.props.envelopes)
.defaultTo([])
.map("author")
.uniq()
.reverse()
.value();
return (
<div className="pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white bg-gray0-d relative"
style={{ flexGrow: 1 }}>
{state.patpSuggestions.length !== 0 && (
<ChatInputSuggestions
onSelect={this.completePatp}
suggestions={state.patpSuggestions}
selected={state.selectedSuggestion}
contacts={props.contacts}
/>
)}
<ShipSearch
popover
onSelect={this.completePatp}
contacts={props.contacts}
candidates={candidates}
searchTerm={this.state.patpSearch}
inputRef={this.textareaRef.current}
/>
<div
className="fl"

View File

@ -0,0 +1,338 @@
import React, { Component } from "react";
import _ from "lodash";
import urbitOb from "urbit-ob";
import cn from "classnames";
import { Sigil } from "/components/lib/icons/sigil";
import { hexToRgba, uxToHex, deSig } from "/lib/util";
function ShipSearchItem({ ship, contacts, selected, onSelect }) {
let contact = contacts[ship];
let color = "#000000";
let sigilClass = "v-mid mix-blend-diff";
let nickname;
let 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
};
}
componentDidMount() {
this.bindShortcuts();
if (this.props.suggestEmpty) {
this.updateSuggestions();
}
}
componentDidUpdate(prevProps) {
const { props } = this;
if (
props.searchTerm !== prevProps.searchTerm &&
props.searchTerm.startsWith(prevProps.searchTerm)
) {
this.updateSuggestions();
} else if (prevProps.searchTerm !== props.searchTerm) {
this.updateSuggestions(true);
}
if (prevProps.inputRef !== props.inputRef) {
this.bindShortcuts();
}
}
updateSuggestions(isStale = false) {
const needle = this.props.searchTerm;
if (needle.length === 0 && !this.props.suggestEmpty) {
this.unbindShortcuts();
this.setState({ suggestions: [] });
return;
}
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] });
}
bindShortcuts() {
if (!this.props.inputRef || this.state.bound) {
return;
}
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.onDismiss();
});
}
unbindShortcuts() {
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 = React.createRef();
this.popoverRef = React.createRef();
this.search = this.search.bind(this);
this.onClick = this.onClick.bind(this);
}
onClick(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();
}
componentDidMount() {
document.addEventListener("mousedown", this.onClick);
document.addEventListener("touchstart", this.onClick);
}
componentWillUnmount() {
document.removeEventListener("mousedown", this.onClick);
document.removeEventListener("touchstart", this.onClick);
}
search(e) {
const searchTerm = e.target.value;
this.setState({ searchTerm });
}
render() {
const { state, props } = this;
return (
<div
ref={this.popoverRef}
style={{ top: "150%", left: "-80px" }}
className="b--gray2 b--solid ba absolute bg-white bg-gray0-d shadow-5"
>
<textarea
style={{ resize: "none" }}
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.inputRef}
/>
<ShipSearch
contacts={props.contacts}
candidates={props.candidates}
searchTerm={deSig(state.searchTerm)}
inputRef={this.inputRef.current}
onSelect={props.onSelect}
onDismiss={props.onDismiss}
suggestEmpty
/>
</div>
);
}
}
// import { useEffect } from 'react';
// // via https:// usehooks.com/useOnClickOutside/
// export default function useOnClickOutside(ref, handler) {
// useEffect(() => {
// const listener = event => {
// // Do nothing if clicking ref's element or descendent elements
// if (!ref.current || ref.current.contains(event.target)) {
// return;
// }
// handler(event);
// };
// document.addEventListener('mousedown', listener);
// document.addEventListener('touchstart', listener);
// return () => {
// document.removeEventListener('mousedown', listener);
// document.removeEventListener('touchstart', listener);
// };
// }, [ref, handler]);
// }

View File

@ -101,10 +101,6 @@ export class NewDmScreen extends Component {
render() {
const { props, state } = this;
let createClasses = state.ship
? "pointer db f9 green2 bg-gray0-d ba pv3 ph4 b--green2"
: "pointer db f9 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3";
return (
<div
className={
@ -117,26 +113,6 @@ export class NewDmScreen extends Component {
</div>
<h2 className="mb3 f8">New DM</h2>
<div className="w-100">
<p className="f8 mt4 lh-copy db">With who?</p>
<InviteSearch
groups={{}}
contacts={props.contacts}
associations={props.associations}
groupResults={false}
shipResults={true}
disabled={!!state.ship}
invites={{
groups: [],
ships: state.ship ? [state.ship] : []
}}
setInvite={this.setInvite}
/>
<button
onClick={this.onClickCreate}
className={createClasses + " mt4"}
>
Start Chat
</button>
<Spinner
awaiting={this.state.awaiting}
classes="mt4"

View File

@ -95,7 +95,7 @@ export class Root extends Component {
/>
<Route
exact
path="/~chat/new/dm/:ship?"
path="/~chat/new/dm/:ship"
render={props => {
const ship = props.match.params.ship;

View File

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