diff --git a/pkg/interface/chat/src/css/custom.css b/pkg/interface/chat/src/css/custom.css index fb6441724..972413079 100644 --- a/pkg/interface/chat/src/css/custom.css +++ b/pkg/interface/chat/src/css/custom.css @@ -169,6 +169,15 @@ h2 { 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 { color: #7ea899; } @@ -363,6 +372,9 @@ pre.CodeMirror-placeholder.CodeMirror-line-like { color: var(--gray); } .b--white-d { border-color: #fff; } + .b--green2-d { + border-color: #2aa779; + } .bb-d { border-bottom-width: 1px; border-bottom-style: solid; diff --git a/pkg/interface/chat/src/js/components/chat.js b/pkg/interface/chat/src/js/components/chat.js index e82db6a08..c2e8b8fae 100644 --- a/pkg/interface/chat/src/js/components/chat.js +++ b/pkg/interface/chat/src/js/components/chat.js @@ -288,6 +288,7 @@ export class ChatScreen extends Component { paddingTop={paddingTop} paddingBot={paddingBot} pending={!!msg.pending} + group={props.association} /> ); if(unread > 0 && i === unread) { @@ -327,7 +328,7 @@ export class ChatScreen extends Component { if (navigator.userAgent.includes("Firefox")) { return ( -
{ this.scrollContainer = e; }}> +
{ this.scrollContainer = e; }}>
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 ce04de716..503e93b62 100644 --- a/pkg/interface/chat/src/js/components/lib/chat-input.js +++ b/pkg/interface/chat/src/js/components/lib/chat-input.js @@ -1,16 +1,15 @@ import React, { Component } from 'react'; import _ from 'lodash'; import moment from 'moment'; -import cn from 'classnames'; import { UnControlled as CodeEditor } from 'react-codemirror2'; -import CodeMirror from 'codemirror'; import 'codemirror/mode/markdown/markdown'; import 'codemirror/addon/display/placeholder'; 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 = { 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 ( -
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) { @@ -111,8 +38,7 @@ export class ChatInput extends Component { this.state = { message: '', - patpSuggestions: [], - selectedSuggestion: null + patpSearch: null }; this.textareaRef = React.createRef(); @@ -121,10 +47,8 @@ export class ChatInput extends Component { this.messageChange = this.messageChange.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); + this.clearSearch = this.clearSearch.bind(this); this.toggleCode = this.toggleCode.bind(this); @@ -185,52 +109,20 @@ export class ChatInput extends Component { this.setState({ selectedSuggestion: patpSuggestions[idx] }); } - patpAutocomplete(message, fresh = false) { + patpAutocomplete(message) { const match = /~([a-zA-Z\-]*)$/.exec(message); if (!match ) { - this.setState({ patpSuggestions: [] }); + this.setState({ patpSearch: null }); 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({ - patpSuggestions: [] + patpSearch: null }); } @@ -247,13 +139,13 @@ export class ChatInput extends Component { const lastCol = this.editor.getLineHandle(lastRow).text.length; this.editor.setCursor(lastRow, lastCol); this.setState({ - patpSuggestions: [] + patpSearch: null }); } messageChange(editor, data, value) { - const { patpSuggestions } = this.state; - if(patpSuggestions.length !== 0) { + const { patpSearch } = this.state; + if(patpSearch !== null) { this.patpAutocomplete(value, false); } } @@ -384,7 +276,12 @@ export class ChatInput extends Component { const sigilClass = props.ownerContact ? '' : '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' : ''; @@ -398,33 +295,11 @@ export class ChatInput extends Component { placeholder: state.code ? 'Code...' : props.placeholder, extraKeys: { Tab: cm => - completeActive - ? 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, + this.patpAutocomplete(cm.getValue(), true), 'Enter': cm => - completeActive - ? this.completePatp(state.selectedSuggestion) - : this.messageSubmit(), + this.messageSubmit(), 'Shift-3': cm => - cm.getValue().length === 0 - ? this.toggleCode() - : CodeMirror.Pass + this.toggleCode() } }; @@ -432,15 +307,15 @@ export class ChatInput extends Component {
- {state.patpSuggestions.length !== 0 && ( - - )} - +
); diff --git a/pkg/interface/chat/src/js/components/lib/invite-search.js b/pkg/interface/chat/src/js/components/lib/invite-search.js index 71a43e63e..d20dfefb8 100644 --- a/pkg/interface/chat/src/js/components/lib/invite-search.js +++ b/pkg/interface/chat/src/js/components/lib/invite-search.js @@ -289,7 +289,7 @@ export class InviteSearch extends Component { render() { const { props, state } = this; - let searchDisabled = false; + let searchDisabled = props.disabled; if (props.invites.groups) { if (props.invites.groups.length > 0) { searchDisabled = true; diff --git a/pkg/interface/chat/src/js/components/lib/message.js b/pkg/interface/chat/src/js/components/lib/message.js index 683a1260f..927109e8b 100644 --- a/pkg/interface/chat/src/js/components/lib/message.js +++ b/pkg/interface/chat/src/js/components/lib/message.js @@ -1,5 +1,7 @@ import React, { Component } from 'react'; 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 { Route, Link } from 'react-router-dom' import { uxToHex, cite, writeText } from '/lib/util'; @@ -53,6 +55,7 @@ export class Message extends Component { iframe.setAttribute('src', iframe.getAttribute('data-src')); } + renderContent() { const { props } = this; let letter = props.msg.letter; @@ -190,20 +193,20 @@ export class Message extends Component { return (
-
- -
+
diff --git a/pkg/interface/chat/src/js/components/lib/overlay-sigil.js b/pkg/interface/chat/src/js/components/lib/overlay-sigil.js new file mode 100644 index 000000000..3fb1dbe6c --- /dev/null +++ b/pkg/interface/chat/src/js/components/lib/overlay-sigil.js @@ -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 ( +
+ {state.profileClicked && ( + + )} + +
+ ); + } +} diff --git a/pkg/interface/chat/src/js/components/lib/profile-overlay.js b/pkg/interface/chat/src/js/components/lib/profile-overlay.js new file mode 100644 index 000000000..a95e8604d --- /dev/null +++ b/pkg/interface/chat/src/js/components/lib/profile-overlay.js @@ -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 ( +
+
+ +
+
+ {contact && contact.nickname && ( +
{contact.nickname}
+ )} +
{cite(`~${ship}`)}
+ {!isOwn && ( + + Send Message + + )} + {isOwn && ( + + Edit Group Identity + + )} +
+
+ ); + } +} diff --git a/pkg/interface/chat/src/js/components/lib/ship-search.js b/pkg/interface/chat/src/js/components/lib/ship-search.js new file mode 100644 index 000000000..e820e462e --- /dev/null +++ b/pkg/interface/chat/src/js/components/lib/ship-search.js @@ -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 ( +
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 + }; + + 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 ( +
+ {suggestions.slice(0, 5).map(ship => ( + + ))} +
+ ); + } +} + +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 ( +
(this.popoverRef = ref)} + style={{ top: '150%', left: '-80px' }} + className="b--gray2 b--solid ba absolute bg-white bg-gray0-d" + > +