From d76f9abdd718e24848a9b8f67486129aee421427 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 13 Sep 2024 16:48:28 -0500 Subject: "N" keyboard shortcut to open a new post modal (#5197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Hailey --- src/App.web.tsx | 3 + src/state/shell/composer.tsx | 107 --------------------- src/state/shell/composer/index.tsx | 107 +++++++++++++++++++++ .../shell/composer/useComposerKeyboardShortcut.tsx | 49 ++++++++++ 4 files changed, 159 insertions(+), 107 deletions(-) delete mode 100644 src/state/shell/composer.tsx create mode 100644 src/state/shell/composer/index.tsx create mode 100644 src/state/shell/composer/useComposerKeyboardShortcut.tsx (limited to 'src') diff --git a/src/App.web.tsx b/src/App.web.tsx index bef320826..6efe7cc02 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -35,6 +35,7 @@ import { } from '#/state/session' import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' +import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' @@ -62,6 +63,8 @@ function InnerApp() { useIntentHandler() const hasCheckedReferrer = useStarterPackEntry() + useComposerKeyboardShortcut() + // init useEffect(() => { async function onLaunch(account?: SessionAccount) { diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx deleted file mode 100644 index 612388ff8..000000000 --- a/src/state/shell/composer.tsx +++ /dev/null @@ -1,107 +0,0 @@ -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(undefined) -const controlsContext = React.createContext({ - openComposer(_opts: ComposerOpts) {}, - closeComposer() { - return false - }, -}) - -export function Provider({children}: React.PropsWithChildren<{}>) { - const {_} = useLingui() - const [state, setState] = React.useState() - - 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 ( - - - {children} - - - ) -} - -export function useComposerState() { - return React.useContext(stateContext) -} - -export function useComposerControls() { - return React.useContext(controlsContext) -} diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx new file mode 100644 index 000000000..612388ff8 --- /dev/null +++ b/src/state/shell/composer/index.tsx @@ -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(undefined) +const controlsContext = React.createContext({ + openComposer(_opts: ComposerOpts) {}, + closeComposer() { + return false + }, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const {_} = useLingui() + const [state, setState] = React.useState() + + 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 ( + + + {children} + + + ) +} + +export function useComposerState() { + return React.useContext(stateContext) +} + +export function useComposerControls() { + return React.useContext(controlsContext) +} diff --git a/src/state/shell/composer/useComposerKeyboardShortcut.tsx b/src/state/shell/composer/useComposerKeyboardShortcut.tsx new file mode 100644 index 000000000..f46062185 --- /dev/null +++ b/src/state/shell/composer/useComposerKeyboardShortcut.tsx @@ -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', and