interface: add omnibox, new statusbar

This commit is contained in:
Matilde Park 2020-07-30 19:14:03 -04:00
parent 5ae12d492d
commit 1be4aed640
14 changed files with 629 additions and 120 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 B

After

Width:  |  Height:  |  Size: 564 B

View File

@ -4,6 +4,8 @@ import * as React from 'react';
import { BrowserRouter as Router, Route, withRouter, Switch } from 'react-router-dom';
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
import Mousetrap from 'mousetrap';
import './css/indigo-static.css';
import './css/fonts.css';
import light from './themes/light';
@ -17,6 +19,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';
@ -70,6 +73,10 @@ class App extends React.Component {
this.api.local.setDark(this.themeWatcher.matches);
this.themeWatcher.addListener(this.updateTheme);
this.api.local.getBaseHash();
Mousetrap.bind(['command+l', 'ctrl+l'], (e) => {
e.preventDefault();
this.api.local.setOmnibox();
});
}
componentWillUnmount() {
@ -91,81 +98,99 @@ class App extends React.Component {
<ThemeProvider theme={theme}>
<Root>
<Router>
<StatusBarWithRouter props={this.props}
associations={associations}
invites={this.state.invites}
api={this.api}
connection={this.state.connection}
subscription={this.subscription}
<StatusBarWithRouter
props={this.props}
associations={associations}
invites={this.state.invites}
api={this.api}
connection={this.state.connection}
subscription={this.subscription}
/>
<Omnibox
associations={state.associations}
apps={state.launch}
api={this.api}
dark={state.dark}
show={state.omniboxShown}
/>
<Content>
<Switch>
<Route exact path="/"
render={ p => (
<LaunchApp
ship={this.ship}
api={this.api}
{...state}
{...p}
<Switch>
<Route
exact
path='/'
render={p => (
<LaunchApp
ship={this.ship}
api={this.api}
{...state}
{...p}
/>
)}
/>
)}
/>
<Route path="/~chat" render={ p => (
<ChatApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
<Route
path='/~chat'
render={p => (
<ChatApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
/>
)}
/>
)}
/>
<Route path="/~dojo" render={ p => (
<DojoApp
ship={this.ship}
channel={channel}
selectedGroups={selectedGroups}
subscription={this.subscription}
{...p}
<Route
path='/~dojo'
render={p => (
<DojoApp
ship={this.ship}
channel={channel}
subscription={this.subscription}
{...p}
/>
)}
/>
)}
/>
<Route path="/~groups" render={ p => (
<GroupsApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
<Route
path='/~groups'
render={p => (
<GroupsApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
/>
)}
/>
)}
/>
<Route path="/~link" render={ p => (
<LinksApp
ship={this.ship}
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
<Route
path='/~link'
render={p => (
<LinksApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
/>
)}
/>
)}
/>
<Route path="/~publish" render={ p => (
<PublishApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
<Route
path='/~publish'
render={p => (
<PublishApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
/>
)}
/>
)}
/>
<Route
render={(props) => (
render={props => (
<ErrorComponent {...props} code={404} description="Not Found" />
)}
/>
/>
</Switch>
</Content>
</Router>

View File

@ -28,4 +28,14 @@ export default class LocalApi extends BaseApi<StoreState> {
});
}
setOmnibox() {
this.store.handleEvent({
data: {
local: {
omniboxShown: true
},
},
});
}
}

View File

@ -339,7 +339,13 @@ export class ChatInput extends Component {
'Shift-3': cm =>
cm.getValue().length === 0
? this.toggleCode()
: CodeMirror.Pass
: CodeMirror.Pass,
'Cmd-L': () => {
this.props.api.local.setOmnibox();
},
'Ctrl-L': () => {
this.props.api.local.setOmnibox();
}
}
};

View File

@ -0,0 +1,241 @@
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 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);
this.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 === 'ArrowDown') {
this.setNextSelected();
}
if (evt.key === 'ArrowUp') {
this.setPreviousSelected();
}
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.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 });
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.includes(query) ||
result.link.includes(query) ||
result.app.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();
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[0].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 });
}
}
render() {
const { props, state } = this;
const categoryResult = [];
const renderResults = <Box maxHeight="400px">
{categoryResult}
</Box>;
['commands', 'subscriptions', 'groups', 'apps'].map((category, i) => {
const categoryResults = state.results.get(category);
if (categoryResults.length > 0) {
const each = categoryResults.map((result, i) => {
return <OmniboxResult
key={i}
icon={result.app}
text={result.title}
subtext={cite(result.host)}
link={result.link}
navigate={() => this.navigate(result.link)}
selected={this.state.selected}
dark={props.dark}
/>;
});
categoryResult.push(<Box key={i} width='50vw' maxWidth='600px'>
<Rule borderTopWidth="0.5px" color="washedGray" />
<Text gray ml={2}>{category.charAt(0).toUpperCase() + category.slice(1)}</Text>
{each}
</Box>);
}
});
return (
<>
<Box
backgroundColor='lightGray'
width='100vw'
height='100vh'
position='absolute'
top='0'
right='0'
zIndex='9'
display={props.show ? 'block' : 'none'}
>
<Row justifyContent='center'>
<Box
mt='20vh'
width='50vw'
maxWidth='600px'
borderRadius='2'
backgroundColor='white'
ref={(el) => {
this.omniBox = el;
}}
>
<input
ref={(el) => {
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..."
onKeyUp={e => this.control(e) }
onChange={this.search}
value={state.query}
/>
{renderResults}
</Box>
</Row>
</Box>
</>
);
}
}
export default withRouter(Omnibox);

View File

@ -0,0 +1,67 @@
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;
const invertGraphic = ((!dark && this.state.hovered || selected === link) || (dark && !(this.state.hovered || selected === link)))
? { filter: 'invert(1)' }
: { filter: 'invert(0)' };
let graphic = <div />;
if (defaultApps.includes(icon.toLowerCase())) {
graphic = <img className="invert-d mr2 v-mid" height="12" width="12" src={`/~landscape/img/${icon.toLowerCase()}.png`} style={invertGraphic} />;
} else {
graphic = <Icon />;
}
return (
<Row
py='2'
px='2'
display='block'
style={{ cursor: "pointer" }}
onMouseEnter={() => this.setHover(true)}
onMouseLeave={() => this.setHover(false)}
backgroundColor={
this.state.hovered || selected === link ? "blue" : "white"
}
onClick={navigate}>
{this.state.hovered || selected === link ? (
<>
{graphic}
<Text color='white' mr='1'>
{text}
</Text>
<Text style={{ float: "right" }} color='white'>
{subtext}
</Text>
</>
) : (
<>
{graphic}
<Text mr='1'>{text}</Text>
<Text style={{ float: "right" }} gray>
{subtext}
</Text>
</>
)}
</Row>
);
}
}
export default OmniboxResult;

View File

@ -1,40 +1,35 @@
import React from 'react';
import { useLocation, Link } from 'react-router-dom';
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 { Box, Text, Icon } from '@tlon/indigo-react';
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 Notification = (Object.keys(invites).length > 0)
? <Icon size="22px" icon="Bullet"
fill="blue" position="absolute"
top={'-8px'} right={'7px'}
/>
: null;
const connection = props.connection || 'connected';
const reconnect = props.subscription.restart.bind(props.subscription);
const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+';
const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(
navigator.userAgent
);
return (
<div
className={
@ -42,38 +37,81 @@ const StatusBar = (props) => {
}
style={{ height: 45 }}
>
<div className="fl lh-copy absolute left-0 pl4" style={{ top: 8 }}>
<Link to="/~groups/me"
className="dib v-top" style={{ lineHeight: 0, paddingTop: 6 }}>
<Sigil
ship={'~' + window.ship}
classes="v-mid mix-blend-diff"
size={16}
color={'#000000'}
<div className='fl absolute left-0 pl4' style={{ top: 10 }}>
{atHome ? null : (
<Box
style={{ cursor: 'pointer' }}
display='inline-block'
borderRadius={2}
color='washedGray'
border={1}
py={1}
px={2}
mr={2}
onClick={() => props.history.push('/')}
>
<img
className='invert-d'
src='/~landscape/img/icon-home.png'
height='12'
width='12'
/>
</Box>
)}
<Box
border={1}
borderRadius={2}
color='washedGray'
display='inline-block'
style={{ cursor: 'pointer' }}
py={1}
px={2}
onClick={() => props.api.local.setOmnibox()}
>
<Text display='inline-block' style={{ transform: 'rotate(180deg)' }}>
</Text>
<Text ml={2} color='black'>
Leap
</Text>
<Text display={mobile ? 'none' : 'inline-block'} ml={4} color='gray'>
{metaKey}L
</Text>
</Box>
{connection === 'disconnected' && (
<span
onClick={reconnect}
className='ml4 ph2 dib f9 v-mid red2 inter ba b-red2 br1 pointer'
>
Reconnect
</span>
)}
{connection === 'reconnecting' && (
<span className='ml4 ph2 dib f9 v-mid yellow2 inter ba b-yellow2 br1'>
Reconnecting
</span>
)}
</div>
<div className='fl absolute relative right-0 pr4' style={{ top: 10 }}>
<Box
style={{ cursor: 'pointer' }}
display='inline-block'
borderRadius={2}
color='washedGray'
border={1}
px={2}
py={1}
onClick={() => props.history.push('/~groups')}
>
<img
className='invert-d v-mid'
src='/~landscape/img/groups.png'
height='16'
width='16'
/>
</Link>
<span className="dib f9 v-mid gray2 ml1 mr1 c-default inter">/</span>
{
location.pathname === '/'
? null
: <Link
className="dib f9 v-mid inter ml2 no-underline white-d"
to="/"
style={{ top: 14 }}
>
</Link>
}
<p className="dib f9 v-mid inter ml2 white-d">{locationName}</p>
{ connection === 'disconnected' &&
(<span
onClick={reconnect}
className="ml4 ph2 dib f9 v-mid red2 inter ba b-red2 br1 pointer"
>Reconnect </span> )
}
{ connection === 'reconnecting' &&
(<span className="ml4 ph2 dib f9 v-mid yellow2 inter ba b-yellow2 br1">Reconnecting</span> )
}
{Notification}
<Text ml={1}>Groups</Text>
</Box>
</div>
</div>
);

View File

@ -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`, title, 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;
};

View File

@ -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);
}

View File

@ -3,7 +3,7 @@ import { StoreState } from '../store/type';
import { Cage } from '../types/cage';
import { LocalUpdate } from '../types/local-update';
type LocalState = Pick<StoreState, 'sidebarShown' | 'dark' | 'baseHash'>;
type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'dark' | 'baseHash'>;
export default class LocalReducer<S extends LocalState> {
reduce(json: Cage, state: S) {
@ -12,6 +12,7 @@ export default class LocalReducer<S extends LocalState> {
this.sidebarToggle(data, state);
this.setDark(data, state);
this.baseHash(data, state);
this.omniboxShown(data, state);
}
}
baseHash(obj: LocalUpdate, state: S) {
@ -20,6 +21,12 @@ export default class LocalReducer<S extends LocalState> {
}
}
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;

View File

@ -41,6 +41,7 @@ export default class GlobalStore extends BaseStore<StoreState> {
chatInitialized: false,
connection: 'connected',
sidebarShown: true,
omniboxShown: false,
baseHash: null,
invites: {},
associations: {

View File

@ -15,6 +15,7 @@ import { ConnectionStatus } from '../types/connection';
export interface StoreState {
// local state
sidebarShown: boolean;
omniboxShown: boolean;
dark: boolean;
connection: ConnectionStatus;
baseHash: string | null;

View File

@ -1,7 +1,7 @@
export type LocalUpdate =
LocalUpdateSidebarToggle
| LocalUpdateSetDark
| LocalUpdateSetOmniboxShown
| LocalUpdateBaseHash;
interface LocalUpdateSidebarToggle {
@ -15,3 +15,7 @@ interface LocalUpdateSetDark {
interface LocalUpdateBaseHash {
baseHash: string;
}
interface LocalUpdateSetOmniboxShown {
omniboxShown: boolean;
}