about summary refs log tree commit diff
path: root/src/state/shell/composer
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-09-13 16:48:28 -0500
committerGitHub <noreply@github.com>2024-09-13 16:48:28 -0500
commitd76f9abdd718e24848a9b8f67486129aee421427 (patch)
treee5574c807eea6010746006a234e3a79dc0684552 /src/state/shell/composer
parentcac43127f0163c84a921afd806d91e1df10ea568 (diff)
downloadvoidsky-d76f9abdd718e24848a9b8f67486129aee421427.tar.zst
"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>
Diffstat (limited to 'src/state/shell/composer')
-rw-r--r--src/state/shell/composer/index.tsx107
-rw-r--r--src/state/shell/composer/useComposerKeyboardShortcut.tsx49
2 files changed, 156 insertions, 0 deletions
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<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)
+}
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', <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])
+}