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"
+ >
+
+
+
+ );
+ }
+}
diff --git a/pkg/interface/chat/src/js/components/new-dm.js b/pkg/interface/chat/src/js/components/new-dm.js
new file mode 100644
index 000000000..684a81274
--- /dev/null
+++ b/pkg/interface/chat/src/js/components/new-dm.js
@@ -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 (
+
+
+ {"⟵ All Chats"}
+
+
New DM
+
+
+
+
+ );
+ }
+}
diff --git a/pkg/interface/chat/src/js/components/new.js b/pkg/interface/chat/src/js/components/new.js
index 8a76ef52e..acbabc5c3 100644
--- a/pkg/interface/chat/src/js/components/new.js
+++ b/pkg/interface/chat/src/js/components/new.js
@@ -16,7 +16,7 @@ export class NewScreen extends Component {
idName: '',
groups: [],
ships: [],
- security: 'village',
+ security: 'channel',
idError: false,
inviteError: false,
allowHistory: true,
@@ -26,7 +26,6 @@ export class NewScreen extends Component {
this.titleChange = this.titleChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
- this.securityChange = this.securityChange.bind(this);
this.allowHistoryChange = this.allowHistoryChange.bind(this);
this.setInvite = this.setInvite.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) {
if (event.target.checked) {
this.setState({
@@ -85,6 +73,7 @@ export class NewScreen extends Component {
} else {
this.setState({
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) {
this.setState({
inviteError: true,
@@ -279,18 +272,6 @@ export class NewScreen extends Component {
setInvite={this.setInvite}
/>
{createGroupToggle}
-
-
-
Invite Only Chat
-
- Chat participants must be invited to see chat content
-
-