diff --git a/pkg/arvo/app/landscape/img/groups.png b/pkg/arvo/app/landscape/img/groups.png new file mode 100644 index 0000000000..fa14f36da5 Binary files /dev/null and b/pkg/arvo/app/landscape/img/groups.png differ diff --git a/pkg/arvo/app/landscape/img/icon-home.png b/pkg/arvo/app/landscape/img/icon-home.png index 04b1e7b870..9eb1d02893 100644 Binary files a/pkg/arvo/app/landscape/img/icon-home.png and b/pkg/arvo/app/landscape/img/icon-home.png differ diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index 51ff31dc38..c3f717ee9f 100644 --- a/pkg/interface/package-lock.json +++ b/pkg/interface/package-lock.json @@ -6500,6 +6500,11 @@ "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", "integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==" }, + "mousetrap-global-bind": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mousetrap-global-bind/-/mousetrap-global-bind-1.1.0.tgz", + "integrity": "sha1-zX3pIivQZG+i4BDVTISnTCaojt0=" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/pkg/interface/package.json b/pkg/interface/package.json index e35970e45e..3388287665 100644 --- a/pkg/interface/package.json +++ b/pkg/interface/package.json @@ -18,6 +18,7 @@ "markdown-to-jsx": "^6.11.4", "moment": "^2.20.1", "mousetrap": "^1.6.5", + "mousetrap-global-bind": "^1.1.0", "prop-types": "^15.7.2", "react": "^16.5.2", "react-codemirror2": "^6.0.1", diff --git a/pkg/interface/src/App.js b/pkg/interface/src/App.js index 6f92017a59..b6488b1b0e 100644 --- a/pkg/interface/src/App.js +++ b/pkg/interface/src/App.js @@ -5,6 +5,9 @@ import { BrowserRouter as Router, Route, withRouter, Switch } from 'react-router import styled, { ThemeProvider, createGlobalStyle } from 'styled-components'; import { sigil as sigiljs, stringRenderer } from 'urbit-sigil-js'; +import Mousetrap from 'mousetrap'; +import 'mousetrap-global-bind'; + import './css/indigo-static.css'; import './css/fonts.css'; import light from './themes/light'; @@ -18,6 +21,7 @@ import LinksApp from './apps/links/app'; import PublishApp from './apps/publish/app'; import StatusBar from './components/StatusBar'; +import Omnibox from './components/Omnibox'; import ErrorComponent from './components/Error'; import GlobalStore from './store/store'; @@ -74,6 +78,10 @@ class App extends React.Component { this.api.local.setDark(this.themeWatcher.matches); this.themeWatcher.addListener(this.updateTheme); this.api.local.getBaseHash(); + Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => { + e.preventDefault(); + this.api.local.setOmnibox(); + }); this.setFavicon(); } @@ -113,7 +121,6 @@ class App extends React.Component { const channel = window.channel; const associations = this.state.associations ? this.state.associations : { contacts: {} }; - const selectedGroups = this.state.selectedGroups ? this.state.selectedGroups : []; const { state } = this; const theme = state.dark ? dark : light; @@ -121,81 +128,99 @@ class App extends React.Component { - + - - ( - + ( + + )} /> - )} - /> - ( - ( + + )} /> - )} - /> - ( - ( + + )} /> - )} - /> - ( - ( + + )} /> - )} - /> - ( - ( + + )} /> - )} - /> - ( - ( + + )} /> - )} - /> ( + render={props => ( )} - /> + /> diff --git a/pkg/interface/src/api/local.ts b/pkg/interface/src/api/local.ts index 8610284c23..77ff9b2b3b 100644 --- a/pkg/interface/src/api/local.ts +++ b/pkg/interface/src/api/local.ts @@ -1,6 +1,5 @@ import BaseApi from "./base"; import { StoreState } from "../store/type"; -import { SelectedGroup } from "../types/local-update"; export default class LocalApi extends BaseApi { getBaseHash() { @@ -9,16 +8,6 @@ export default class LocalApi extends BaseApi { }); } - setSelected(selected: SelectedGroup[]) { - this.store.handleEvent({ - data: { - local: { - selected - } - } - }) - } - sidebarToggle() { this.store.handleEvent({ data: { @@ -39,4 +28,14 @@ export default class LocalApi extends BaseApi { }); } + setOmnibox() { + this.store.handleEvent({ + data: { + local: { + omniboxShown: true + }, + }, + }); + } + } diff --git a/pkg/interface/src/apps/chat/app.tsx b/pkg/interface/src/apps/chat/app.tsx index 8f9adc8432..fc8c4e0b3a 100644 --- a/pkg/interface/src/apps/chat/app.tsx +++ b/pkg/interface/src/apps/chat/app.tsx @@ -53,7 +53,6 @@ export default class ChatApp extends React.Component { const unreads = {}; let totalUnreads = 0; - const selectedGroups = props.selectedGroups ? props.selectedGroups : []; const associations = props.associations ? props.associations : { chat: {}, contacts: {} }; @@ -74,14 +73,7 @@ export default class ChatApp extends React.Component { unreads[stat] = Boolean(unread); if ( unread && - stat in associations.chat && - (selectedGroups.length === 0 || - selectedGroups - .map((e) => { - return e[0]; - }) - .includes(associations.chat?.[stat]?.['group-path']) || - props.groups[associations.chat?.[stat]?.['group-path']]?.hidden) + stat in associations.chat ) { totalUnreads += unread; } @@ -111,7 +103,6 @@ export default class ChatApp extends React.Component { inbox={inbox} messagePreviews={messagePreviews} associations={associations} - selectedGroups={selectedGroups} contacts={contacts} invites={invites['/chat'] || {}} unreads={unreads} diff --git a/pkg/interface/src/apps/chat/components/sidebar.js b/pkg/interface/src/apps/chat/components/sidebar.js index d7d63a9849..95abbca9ac 100644 --- a/pkg/interface/src/apps/chat/components/sidebar.js +++ b/pkg/interface/src/apps/chat/components/sidebar.js @@ -13,8 +13,6 @@ export class Sidebar extends Component { render() { const { props } = this; - const selectedGroups = props.selectedGroups ? props.selectedGroups : []; - const contactAssoc = (props.associations && 'contacts' in props.associations) ? alphabetiseAssociations(props.associations.contacts) : {}; @@ -61,15 +59,6 @@ export class Sidebar extends Component { const groupedItems = Object.keys(contactAssoc) .filter(each => (groupedChannels[each] || []).length !== 0) - .filter((each) => { - if (selectedGroups.length === 0) { - return true; - } - const selectedPaths = selectedGroups.map((e) => { - return e[0]; - }); - return selectedPaths.includes(each); - }) .map((each, i) => { const channels = groupedChannels[each] || []; return( diff --git a/pkg/interface/src/apps/groups/app.tsx b/pkg/interface/src/apps/groups/app.tsx index a88da3eb39..cefd0d6ec5 100644 --- a/pkg/interface/src/apps/groups/app.tsx +++ b/pkg/interface/src/apps/groups/app.tsx @@ -48,7 +48,6 @@ export default class GroupsApp extends Component { const invites = (Boolean(props.invites) && '/contacts' in props.invites) ? props.invites['/contacts'] : {}; - const selectedGroups = props.selectedGroups ? props.selectedGroups : []; const s3 = props.s3 ? props.s3 : {}; const groups = props.groups || {}; const associations = props.associations || {}; @@ -62,7 +61,6 @@ export default class GroupsApp extends Component { return ( { return ( { return ( { return ( { return ( { { { { if (e.key === 'Enter') { e.preventDefault(); @@ -110,7 +111,7 @@ export class JoinScreen extends Component { } }} style={{ - resize: 'none', + resize: 'none' }} onChange={this.groupChange} value={this.state.group} diff --git a/pkg/interface/src/apps/groups/components/lib/group-sidebar.js b/pkg/interface/src/apps/groups/components/lib/group-sidebar.js index 94fe6204ec..b000eca478 100644 --- a/pkg/interface/src/apps/groups/components/lib/group-sidebar.js +++ b/pkg/interface/src/apps/groups/components/lib/group-sidebar.js @@ -72,16 +72,6 @@ export class GroupSidebar extends Component { (path in props.groups) ); }) - .filter((path) => { - const selectedGroups = props.selectedGroups ? props.selectedGroups : []; - if (selectedGroups.length === 0) { - return true; - } - const selectedPaths = selectedGroups.map(((e) => { - return e[0]; - })); - return (selectedPaths.includes(path)); - }) .sort((a, b) => { let aName = a.substr(1); let bName = b.substr(1); diff --git a/pkg/interface/src/apps/groups/components/skeleton.js b/pkg/interface/src/apps/groups/components/skeleton.js index e1bc4a5473..a3118703ea 100644 --- a/pkg/interface/src/apps/groups/components/skeleton.js +++ b/pkg/interface/src/apps/groups/components/skeleton.js @@ -17,7 +17,6 @@ export class Skeleton extends Component { invites={props.invites} activeDrawer={props.activeDrawer} selected={props.selected} - selectedGroups={props.selectedGroups} history={props.history} api={props.api} associations={props.associations} diff --git a/pkg/interface/src/apps/launch/components/tiles/basic.js b/pkg/interface/src/apps/launch/components/tiles/basic.js index 33f9244ed6..f6b3790ad2 100644 --- a/pkg/interface/src/apps/launch/components/tiles/basic.js +++ b/pkg/interface/src/apps/launch/components/tiles/basic.js @@ -1,6 +1,7 @@ import React from 'react'; import classnames from 'classnames'; import { Link } from 'react-router-dom'; +import defaultApps from '../../../../lib/default-apps'; import Tile from './tile'; @@ -29,7 +30,9 @@ export default class BasicTile extends React.PureComponent { ); - const routeList = ['/~chat', '/~publish', '/~link', '/~groups', '/~dojo']; + const routeList = defaultApps.map((e) => { + return `/~${e}`; + }); const tile = ( routeList.indexOf(props.linkedUrl) !== -1 ) ? ( diff --git a/pkg/interface/src/apps/links/app.js b/pkg/interface/src/apps/links/app.js index 940d42af80..0f1522e721 100644 --- a/pkg/interface/src/apps/links/app.js +++ b/pkg/interface/src/apps/links/app.js @@ -41,7 +41,7 @@ export class LinksApp extends Component { render() { const { props } = this; - const contacts = props.contacts ? props.contacts : {}; + const contacts = props.contacts ? props.contacts : {}; const groups = props.groups ? props.groups : {}; @@ -51,18 +51,9 @@ export class LinksApp extends Component { const seen = props.linksSeen ? props.linksSeen : {}; - const selectedGroups = props.selectedGroups ? props.selectedGroups : []; - - const selGroupPaths = selectedGroups.map(g => g[0]); const totalUnseen = _.reduce( links, - (acc, collection, path) => { - if(selGroupPaths.length > 0 - && !selGroupPaths.includes(associations.link?.[path]?.['group-path'])) { - return acc; - } - return acc + collection.unseenCount; - }, + (acc, collection) => acc + collection.unseenCount, 0 ); @@ -91,7 +82,6 @@ export class LinksApp extends Component { groups={groups} rightPanelHide={true} sidebarShown={sidebarShown} - selectedGroups={selectedGroups} links={links} listening={listening} api={api} @@ -109,7 +99,6 @@ export class LinksApp extends Component { invites={invites} groups={groups} sidebarShown={sidebarShown} - selectedGroups={selectedGroups} links={links} listening={listening} api={api} @@ -157,7 +146,6 @@ export class LinksApp extends Component { groups={groups} selected={resourcePath} sidebarShown={sidebarShown} - selectedGroups={selectedGroups} links={links} listening={listening} api={api} @@ -198,7 +186,6 @@ export class LinksApp extends Component { groups={groups} selected={resourcePath} sidebarShown={sidebarShown} - selectedGroups={selectedGroups} popout={popout} links={links} listening={listening} @@ -253,7 +240,6 @@ export class LinksApp extends Component { groups={groups} selected={resourcePath} sidebarShown={sidebarShown} - selectedGroups={selectedGroups} sidebarHideMobile={true} popout={popout} links={links} @@ -311,7 +297,6 @@ export class LinksApp extends Component { groups={groups} selected={resourcePath} sidebarShown={sidebarShown} - selectedGroups={selectedGroups} sidebarHideMobile={true} popout={popout} links={links} diff --git a/pkg/interface/src/apps/links/components/lib/channel-sidebar.js b/pkg/interface/src/apps/links/components/lib/channel-sidebar.js index a17ac35850..e33960d3bc 100644 --- a/pkg/interface/src/apps/links/components/lib/channel-sidebar.js +++ b/pkg/interface/src/apps/links/components/lib/channel-sidebar.js @@ -51,24 +51,14 @@ export class ChannelsSidebar extends Component { } }); - const selectedGroups = props.selectedGroups ? props.selectedGroups : []; let i = -1; const groupedItems = Object.keys(associations) - .filter((each) => { - if (selectedGroups.length === 0) { - return true; - }; - const selectedPaths = selectedGroups.map((e) => { - return e[0]; - }); - return selectedPaths.includes(each); - }) .map((each) => { const channels = groupedChannels[each]; if (!channels || channels.length === 0) return; i++; - if ((selectedGroups.length === 0) && groupedChannels['/~/'] && groupedChannels['/~/'].length !== 0) { + if (groupedChannels['/~/'] && groupedChannels['/~/'].length !== 0) { i++; } @@ -84,7 +74,7 @@ export class ChannelsSidebar extends Component { /> ); }); - if ((selectedGroups.length === 0) && groupedChannels['/~/'] && groupedChannels['/~/'].length !== 0) { + if (groupedChannels['/~/'] && groupedChannels['/~/'].length !== 0) { groupedItems.unshift( { - return ((selectedGroups.map((e) => { - return e[0]; - }).includes(each?.['writers-group-path'])) || - (selectedGroups.length === 0)); - }) .map('num-unread') .reduce((acc, count) => acc + count, 0) .value(); @@ -80,7 +73,6 @@ export default class PublishApp extends React.Component { invites={invites} notebooks={notebooks} associations={associations} - selectedGroups={selectedGroups} contacts={contacts} api={api} > @@ -111,7 +103,6 @@ export default class PublishApp extends React.Component { invites={invites} notebooks={notebooks} associations={associations} - selectedGroups={selectedGroups} contacts={contacts} api={api} > @@ -142,7 +133,6 @@ export default class PublishApp extends React.Component { invites={invites} notebooks={notebooks} associations={associations} - selectedGroups={selectedGroups} contacts={contacts} api={api} > @@ -188,7 +178,6 @@ export default class PublishApp extends React.Component { invites={invites} notebooks={notebooks} associations={associations} - selectedGroups={selectedGroups} contacts={contacts} path={path} api={api} @@ -215,7 +204,6 @@ export default class PublishApp extends React.Component { notebooks={notebooks} associations={associations} contacts={contacts} - selectedGroups={selectedGroups} path={path} api={api} > @@ -265,7 +253,6 @@ export default class PublishApp extends React.Component { sidebarShown={sidebarShown} invites={invites} notebooks={notebooks} - selectedGroups={selectedGroups} associations={associations} contacts={contacts} path={path} @@ -293,7 +280,6 @@ export default class PublishApp extends React.Component { invites={invites} notebooks={notebooks} associations={associations} - selectedGroups={selectedGroups} contacts={contacts} path={path} api={api} diff --git a/pkg/interface/src/apps/publish/components/lib/sidebar.js b/pkg/interface/src/apps/publish/components/lib/sidebar.js index b801d464d9..5a9d3809e9 100644 --- a/pkg/interface/src/apps/publish/components/lib/sidebar.js +++ b/pkg/interface/src/apps/publish/components/lib/sidebar.js @@ -64,23 +64,12 @@ export class Sidebar extends Component { } }); - const selectedGroups = props.selectedGroups ? props.selectedGroups: []; const groupedItems = Object.keys(associations) - .filter((each) => { - if (selectedGroups.length === 0) { - return true; - } - const selectedPaths = selectedGroups.map((e) => { - return e[0]; - }); - return (selectedPaths.includes(each)); - }) .map((each, i) => { const books = groupedNotebooks[each] || []; if (books.length === 0) return; - if ((selectedGroups.length === 0) && - groupedNotebooks['/~/'] && + if (groupedNotebooks['/~/'] && groupedNotebooks['/~/'].length !== 0) { i = i + 1; } @@ -95,8 +84,7 @@ export class Sidebar extends Component { /> ); }); - if ((selectedGroups.length === 0) && - groupedNotebooks['/~/'] && + if (groupedNotebooks['/~/'] && groupedNotebooks['/~/'].length !== 0) { groupedItems.unshift(
{ - this.props.api.local.setSelected(this.state.selected); - })); - } - } - - componentWillUnmount() { - document.removeEventListener('mousedown', this.handleClickOutside); - } - - componentDidUpdate(prevProps) { - if (prevProps !== this.props) { - this.groupIndex(); - } - } - - handleClickOutside(evt) { - if ((this.dropdown && !this.dropdown.contains(evt.target)) - && (this.toggleButton && !this.toggleButton.contains(evt.target))) { - this.setState({ open: false }); - } - } - - toggleOpen() { - this.setState({ open: !this.state.open }); - } - - groupIndex() { - const { props } = this; - let index = []; - const associations = - (props.associations && 'contacts' in props.associations) ? - props.associations.contacts : {}; - index = Object.keys(associations).map((each) => { - const eachGroup = []; - eachGroup.push(each); - let name = each; - if (associations[each].metadata) { - name = (associations[each].metadata.title !== '') - ? associations[each].metadata.title : name; - } - eachGroup.push(name); - return eachGroup; - }); - this.setState({ groups: index }); - } - - search(evt) { - this.setState({ searchTerm: evt.target.value }); - const term = evt.target.value.toLowerCase(); - - if (term.length < 3) { - return this.setState({ results: [] }); - } - - let groupMatches = []; - groupMatches = this.state.groups.filter((e) => { - return (e[0].includes(term) || e[1].includes(term)); - }); - this.setState({ results: groupMatches }); - } - - addGroup(group) { - const selected = this.state.selected; - if (!(group in selected)) { - selected.push(group); - } - this.setState({ - searchTerm: '', - selected: selected, - results: [] - }, (() => { - this.props.api.local.setSelected(this.state.selected); - localStorage.setItem('urbit-selectedGroups', JSON.stringify(this.state.selected)); - })); - } - - deleteGroup(group) { - let selected = this.state.selected; - selected = selected.filter((e) => { - return e !== group; - }); - this.setState({ selected: selected }, (() => { - this.props.api.local.setSelected(this.state.selected); - localStorage.setItem('urbit-selectedGroups', JSON.stringify(this.state.selected)); - })); - } - - render() { - const { props, state } = this; - - let currentGroup = 'All Groups'; - - if (state.selected.length > 0) { - const titles = state.selected.map((each) => { - return each[1]; - }); - currentGroup = titles.join(' + '); - } - - const buttonOpened = (state.open) - ? 'bg-gray5 bg-gray1-d white-d' : 'hover-bg-gray5 hover-bg-gray1-d white-d'; - - const dropdownClass = (state.open) - ? 'absolute db z-2 bg-white bg-gray0-d white-d ba b--gray3 b--gray1-d' - : 'dn'; - - const inviteCount = (props.invites && Object.keys(props.invites).length > 0) - ? - : ; - - let selectedGroups =
; - let searchResults =
; - - if (state.results.length > 0) { - const groupResults = state.results.map(((group) => { - return( -
  • this.addGroup(group)} - > - {(group[1]) ? group[1] : group[0]} -
  • - ); - })); - searchResults = ( -
    -

    Groups

    - {groupResults} -
    - ); - } - - if (state.selected.length > 0) { - const allSelected = this.state.selected.map((each) => { - const name = each[1]; - return( - - {name} - this.deleteGroup(each)} - > - x - - - ); - }); - selectedGroups = ( -
    - {allSelected} -
    - ); - } - - return ( -
    -
    this.toggleOpen()} - ref={el => this.toggleButton = el} - > -

    {currentGroup}

    -
    -
    { - this.dropdown = el; -}} - > -

    Group Select and Filter

    - this.setState({ open: false })} - > - Manage all Groups - {inviteCount} - -

    Filter Groups

    -
    - - {searchResults} - {selectedGroups} -
    -
    -
    - ); - } -} diff --git a/pkg/interface/src/components/Omnibox.js b/pkg/interface/src/components/Omnibox.js new file mode 100644 index 0000000000..14072bfde5 --- /dev/null +++ b/pkg/interface/src/components/Omnibox.js @@ -0,0 +1,249 @@ +import React, { Component } from 'react'; +import { withRouter } from 'react-router-dom'; +import { Box, Row, Rule, Text } from '@tlon/indigo-react'; +import index from '../lib/omnibox'; +import Mousetrap from 'mousetrap'; +import OmniboxInput from './OmniboxInput'; +import OmniboxResult from './OmniboxResult'; + +import { cite } from '../lib/util'; + +export class Omnibox extends Component { + constructor(props) { + super(props); + this.state = { + index: new Map([]), + query: '', + results: this.initialResults(), + selected: '' + }; + this.handleClickOutside = this.handleClickOutside.bind(this); + this.search = this.search.bind(this); + this.navigate = this.navigate.bind(this); + this.control - this.control.bind(this); + this.setPreviousSelected = this.setPreviousSelected.bind(this); + this.setNextSelected = this.setNextSelected.bind(this); + } + + componentDidUpdate(prevProps) { + if (prevProps !== this.props) { + this.setState({ index: index(this.props.associations, this.props.apps.tiles) }); + } + + if (prevProps && this.props.show && prevProps.show !== this.props.show) { + Mousetrap.bind('escape', () => this.props.api.local.setOmnibox()); + document.addEventListener('mousedown', this.handleClickOutside); + const touchstart = new Event('touchstart'); + this.omniInput.input.dispatchEvent(touchstart); + this.omniInput.input.focus(); + } + } + + componentWillUpdate(prevProps) { + if (this.props.show && prevProps.show !== this.props.show) { + Mousetrap.unbind('escape'); + document.removeEventListener('mousedown', this.handleClickOutside); + } + } + + control(evt) { + if (evt.key === 'Escape') { + if (this.state.query.length > 0) { + this.setState({ query: '', results: this.initialResults() }); + } else if (this.props.show) { + this.props.api.local.setOmnibox(); + } + }; + + if ( + evt.key === 'ArrowUp' || + (evt.shiftKey && evt.key === 'Tab')) { + evt.preventDefault(); + return this.setPreviousSelected(); + } + + if (evt.key === 'ArrowDown' || evt.key === 'Tab') { + evt.preventDefault(); + this.setNextSelected(); + } + + if (evt.key === 'Enter') { + if (this.state.selected !== '') { + this.navigate(this.state.selected); + } else { + this.navigate(Array.from(this.state.results.values()).flat()[0].link); + } + } + } + + handleClickOutside(evt) { + if (this.props.show && !this.omniBox.contains(evt.target)) { + this.setState({ results: this.initialResults(), query: '' }, () => { + this.props.api.local.setOmnibox(); + }); + } + } + + initialResults() { + return new Map([ + ['commands', []], + ['subscriptions', []], + ['groups', []], + ['apps', []] + ]); + } + + navigate(link) { + const { props } = this; + this.setState({ results: this.initialResults(), query: '' }, () => { + props.api.local.setOmnibox(); + props.history.push(link); + }); + } + + search(event) { + const { state } = this; + const query = event.target.value; + const results = this.initialResults(); + + this.setState({ query: query }); + + // wipe results if backspacing + if (query.length === 0) { + this.setState({ results: results, selected: '' }); + return; + } + + // don't search for single characters + if (query.length === 1) { + return; + } + + ['commands', 'subscriptions', 'groups', 'apps'].map((category) => { + const categoryIndex = state.index.get(category); + results.set(category, + categoryIndex.filter((result) => { + return ( + result.title.toLowerCase().includes(query) || + result.link.toLowerCase().includes(query) || + result.app.toLowerCase().includes(query) || + (result.host !== null ? result.host.includes(query) : false) + ); + }) + ); + }); + + this.setState({ results: results }); + } + + setPreviousSelected() { + const current = this.state.selected; + const flattenedResults = Array.from(this.state.results.values()).flat(); + const totalLength = flattenedResults.length; + if (current !== '') { + const currentIndex = flattenedResults.indexOf( + ...flattenedResults.filter((e) => { + return e.link === current; + }) + ); + if (currentIndex > 0) { + const nextLink = flattenedResults[currentIndex - 1].link; + this.setState({ selected: nextLink }); + } else { + const nextLink = flattenedResults[totalLength - 1].link; + this.setState({ selected: nextLink }); + } + } else { + const nextLink = flattenedResults[totalLength - 1].link; + this.setState({ selected: nextLink }); + } + } + + setNextSelected() { + const current = this.state.selected; + const flattenedResults = Array.from(this.state.results.values()).flat(); + if (current !== '') { + const currentIndex = flattenedResults.indexOf( + ...flattenedResults.filter((e) => { + return e.link === current; + }) + ); + if (currentIndex < flattenedResults.length - 1) { + const nextLink = flattenedResults[currentIndex + 1].link; + this.setState({ selected: nextLink }); + } else { + const nextLink = flattenedResults[0].link; + this.setState({ selected: nextLink }); + } + } else { + const nextLink = flattenedResults[0].link; + this.setState({ selected: nextLink }); + } + } + + render() { + const { props, state } = this; + const categoryResult = []; + + const renderResults = + {categoryResult} + ; + + ['commands', 'subscriptions', 'groups', 'apps'].map((category, i) => { + const categoryResults = state.results.get(category); + if (categoryResults.length > 0) { + const each = categoryResults.map((result, i) => { + return this.navigate(result.link)} + selected={this.state.selected} + dark={props.dark} + />; + }); + categoryResult.push( + + {category.charAt(0).toUpperCase() + category.slice(1)} + {each} + ); + } + }); + + return ( + + + { + this.omniBox = el; + }}> + { this.omniInput = el; }} + control={e => this.control(e)} + search={this.search} + query={state.query} + /> + {renderResults} + + + + ); + } +} + +export default withRouter(Omnibox); diff --git a/pkg/interface/src/components/OmniboxInput.js b/pkg/interface/src/components/OmniboxInput.js new file mode 100644 index 0000000000..c86e714b7c --- /dev/null +++ b/pkg/interface/src/components/OmniboxInput.js @@ -0,0 +1,25 @@ + +import React, { Component } from 'react'; + +export class OmniboxInput extends Component { + render() { + const { props } = this; + return ( + { + this.input = el; + } + } + className='ba b--transparent w-100 br2 white-d bg-gray0-d inter f9 pa2' + style={{ maxWidth: 'calc(600px - 1.15rem)', boxSizing: 'border-box' }} + placeholder='Search...' + onKeyDown={props.control} + onChange={props.search} + value={props.query} + /> + ); + } +} + +export default OmniboxInput; + diff --git a/pkg/interface/src/components/OmniboxResult.js b/pkg/interface/src/components/OmniboxResult.js new file mode 100644 index 0000000000..5e93a95382 --- /dev/null +++ b/pkg/interface/src/components/OmniboxResult.js @@ -0,0 +1,83 @@ +import React, { Component } from 'react'; +import { Row, Icon, Text } from '@tlon/indigo-react'; +import defaultApps from '../lib/default-apps'; + +export class OmniboxResult extends Component { + constructor(props) { + super(props); + this.state = { + isSelected: false, + hovered: false + }; + this.setHover = this.setHover.bind(this); + } + + setHover(boolean) { + this.setState({ hovered: boolean }); + } + render() { + const { icon, text, subtext, link, navigate, selected, dark } = this.props; + + let invertGraphic = {}; + + if (icon.toLowerCase() !== 'dojo') { + invertGraphic = (!dark && this.state.hovered) || + selected === link || + (dark && !(this.state.hovered || selected === link)) + ? { filter: 'invert(1)', paddingTop: 2 } + : { filter: 'invert(0)', paddingTop: 2 }; + } else { + invertGraphic = + (!dark && this.state.hovered) || + selected === link || + (dark && !(this.state.hovered || selected === link)) + ? { filter: 'invert(0)', paddingTop: 2 } + : { filter: 'invert(1)', paddingTop: 2 }; + } + + let graphic =
    ; + if (defaultApps.includes(icon.toLowerCase()) || icon.toLowerCase() === 'links') { + graphic = ; + } else { + graphic = ; + } + return ( + this.setHover(true)} + onMouseLeave={() => this.setHover(false)} + backgroundColor={ + this.state.hovered || selected === link ? 'blue' : 'white' + } + onClick={navigate} + width="100%" + > + {this.state.hovered || selected === link ? ( + <> + {graphic} + + {text} + + + {subtext} + + + ) : ( + <> + {graphic} + {text} + + {subtext} + + + )} + + ); + } +} + +export default OmniboxResult; diff --git a/pkg/interface/src/components/ReconnectButton.js b/pkg/interface/src/components/ReconnectButton.js new file mode 100644 index 0000000000..2ffb026f90 --- /dev/null +++ b/pkg/interface/src/components/ReconnectButton.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { Box, Text } from '@tlon/indigo-react'; + +const ReconnectButton = ({ connection, subscription }) => { + const connectedStatus = connection || 'connected'; + const reconnect = subscription.restart.bind(subscription); + if (connectedStatus === 'disconnected') { + return ( + <> + + Reconnect ↻ + + + ); + } else if (connectedStatus === 'reconnecting') { + return ( + <> + + Reconnecting + + + ); + } else { + return null; + } + }; + +export default ReconnectButton; diff --git a/pkg/interface/src/components/StatusBar.js b/pkg/interface/src/components/StatusBar.js index cf01e6bc59..da80a9be20 100644 --- a/pkg/interface/src/components/StatusBar.js +++ b/pkg/interface/src/components/StatusBar.js @@ -1,82 +1,103 @@ import React from 'react'; -import { useLocation, Link } from 'react-router-dom'; - -import GroupFilter from './GroupFilter'; -import { Sigil } from '../lib/sigil'; - -const getLocationName = (basePath) => { - if (basePath === '~chat') - return 'Chat'; - else if (basePath === '~dojo') - return 'Dojo'; - else if (basePath === '~groups') - return 'Groups'; - else if (basePath === '~link') - return 'Links'; - else if (basePath === '~publish') - return 'Publish'; - else - return 'Unknown'; -}; +import { useLocation } from 'react-router-dom'; +import { Box, Text, Icon } from '@tlon/indigo-react'; +import ReconnectButton from './ReconnectButton'; const StatusBar = (props) => { const location = useLocation(); - const basePath = location.pathname.split('/')[1]; - const locationName = location.pathname === '/' - ? 'Home' - : getLocationName(basePath); + const atHome = Boolean(location.pathname === '/'); - const display = (!window.location.href.includes('popout/') && - (locationName !== 'Unknown')) + const display = (!window.location.href.includes('popout/')) ? 'db' : 'dn'; const invites = (props.invites && props.invites['/contacts']) ? props.invites['/contacts'] : {}; - const connection = props.connection || 'connected'; - const reconnect = props.subscription.restart.bind(props.subscription); + const Notification = (Object.keys(invites).length > 0) + ? + : null; + + const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+'; + + const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test( + navigator.userAgent + ); return (
    -
    - - +
    + {atHome ? null : ( + props.history.push('/')}> + + + )} + props.api.local.setOmnibox()}> + + ↩ + + + Leap + + + {metaKey}/ + + + +
    +
    + props.history.push('/~groups')}> + - - - / - { - location.pathname === '/' - ? null - : - ⟵ - - } -

    {locationName}

    - { connection === 'disconnected' && - (Reconnect ↻ ) - } - { connection === 'reconnecting' && - (Reconnecting ) - } + {Notification} + Groups +
    ); diff --git a/pkg/interface/src/lib/default-apps.js b/pkg/interface/src/lib/default-apps.js new file mode 100644 index 0000000000..4fcbe37330 --- /dev/null +++ b/pkg/interface/src/lib/default-apps.js @@ -0,0 +1,3 @@ +const defaultApps = ['chat', 'dojo', 'groups', 'link', 'publish']; + +export default defaultApps; diff --git a/pkg/interface/src/lib/omnibox.js b/pkg/interface/src/lib/omnibox.js new file mode 100644 index 0000000000..2be8824f21 --- /dev/null +++ b/pkg/interface/src/lib/omnibox.js @@ -0,0 +1,106 @@ +import defaultApps from './default-apps'; + +export default function index(associations, apps) { + const index = new Map([ + ['commands', []], + ['subscriptions', []], + ['groups', []], + ['apps', []] + ]); + + // result schematic + const result = function(title, link, app, host) { + return { + 'title': title, + 'link': link, + 'app': app, + 'host': host + }; + }; + + // commands are special cased for default suite + const commands = []; + defaultApps.filter((e) => { + return (e !== 'dojo'); + }).map((e) => { + let title = e; + if (e === 'link') { + title = 'Links'; + } + + title = title.charAt(0).toUpperCase() + title.slice(1); + + let obj = result(`${title}: Create`, `/~${e}/new`, e, null); + commands.push(obj); + + if (title === 'Groups') { + obj = result(`${title}: Join Group`, `/~${e}/join`, title, null); + commands.push(obj); + } + }); + index.set('commands', commands); + + // all metadata from all apps is indexed + // into subscriptions and groups + const subscriptions = []; + const groups = []; + Object.keys(associations).filter((e) => { + // skip apps with no metadata + return Object.keys(associations[e]).length > 0; + }).map((e) => { + // iterate through each app's metadata object + Object.keys(associations[e]).map((association) => { + const each = associations[e][association]; + let title = each['app-path']; + if (each.metadata.title !== '') { + title = each.metadata.title; + } + + let app = each['app-name']; + if (each['app-name'] === 'contacts') { + app = 'groups'; + }; + + const shipStart = each['app-path'].substr(each['app-path'].indexOf('~')); + + if (app === 'groups') { + const obj = result( + title, + `/~${app}${each['app-path']}`, + app.charAt(0).toUpperCase() + app.slice(1), + shipStart.slice(0, shipStart.indexOf('/')) + ); + groups.push(obj); + } else { + let endpoint = ''; + if (app === 'chat') { + endpoint = '/room'; + } else if (app === 'publish') { + endpoint = '/notebook'; + } + const obj = result( + title, + `/~${each['app-name']}${endpoint}${each['app-path']}`, + app.charAt(0).toUpperCase() + app.slice(1), + shipStart.slice(0, shipStart.indexOf('/')) + ); + subscriptions.push(obj); + } + }); + }); + index.set('subscriptions', subscriptions); + index.set('groups', groups); + + // all apps are indexed from launch data + // indexed into 'apps' + const applications = []; + Object.keys(apps).filter((e) => { + return (apps[e]?.type?.basic); + }).map((e) => { + const obj = result(apps[e].type.basic.title, apps[e].type.basic.linkedUrl, apps[e].type.basic.title, null); + applications.push(obj); + }); + index.set('apps', applications); + + return index; +}; diff --git a/pkg/interface/src/lib/util.js b/pkg/interface/src/lib/util.js index 0b20a8f43d..bfc42709aa 100644 --- a/pkg/interface/src/lib/util.js +++ b/pkg/interface/src/lib/util.js @@ -119,6 +119,9 @@ export function writeText(str) { // trim patps to match dojo, chat-cli export function cite(ship) { let patp = ship, shortened = ''; + if (patp === null || patp === '') { + return null; + } if (patp.startsWith('~')) { patp = patp.substr(1); } diff --git a/pkg/interface/src/reducers/local.ts b/pkg/interface/src/reducers/local.ts index ec11a23669..f5a526653c 100644 --- a/pkg/interface/src/reducers/local.ts +++ b/pkg/interface/src/reducers/local.ts @@ -3,16 +3,16 @@ import { StoreState } from '../store/type'; import { Cage } from '../types/cage'; import { LocalUpdate } from '../types/local-update'; -type LocalState = Pick; +type LocalState = Pick; export default class LocalReducer { reduce(json: Cage, state: S) { const data = json['local']; if (data) { this.sidebarToggle(data, state); - this.setSelected(data, state); this.setDark(data, state); this.baseHash(data, state); + this.omniboxShown(data, state); } } baseHash(obj: LocalUpdate, state: S) { @@ -21,18 +21,18 @@ export default class LocalReducer { } } + omniboxShown(obj: LocalUpdate, state: S) { + if ('omniboxShown' in obj) { + state.omniboxShown = !state.omniboxShown; + } + } + sidebarToggle(obj: LocalUpdate, state: S) { if ('sidebarToggle' in obj) { state.sidebarShown = !state.sidebarShown; } } - setSelected(obj: LocalUpdate, state: S) { - if ('selected' in obj) { - state.selectedGroups = obj.selected; - } - } - setDark(obj: LocalUpdate, state: S) { if('setDark' in obj) { state.dark = obj.setDark; diff --git a/pkg/interface/src/store/store.ts b/pkg/interface/src/store/store.ts index 76cdad3c2d..bc840f0856 100644 --- a/pkg/interface/src/store/store.ts +++ b/pkg/interface/src/store/store.ts @@ -41,6 +41,7 @@ export default class GlobalStore extends BaseStore { chatInitialized: false, connection: 'connected', sidebarShown: true, + omniboxShown: false, baseHash: null, invites: {}, associations: { @@ -72,7 +73,6 @@ export default class GlobalStore extends BaseStore { linkComments: {}, notebooks: {}, contacts: {}, - selectedGroups: [], dark: false, inbox: {}, chatSynced: null, diff --git a/pkg/interface/src/store/type.ts b/pkg/interface/src/store/type.ts index 875f80318a..6ff247105e 100644 --- a/pkg/interface/src/store/type.ts +++ b/pkg/interface/src/store/type.ts @@ -2,7 +2,6 @@ import { Inbox, Envelope } from '../types/chat-update'; import { ChatHookUpdate } from '../types/chat-hook-update'; import { Path } from '../types/noun'; import { Invites } from '../types/invite-update'; -import { SelectedGroup } from '../types/local-update'; import { Associations } from '../types/metadata-update'; import { Rolodex } from '../types/contact-update'; import { Notebooks } from '../types/publish-update'; @@ -16,7 +15,7 @@ import { ConnectionStatus } from '../types/connection'; export interface StoreState { // local state sidebarShown: boolean; - selectedGroups: SelectedGroup[]; + omniboxShown: boolean; dark: boolean; connection: ConnectionStatus; baseHash: string | null; diff --git a/pkg/interface/src/types/local-update.ts b/pkg/interface/src/types/local-update.ts index 326d845400..0ea170da0a 100644 --- a/pkg/interface/src/types/local-update.ts +++ b/pkg/interface/src/types/local-update.ts @@ -1,19 +1,13 @@ -import { Path } from './noun'; - export type LocalUpdate = LocalUpdateSidebarToggle -| LocalUpdateSelectedGroups | LocalUpdateSetDark +| LocalUpdateSetOmniboxShown | LocalUpdateBaseHash; interface LocalUpdateSidebarToggle { sidebarToggle: boolean; } -interface LocalUpdateSelectedGroups { - selected: SelectedGroup[]; -} - interface LocalUpdateSetDark { setDark: boolean; } @@ -22,4 +16,6 @@ interface LocalUpdateBaseHash { baseHash: string; } -export type SelectedGroup = [Path, string]; +interface LocalUpdateSetOmniboxShown { + omniboxShown: boolean; +}