"N" keyboard shortcut to open a new post modal (#5197)

* feat: Add hook on web app to open composer with 'N' keyboard shortcut

* Extract, don't fire open composer if already open

* Ignore interactive elements

---------

Co-authored-by: João Gabriel <joaog@nocorp.io>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Eric Bailey
2024-09-13 16:48:28 -05:00
committed by GitHub
parent cac43127f0
commit d76f9abdd7
3 changed files with 52 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import React from 'react'
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyRichtextFacet,
ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import * as Toast from '#/view/com/util/Toast'
export interface ComposerOptsPostRef {
uri: string
cid: string
text: string
author: AppBskyActorDefs.ProfileViewBasic
embed?: AppBskyEmbedRecord.ViewRecord['embed']
moderation?: ModerationDecision
}
export interface ComposerOptsQuote {
uri: string
cid: string
text: string
facets?: AppBskyRichtextFacet.Main[]
indexedAt: string
author: AppBskyActorDefs.ProfileViewBasic
embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
}
export interface ComposerOpts {
replyTo?: ComposerOptsPostRef
onPost?: (postUri: string | undefined) => void
quote?: ComposerOptsQuote
quoteCount?: number
mention?: string // handle of user to mention
openEmojiPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
}
type StateContext = ComposerOpts | undefined
type ControlsContext = {
openComposer: (opts: ComposerOpts) => void
closeComposer: () => boolean
}
const stateContext = React.createContext<StateContext>(undefined)
const controlsContext = React.createContext<ControlsContext>({
openComposer(_opts: ComposerOpts) {},
closeComposer() {
return false
},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const {_} = useLingui()
const [state, setState] = React.useState<StateContext>()
const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
const author = opts.replyTo?.author || opts.quote?.author
const isBlocked = Boolean(
author &&
(author.viewer?.blocking ||
author.viewer?.blockedBy ||
author.viewer?.blockingByList),
)
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
} else {
setState(opts)
}
})
const closeComposer = useNonReactiveCallback(() => {
let wasOpen = !!state
setState(undefined)
return wasOpen
})
const api = React.useMemo(
() => ({
openComposer,
closeComposer,
}),
[openComposer, closeComposer],
)
return (
<stateContext.Provider value={state}>
<controlsContext.Provider value={api}>
{children}
</controlsContext.Provider>
</stateContext.Provider>
)
}
export function useComposerState() {
return React.useContext(stateContext)
}
export function useComposerControls() {
return React.useContext(controlsContext)
}
@@ -0,0 +1,49 @@
import React from 'react'
import {useComposerControls} from './'
/**
* Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2}
*/
function shouldIgnore(event: KeyboardEvent) {
const target: any = event.target || event.srcElement
if (!target) return false
const {tagName} = target
if (!tagName) return false
const isInput =
tagName === 'INPUT' &&
![
'checkbox',
'radio',
'range',
'button',
'file',
'reset',
'submit',
'color',
].includes(target.type)
// ignore: isContentEditable === 'true', <input> and <textarea> when readOnly state is false, <select>
if (
target.isContentEditable ||
((isInput || tagName === 'TEXTAREA' || tagName === 'SELECT') &&
!target.readOnly)
) {
return true
}
return false
}
export function useComposerKeyboardShortcut() {
const {openComposer} = useComposerControls()
React.useEffect(() => {
function handler(event: KeyboardEvent) {
if (shouldIgnore(event)) return
if (event.key === 'n' || event.key === 'N') {
openComposer({})
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [openComposer])
}