import React from 'react' ; import Debug from 'debug' ; import { Handle , Doc } from 'hypermerge' ; import { createDocumentLink , parseDocumentLink , HypermergeUrl , PushpinUrl , } from '../../../ShareLink' ; import InvitationsView from '../../../InvitationsView' ; import { ContactDoc } from '../contact' ; import Badge from '../../Badge' ; import Text from '../../Text' ; import './Omnibox.css' ; import InvitationListItem from './InvitationListItem' ; import ListMenuSection from '../../ListMenuSection' ; import ListMenuItem from '../../ListMenuItem' ; import ListMenu from '../../ListMenu' ; import ActionListItem from './ActionListItem' ; const log = Debug ( 'pushpin:omnibox' ) ; export interface Props { active : boolean ; hypermergeUrl : HypermergeUrl ; omniboxFinished : Function ; } interface WorkspaceDoc { selfId : HypermergeUrl ; contactIds : HypermergeUrl [ ] ; currentDocUrl : PushpinUrl ; viewedDocUrls : PushpinUrl [ ] ; archivedDocUrls ? : PushpinUrl [ ] ; } interface State { search : string ; selectedIndex : number ; invitations : any [ ] ; viewedDocs : { [ docUrl ] : any ; } ; contacts : { [ contactId ] : ContactDoc ; } ; doc ? : Doc < WorkspaceDoc > ; } interface SectionIndex { [ sectionName ] : SectionRange ; } interface SectionRange { start : number ; end : number ; } interface Section { name : string ; label ? : string ; actions : Action [ ] ; items : ( state : State , props : Props ) => Item [ ] ; } interface Item { type ? : string ; object ? : any ; url ? : PushpinUrl ; selected ? : boolean ; actions ? : Action [ ] ; } interface Action { name : string ; callback : ( url : any ) => ( ) => void ; faIcon : string ; label : string ; shortcut : string ; keysForActionPressed : ( e : any ) => boolean ; } export default class Omnibox extends React . PureComponent < Props , State > { omniboxInput = React . createRef < HTMLInputElement > ( ) ; handle ? : Handle < WorkspaceDoc > ; viewedDocHandles : { [ docUrl ] : Handle < any > ; } ; contactHandles : { [ contactId ] : Handle < ContactDoc > ; } ; invitationsView : any ; state : State = { search : '' , selectedIndex : 0 , invitations : [ ] , viewedDocs : { } , contacts : { } } ; constructor ( props ) { super ( props ) ; this . viewedDocHandles = { } ; this . contactHandles = { } ; } componentDidMount = ( ) => { log ( 'componentDidMount' ) ; this . refreshHandle ( this . props . hypermergeUrl ) ; document . addEventListener ( 'keydown' , this . handleCommandKeys ) ; this . invitationsView = new InvitationsView ( window . repo , this . props . hypermergeUrl , this . onInvitationsChange ) ; } ; componentWillUnmount = ( ) => { log ( 'componentWillUnmount' ) ; this . handle && this . handle . close ( ) ; document . removeEventListener ( 'keydown' , this . handleCommandKeys ) ; Object . values ( this . viewedDocHandles ) . forEach ( ( handle ) => handle . close ( ) ) ; Object . values ( this . contactHandles ) . forEach ( ( handle ) => handle . close ( ) ) ; } ; componentDidUpdate = ( prevProps ) => { log ( 'componentDidUpdate' ) ; if ( prevProps . hypermergeUrl !== this . props . hypermergeUrl ) { this . refreshHandle ( this . props . hypermergeUrl ) ; } } ; componentWillReceiveProps ( newProps ) { if ( ! this . props . active && newProps . active ) { this . setState ( { search : '' } , ( ) => { setTimeout ( ( ) => { this . omniboxInput && this . omniboxInput . current && this . omniboxInput . current . focus ( ) ; } , 0 ) ; } ) ; } } refreshHandle = ( hypermergeUrl ) => { if ( this . handle ) { this . handle . close ( ) ; } this . handle = window . repo . watch ( hypermergeUrl , ( doc ) => this . onChange ( doc ) ) ; } ; onInvitationsChange = ( invitations ) => { log ( 'invitations change' ) ; this . setState ( { invitations } , ( ) => this . forceUpdate ( ) ) ; } ; onChange = ( doc ) => { log ( 'onChange' , doc ) ; this . setState ( { doc } , ( ) => { this . state . doc && this . state . doc . viewedDocUrls . forEach ( ( url ) => { if ( ! this . viewedDocHandles [ url ] ) { const { hypermergeUrl } = parseDocumentLink ( url ) ; const handle = window . repo . watch ( hypermergeUrl , ( doc ) => { this . setState ( ( state ) => { const { viewedDocs } = state ; viewedDocs [ url ] = doc ; return { viewedDocs } ; } ) ; } ) ; this . viewedDocHandles [ url ] = handle ; } } ) ; this . state . doc && this . state . doc . contactIds . forEach ( ( contactId ) => { if ( ! this . contactHandles [ contactId ] ) { const handle = window . repo . watch ( ( contactId as unknown ) as HypermergeUrl , ( doc ) => { this . setState ( ( state ) => { const { contacts } = state ; contacts [ contactId ] = doc ; return { contacts } ; } ) ; } ) ; this . contactHandles [ contactId ] = handle ; } } ) ; } ) ; } ; endSession = ( ) => { this . props . omniboxFinished ( ) ; } ; handleCommandKeys = ( e ) => { if ( ! this . props . active ) { return ; } if ( e . key === 'ArrowDown' ) { e . preventDefault ( ) ; this . moveDown ( ) ; } if ( e . key === 'ArrowUp' ) { e . preventDefault ( ) ; this . moveUp ( ) ; } const { selectedIndex } = this . state ; const { items } = this . menuSections ( ) ; const selected = items [ selectedIndex ] ; if ( ! selected ) { return ; } if ( selected . actions ) { selected . actions . forEach ( ( action ) => { if ( action . keysForActionPressed ( e ) ) { action . callback ( selected . url ) ( ) ; this . endSession ( ) ; } } ) ; } } ; setSelection = ( newIndex ) => { this . setState ( { selectedIndex : newIndex } ) ; } ; moveUp = ( ) => { const { selectedIndex } = this . state ; if ( selectedIndex > 0 ) { this . setState ( { selectedIndex : selectedIndex - 1 } ) ; } } ; moveDown = ( ) => { const { items } = this . menuSections ( ) ; const { selectedIndex } = this . state ; if ( selectedIndex < items . length - 1 ) { this . setState ( { selectedIndex : selectedIndex + 1 } ) ; } } ; onInputChange = ( e ) => { this . setState ( { search : e . target . value , selectedIndex : 0 } ) ; } ; menuSections = ( ) : { items : Item [ ] ; sectionIndices : SectionIndex ; } => { const { doc } = this . state ; if ( ! doc ) { return { items : [ ] , sectionIndices : { } } ; } let items = [ ] ; const sectionIndices = { } ; const { search } = this . state ; let searchRegEx ; try { searchRegEx = new RegExp ( search , 'i' ) ; } catch ( e ) { items . push ( { type : 'nothingFound' , actions : [ ] } ) ; sectionIndices . nothingFound = { start : 0 , end : 1 } ; return { items , sectionIndices } ; } const invitationItems = ( this . state . invitations || [ ] ) . filter ( ( i ) => ! doc . viewedDocUrls . some ( ( url ) => url === i . documentUrl ) ) . filter ( ( invitation ) => ( invitation . doc . title || 'Loading...' ) . match ( searchRegEx ) ) . map ( ( invitation ) => ( { type : 'invitation' , object : invitation , url : invitation . documentUrl , actions : [ this . view ] } ) ) ; sectionIndices . invitations = { start : items . length , end : invitationItems . length } ; items = items . concat ( invitationItems ) ; this . sectionDefinitions . forEach ( ( sectionDefinition ) => { const sectionItems = sectionDefinition . items ( this . state , this . props ) ; sectionItems . forEach ( ( item ) => { item . actions = sectionDefinition . actions ; } ) ; if ( sectionItems . length > 0 ) { sectionIndices [ sectionDefinition . name ] = { start : items . length , end : items . length + sectionItems . length } ; items = items . concat ( sectionItems ) ; } } ) ; if ( items . length === 0 ) { items . push ( { type : 'nothingFound' , actions : [ ] } ) ; sectionIndices . nothingFound = { start : 0 , end : 1 } ; } if ( items [ this . state . selectedIndex ] ) { items [ this . state . selectedIndex ] . selected = true ; } return { items , sectionIndices } ; } ; sectionItems = ( name ) => { const { items , sectionIndices } = this . menuSections ( ) ; const sectionRange = sectionIndices [ name ] ; if ( sectionRange ) { return items . slice ( sectionRange . start , sectionRange . end ) ; } return [ ] ; } ; view = { name : 'view' , callback : ( url ) => ( ) => this . navigate ( url ) , faIcon : 'fa-compass' , label : 'View' , shortcut : '⏎' , keysForActionPressed : ( e ) => e . key === 'Enter' } ; invite = { name : 'invite' , callback : ( url ) => ( ) => this . offerDocumentToIdentity ( url ) , faIcon : 'fa-share-alt' , label : 'Invite' , shortcut : '⏎' , keysForActionPressed : ( e ) => e . key === 'Enter' } ; archive = { name : 'archive' , destructive : true , callback : ( url ) => ( ) => this . archiveDocument ( url ) , faIcon : 'fa-trash' , label : 'Archive' , shortcut : '⌘+⌫' , keysForActionPressed : ( e ) => ( e . metaKey || e . ctrlKey ) && e . key === 'Backspace' } ; unarchive = { name : 'unarchive' , callback : ( url ) => ( ) => this . unarchiveDocument ( url ) , faIcon : 'fa-trash-restore' , label : 'Unarchive' , shortcut : '⌘+⌫' , keysForActionPressed : ( e ) => ( e . metaKey || e . ctrlKey ) && e . key === 'Backspace' } ; sectionDefinitions : Section [ ] = [ { name : 'viewedDocUrls' , label : 'Boards' , actions : [ this . view , this . archive ] , items : ( state , props ) => Object . entries ( this . state . viewedDocs ) . filter ( ( [ url , _doc ] ) => ! state . doc || ! state . doc . archivedDocUrls || ! state . doc . archivedDocUrls . includes ( url as PushpinUrl ) ) . filter ( ( [ url , _doc ] ) => parseDocumentLink ( url ) . type === 'board' ) . filter ( ( [ _url , doc ] ) => doc && doc . title . match ( new RegExp ( state . search , 'i' ) ) ) . map ( ( [ url , _doc ] ) => ( { url : url as PushpinUrl } ) ) } , { name : 'archivedDocUrls' , label : 'Archived' , actions : [ this . view , this . unarchive ] , items : ( state , props ) => state . search === '' || ! state . doc ? [ ] : ( state . doc . archivedDocUrls || [ ] ) . map ( ( url ) => [ url , this . state . viewedDocs [ url ] ] ) . filter ( ( [ url , doc ] ) => parseDocumentLink ( url ) . type === 'board' ) . filter ( ( [ url , doc ] ) => doc && doc . title . match ( new RegExp ( state . search , 'i' ) ) ) . map ( ( [ url , doc ] ) => ( { url } ) ) } , { name : 'docUrls' , actions : [ this . view ] , items : ( state , props ) => { try { parseDocumentLink ( state . search ) ; return [ { url : state . search as PushpinUrl } ] ; } catch { return [ ] ; } } } , { name : 'contacts' , label : 'Contacts' , actions : [ this . invite ] , items : ( state , props ) => Object . entries ( this . state . contacts ) . filter ( ( [ id , doc ] ) => doc . name ) . filter ( ( [ id , doc ] ) => doc . name . match ( new RegExp ( state . search , 'i' ) ) ) . map ( ( [ id , doc ] ) => ( { url : createDocumentLink ( 'contact' , id as HypermergeUrl ) } ) ) } , ] ; navigate = ( url ) => { window . location = url ; this . props . omniboxFinished ( ) ; } ; offerDocumentToIdentity = ( contactUrl ) => { const { type , hypermergeUrl } = parseDocumentLink ( contactUrl ) ; const { doc } = this . state ; if ( ! doc || ! doc . selfId ) { return ; } if ( type !== 'contact' ) { throw new Error ( 'Offer the current document to a contact by passing in the contact id document.' ) ; } window . repo . change ( doc . selfId , ( s ) => { if ( ! s . offeredUrls ) { s . offeredUrls = { } ; } if ( ! s . offeredUrls [ hypermergeUrl ] ) { s . offeredUrls [ hypermergeUrl ] = [ ] ; } if ( ! s . offeredUrls [ hypermergeUrl ] . includes ( doc . currentDocUrl ) ) { s . offeredUrls [ hypermergeUrl ] . push ( doc . currentDocUrl ) ; } } ) ; } ; archiveDocument = ( url ) => { this . handle && this . handle . change ( ( doc ) => { if ( ! doc . archivedDocUrls ) { doc . archivedDocUrls = [ ] ; } if ( ! doc . archivedDocUrls . includes ( url ) ) { doc . archivedDocUrls . push ( url ) ; } } ) ; } ; unarchiveDocument = ( url ) => { this . handle && this . handle . change ( ( doc ) => { if ( ! doc . archivedDocUrls ) { return ; } const unarchiveIndex = doc . archivedDocUrls . findIndex ( ( i ) => i === url ) ; if ( unarchiveIndex >= 0 ) { delete doc . archivedDocUrls [ unarchiveIndex ] ; } } ) ; } ; renderNothingFound = ( ) => { const item = this . sectionItems ( 'nothingFound' ) [ 0 ] ; if ( item ) { return ( < ListMenuSection title = "Oops..." key = "nothingFound" > < ListMenuItem > < div className = "Omnibox-nothingFound" > < span className = "Omnibox-nothingFoundBadge" > < Badge icon = "question-circle" backgroundColor = "var(--colorPaleGrey)" / > < /span> < Text > Nothing Found < /Text> < /div> < /ListMenuItem> < /ListMenuSection>); } return null ; } ; renderInvitationsSection = ( ) => { const invitations = this . sectionItems ( 'invitations' ) . map ( ( item ) => { const invitation = item . object ; return ( < InvitationListItem invitation = { invitation } key = { `${ invitation . sender . hypermergeUrl }-${ invitation . documentUrl }` } selected = { item . selected } / > ) ; } ) ; if ( invitations . length > 0 ) { return ( < ListMenuSection title = "Invitations" key = "invitations" > { invitations } < /ListMenuSection>); } return null ; } ; renderContentSection = ( { name , label , actions } ) => { const items = this . sectionItems ( name ) ; if ( items . length === 0 ) { return null ; } return ( < ListMenuSection key = { name } title = { label } > { items . map ( ( { url , selected } ) => url && ( < ActionListItem key = { url } contentUrl = { url } actions = { actions } selected = { selected } / > ) ) } < /ListMenuSection>); } ; render = ( ) => { log ( 'render' ) ; if ( ! this . state . doc || ! this . state . doc . currentDocUrl ) { return null ; } return ( < div className = "Omnibox" > < input type = "text" ref = { this . omniboxInput } style = { css . omniboxInput } onChange = { this . onInputChange } value = { this . state . search } placeholder = "Search..." / > < ListMenu > { this . renderInvitationsSection ( ) } { this . sectionDefinitions . map ( ( sectionDefinition ) => this . renderContentSection ( sectionDefinition ) ) } { this . renderNothingFound ( ) } < /ListMenu> < /div>); } ; } const css = { omniboxInput : { fontSize : '18px' , color : 'var(--colorBlueBlack)' , fontFamily : "'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif" , background : 'var(--colorInputGrey)' , border : '0px' , outline : 'none' , borderRadius : '4px' , margin : '4px' , height : '24px' , lineHeight : '24px' } } ;