From 966155088c3953b3bf110d643394b60ccc5b00bd Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Sat, 11 Apr 2020 17:43:30 +1000 Subject: [PATCH] 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. --- .../chat/src/js/components/lib/chat-input.js | 220 +++--------- .../chat/src/js/components/lib/ship-search.js | 338 ++++++++++++++++++ .../chat/src/js/components/new-dm.js | 24 -- pkg/interface/chat/src/js/components/root.js | 2 +- .../chat/src/js/components/sidebar.js | 40 ++- 5 files changed, 422 insertions(+), 202 deletions(-) create mode 100644 pkg/interface/chat/src/js/components/lib/ship-search.js diff --git a/pkg/interface/chat/src/js/components/lib/chat-input.js b/pkg/interface/chat/src/js/components/lib/chat-input.js index 67cc62ed0..292f09578 100644 --- a/pkg/interface/chat/src/js/components/lib/chat-input.js +++ b/pkg/interface/chat/src/js/components/lib/chat-input.js @@ -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 ( -
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} - > - - { nickname && ( -

{nickname}

) - } -
- {'~' + ship} -
-

- {status} -

-
- ); - -} - -function ChatInputSuggestions({ suggestions, onSelect, selected, contacts }) { - return ( -
- {suggestions.map(ship => - () - )} -
- ); -} 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 (
- {state.patpSuggestions.length !== 0 && ( - - )} +
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} + > + + {nickname && ( +

+ {nickname} +

+ )} +
{"~" + ship}
+

{status}

+
+ ); +} + +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 ( +
+ {suggestions.slice(0, 5).map(ship => ( + + ))} +
+ ); + } +} + +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 ( +
+