import { remote , shell , ipcRenderer as ipc } from 'electron' ; import * as Mousetrap from 'mousetrap' ; import { AppContext } from './context' ; import SELECTORS from './selectors' ; function targetIsInput ( target ) { if ( target . isContentEditable ) { return true ; } switch ( target . tagName ) { case 'TEXTAREA' : { return true ; } case 'INPUT' : { const type = target . getAttribute ( 'type' ) ; return type === 'search' || type === 'text' || type === 'url' || type === 'email' || type === 'tel' || type === 'number' ; } case 'SELECT' : { return true ; } default : return false ; } } Mousetrap . prototype . stopCallback = ( e , elem ) => { if ( ! targetIsInput ( elem ) ) { return false ; } if ( e . ctrlKey || e . altKey || e . metaKey ) { return false ; } if ( e . key === 'Escape' ) { return false ; } return true ; } ; type CustomHandler = ( c : AppContext , e : KeyboardEvent ) => void ; export default class KeymapsHandler { private customHandlers : { [ name ] : CustomHandler ; } = { } ; private focusedTweet : HTMLElement | null = null ; private prevHref = location . href ; constructor ( private config , private context ) { } subscribeKeydown ( ) { for ( const key in this . config ) { this . registerKeymap ( key , this . config [ key ] ) ; } console . log ( 'Tui: Keymappings are registered:' , this . config ) ; } registerKeymap ( key , name ) { if ( ! name ) { return ; } if ( ! this [ name ] ) { Mousetrap . bind ( key , e => { e . preventDefault ( ) ; console . log ( 'Tui: Keydown: Custom action: ' + key , name , e ) ; const handle = this . customHandlers [ name as string ] ; if ( ! handle ) { console . error ( 'Tui: No custom handler found for the action:' , name ) ; return ; } handle ( this . context , e ) ; } ) ; } else { const method = this [ name ] . bind ( this ) ; Mousetrap . bind ( key , e => { e . preventDefault ( ) ; console . log ( 'Tui: Keydown: ' + key , name , e ) ; method ( e ) ; } ) ; } } registerCustomHandler ( name , handler ) { this . customHandlers [ name ] = handler ; console . log ( 'Tui: Registered keymap: ' + name , handler ) ; } 'next-tweet' ( ) { this . moveFocusByOffset ( 1 , false ) ; } 'previous-tweet' ( ) { this . moveFocusByOffset ( - 1 , true ) ; } 'unfocus-tweet' ( ) { const active = document . activeElement as HTMLElement | null ; if ( active && ( active . matches ( SELECTORS . directMessageTextarea ) || active . matches ( SELECTORS . searchPageInput ) ) ) { active . blur ( ) ; return ; } const cancel = ( document . querySelector ( SELECTORS . backButton ) || document . querySelector ( SELECTORS . editCancelButton ) ) as HTMLElement | null ; if ( cancel !== null ) { cancel . click ( ) ; return ; } this . setCurrentFocusedTweet ( null ) ; } 'scroll-down-page' ( ) { window . scrollBy ( 0 , window . innerHeight ) ; this . setCurrentFocusedTweet ( this . getFirstTweetInView ( document . querySelectorAll ( SELECTORS . tabItems ) ) ) ; } 'scroll-up-page' ( ) { window . scrollBy ( 0 , - window . innerHeight ) ; this . setCurrentFocusedTweet ( this . getFirstTweetInView ( document . querySelectorAll ( SELECTORS . tabItems ) ) ) ; } 'scroll-up-to-top' ( ) { const e = document . querySelector ( SELECTORS . scrollUpToNewTweet ) as HTMLElement | null ; if ( e !== null ) { e . click ( ) ; } else { window . scrollTo ( 0 , 0 ) ; } this . setCurrentFocusedTweet ( null ) ; } 'scroll-down-to-bottom' ( ) { window . scrollTo ( 0 , document . body . scrollHeight ) ; this . setCurrentFocusedTweet ( null ) ; } 'switch-home-timeline' ( ) { this . clickTab ( 0 ) ; } 'switch-notifications' ( ) { this . clickTab ( 1 ) ; } 'switch-direct-messages' ( ) { this . clickTab ( 2 ) ; } 'switch-search' ( ) { if ( this . clickTab ( 3 ) ) { const input = document . querySelector ( SELECTORS . searchPageInput ) as HTMLInputElement | null ; if ( input ) { input . focus ( ) ; } } } 'new-tweet' ( ) { if ( this . context . isMessagesConversationPage ( ) ) { const textarea = document . querySelector ( SELECTORS . directMessageTextarea ) as HTMLTextAreaElement | null ; if ( textarea === null ) { console . error ( 'Tui: Textarea for direct message was not found' ) ; return ; } textarea . focus ( ) ; return ; } const button = document . querySelector ( SELECTORS . newTweetButton ) as HTMLElement | null ; if ( button !== null ) { button . click ( ) ; this . focusNewTweetTextarea ( ) ; } else { if ( this . clickTab ( 0 ) ) { this [ 'new-tweet' ] ( ) ; } } } 'send-tweet' ( ) { const selector = this . context . isMessagesConversationPage ( ) ? SELECTORS . directMessageSubmitButton : SELECTORS . sendTweet ; const button = document . querySelector ( selector ) as HTMLElement | null ; if ( button !== null ) { button . click ( ) ; } } 'reply-tweet' ( ) { this . clickTweetAction ( 0 ) ; this . focusNewTweetTextarea ( ) ; } 'like-tweet' ( ) { this . clickTweetAction ( 2 ) ; } 'retweet-tweet' ( ) { if ( ! this . clickTweetAction ( 1 ) ) { return ; } const selectionButtons = document . querySelectorAll ( SELECTORS . selectionDialogItems ) ; if ( selectionButtons . length < 2 ) { return ; } const rtButton = selectionButtons [ 0 ] as HTMLElement ; rtButton . click ( ) ; } 'quote-tweet' ( ) { if ( ! this . clickTweetAction ( 1 ) ) { return ; } const selectionButtons = document . querySelectorAll ( SELECTORS . selectionDialogItems ) ; if ( selectionButtons . length < 2 ) { return ; } const qtButton = selectionButtons [ 1 ] as HTMLElement ; qtButton . click ( ) ; this . focusNewTweetTextarea ( ) ; } 'open-images' ( ) { if ( this . focusedTweet === null ) { return ; } const thumb = this . focusedTweet . querySelector ( SELECTORS . thumbnailImageInTweet ) as HTMLElement | null ; if ( thumb === null ) { return ; } thumb . click ( ) ; } 'open-images-in-browser' ( ) { if ( this . focusedTweet === null ) { return ; } const thumb = this . focusedTweet . querySelector ( SELECTORS . thumbnailImageInTweet ) as HTMLAnchorElement | null ; if ( thumb === null ) { return ; } let url = thumb . href ; if ( url . startsWith ( '/' ) ) { url = 'https://twitter.com' + url ; } shell . openExternal ( url ) ; } 'open-tweet' ( ) { if ( this . focusedTweet === null ) { return ; } if ( this . context . isMessagesPage ( ) ) { this . focusedTweet . click ( ) ; return ; } const body = this . focusedTweet . querySelector ( SELECTORS . tweetBody ) as HTMLDivElement | null ; if ( body !== null ) { body . click ( ) ; return ; } const activityIcons = this . focusedTweet . querySelectorAll ( SELECTORS . tweetActivityUserIcon ) ; if ( activityIcons . length > 0 ) { const icon = activityIcons [ 0 ] as HTMLElement ; console . log ( 'Tui: Open activity user:' , icon ) ; icon . click ( ) ; } } 'open-links' ( ) { if ( this . focusedTweet === null ) { return ; } const urls = [ ] ; const text = this . focusedTweet . querySelector ( SELECTORS . tweetText ) as HTMLDivElement | null ; if ( text !== null ) { const links = text . querySelectorAll ( 'a' ) ; for ( const l of links ) { const u = ( l as HTMLAnchorElement ) . href ; if ( u ) { urls . push ( u ) ; } } } const thumbnail = this . focusedTweet . querySelector ( SELECTORS . thumbnailLinkInTweet ) as HTMLAnchorElement | null ; if ( thumbnail !== null ) { urls . push ( thumbnail . href ) ; } console . log ( 'Tui: Open links:' , urls ) ; for ( const u of urls ) { if ( ! u . startsWith ( 'https://mobile.twitter.com' ) ) { shell . openExternal ( u ) ; } } } 'show-user' ( ) { if ( this . focusedTweet === null ) { return ; } const icons = this . focusedTweet . querySelectorAll ( SELECTORS . tweetUserIcon ) ; if ( icons . length === 0 ) { return ; } const target = icons [ icons . length - 1 ] as HTMLElement ; console . log ( 'Tui: Open user:' , target ) ; target . click ( ) ; } 'browser-go-back' ( ) { const c = remote . getCurrentWebContents ( ) ; if ( ! c . canGoBack ( ) ) { return ; } c . goBack ( ) ; } 'browser-go-forward' ( ) { const c = remote . getCurrentWebContents ( ) ; if ( ! c . canGoForward ( ) ) { return ; } c . goForward ( ) ; } 'browser-reload' ( ) { remote . getCurrentWebContents ( ) . reload ( ) ; } 'quit-app' ( ) { remote . app . quit ( ) ; } 'zoom-in' ( ) { this . modifyZoomFactor ( 0.1 ) ; remote . getCurrentWebContents ( ) ; } 'zoom-out' ( ) { this . modifyZoomFactor ( - 0.1 ) ; } 'open-devtools' ( ) { remote . getCurrentWebContents ( ) . openDevTools ( { mode : 'detach' } ) ; } 'last-account' ( ) { ipc . send ( 'tuitter:switch-account-last' ) ; } 'next-account' ( ) { ipc . send ( 'tuitter:switch-account-next' ) ; } 'previous-account' ( ) { ipc . send ( 'tuitter:switch-account-prev' ) ; } private getFirstTweetInView ( tweets ) { const viewTop = document . body . scrollTop ; const viewBottom = viewTop + window . innerHeight ; for ( const tw of tweets ) { const rect = ( tw as HTMLDivElement ) . getBoundingClientRect ( ) ; const top = viewTop + rect . top ; const bottom = viewTop + rect . bottom ; const inView = ( viewTop <= top && top < viewBottom ) || ( viewTop < bottom && bottom <= viewBottom ) ; if ( inView ) { return tw as HTMLDivElement ; } } return null ; } private setCurrentFocusedTweet ( tw ) { if ( this . focusedTweet !== null ) { this . focusedTweet . classList . remove ( 'tuitter-focused-item' ) ; } this . focusedTweet = tw ; if ( tw !== null ) { tw . classList . add ( 'tuitter-focused-item' ) ; } } private getFocusableItemsSelector ( ) { if ( this . context . isMessagesPage ( ) ) { return SELECTORS . directMessagesThread ; } else if ( this . context . isMessagesConversationPage ( ) ) { return SELECTORS . directMessageItem ; } else if ( this . context . isMentionTimeline ( ) ) { return SELECTORS . tweetActivity ; } else { return SELECTORS . tweet ; } } private indexOfFocusedItem ( items ) { for ( let i = 0 ; i < items . length ; ++ i ) { if ( items [ i ] === this . focusedTweet ) { return i ; } } return - 1 ; } private moveFocusByOffset ( offset , alignWithTop ) { if ( location . href !== this . prevHref ) { this . focusedTweet = null ; this . prevHref = location . href ; } const items = document . querySelectorAll ( this . getFocusableItemsSelector ( ) ) as NodeListOf < HTMLElement > ; if ( items . length === 0 ) { console . log ( 'Tui no item to scroll was found' ) ; return ; } const idx = this . indexOfFocusedItem ( items ) ; const current = items [ idx ] ; const next = items [ idx + offset ] ; const viewTop = document . body . scrollTop ; const viewBottom = viewTop + window . innerHeight ; let headerTop = NaN ; if ( alignWithTop ) { const header = document . querySelector ( SELECTORS . header ) as HTMLElement | null ; if ( header !== null ) { headerTop = viewTop + header . clientHeight ; } else { console . error ( 'Tui: No header found on moving focus. Skipped.' ) ; } } if ( ! current ) { const first = this . getFirstTweetInView ( items ) ; const target = first || items [ 0 ] ; const targetRect = target . getBoundingClientRect ( ) ; const targetTop = viewTop + targetRect . top ; const targetBottom = viewTop + targetRect . bottom ; if ( alignWithTop ) { this . scrollToFitEdge ( headerTop , targetTop ) ; } else { this . scrollToFitEdge ( viewBottom , targetBottom ) ; } this . setCurrentFocusedTweet ( target ) ; return ; } const currentRect = current . getBoundingClientRect ( ) ; const currentTop = viewTop + currentRect . top ; const currentBottom = viewTop + currentRect . bottom ; if ( alignWithTop ) { if ( headerTop > currentTop ) { if ( this . scrollToFitEdge ( headerTop , currentTop , viewTop ) ) { return ; } } } else { if ( currentBottom > viewBottom ) { if ( this . scrollToFitEdge ( viewBottom , currentBottom , viewTop ) ) { return ; } } } const nextRect = next . getBoundingClientRect ( ) ; const nextTop = viewTop + nextRect . top ; const nextBottom = viewTop + nextRect . bottom ; const nextInView = viewTop <= nextTop && nextBottom <= viewBottom ; if ( ! nextInView ) { if ( alignWithTop ) { this . scrollToFitEdge ( headerTop , nextTop ) ; } else { this . scrollToFitEdge ( viewBottom , nextBottom ) ; } } this . setCurrentFocusedTweet ( next ) ; } private clickTweetAction ( index ) { if ( this . focusedTweet === null ) { return false ; } const actionsBodies = this . focusedTweet . querySelectorAll ( SELECTORS . tweetActions ) ; if ( actionsBodies . length === 0 ) { console . error ( 'Unexpected number of actions body in tweet element:' , actionsBodies ) ; return false ; } const actionsBody = actionsBodies [ actionsBodies . length - 1 ] as HTMLDivElement ; const actions = actionsBody . querySelectorAll ( SELECTORS . tweetAction ) ; if ( actions . length !== 4 ) { console . error ( 'Unexpected number of actions in tweet element:' , actions ) ; return false ; } ( actions [ index ] as HTMLElement ) . click ( ) ; return true ; } private clickTab ( index ) { const items = document . querySelectorAll ( SELECTORS . tabItems ) ; if ( items . length === 0 ) { console . log ( 'Tui: No tab items found: ' + SELECTORS . tabItems ) ; return false ; } if ( items [ 0 ] . querySelector ( SELECTORS . loginIcon ) !== null ) { index += 1 ; } if ( items . length > index ) { ( items [ index ] as HTMLElement ) . click ( ) ; return true ; } else { return false ; } } private modifyZoomFactor ( diff ) { const c = remote . getCurrentWebContents ( ) ; c . getZoomFactor ( factor => { c . setZoomFactor ( factor + diff ) ; } ) ; } private focusNewTweetTextarea ( ) { const textarea = document . querySelector ( SELECTORS . newTweetTextarea ) as HTMLElement | null ; if ( textarea === null ) { console . error ( 'Tui: Textarea not found after clicking new tweet button.' ) ; return ; } setTimeout ( ( ) => textarea . focus ( ) , 0 ) ; } private scrollToFitEdge ( fitted , willFit , viewTop ? ) { window . scrollBy ( 0 , willFit - fitted ) ; return viewTop === undefined || document . body . scrollTop !== viewTop ; } }