"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:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user