about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-09-06 17:58:47 -0500
committerGitHub <noreply@github.com>2024-09-06 15:58:47 -0700
commit543be176741bfcc6c093143799376972818908c4 (patch)
treefe329e9bbc0a784973e74f3af22868e1b68e0f48 /src
parent30d2ab8dd3ef793f235489910564cf46e8e6a860 (diff)
downloadvoidsky-543be176741bfcc6c093143799376972818908c4.tar.zst
Add emoji picker to chat composer (#5196)
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Adrov Igor <nucleartux@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/screens/Messages/Conversation/MessageInput.tsx2
-rw-r--r--src/screens/Messages/Conversation/MessageInput.web.tsx66
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx21
-rw-r--r--src/state/shell/composer.tsx2
-rw-r--r--src/view/com/composer/Composer.tsx6
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx4
-rw-r--r--src/view/com/composer/text-input/textInputWebEmitter.ts3
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx27
-rw-r--r--src/view/shell/Composer.web.tsx2
9 files changed, 119 insertions, 14 deletions
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
index dc63a869a..674edc41e 100644
--- a/src/screens/Messages/Conversation/MessageInput.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.tsx
@@ -23,6 +23,7 @@ import {
   useSaveMessageDraft,
 } from '#/state/messages/message-drafts'
 import {isIOS} from 'platform/detection'
+import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {useSharedInputStyles} from '#/components/forms/TextField'
@@ -41,6 +42,7 @@ export function MessageInput({
   hasEmbed: boolean
   setEmbed: (embedUrl: string | undefined) => void
   children?: React.ReactNode
+  openEmojiPicker?: (pos: EmojiPickerPosition) => void
 }) {
   const {_} = useLingui()
   const t = useTheme()
diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx
index a4a8a7852..0b7e47920 100644
--- a/src/screens/Messages/Conversation/MessageInput.web.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.web.tsx
@@ -12,9 +12,16 @@ import {
 } from '#/state/messages/message-drafts'
 import {isSafari, isTouchDevice} from 'lib/browser'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
+import {
+  Emoji,
+  EmojiPickerPosition,
+} from '#/view/com/composer/text-input/web/EmojiPicker.web'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
 import {useSharedInputStyles} from '#/components/forms/TextField'
+import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
 import {useExtractEmbedFromFacets} from './MessageInputEmbed'
 
@@ -23,11 +30,13 @@ export function MessageInput({
   hasEmbed,
   setEmbed,
   children,
+  openEmojiPicker,
 }: {
   onSendMessage: (message: string) => void
   hasEmbed: boolean
   setEmbed: (embedUrl: string | undefined) => void
   children?: React.ReactNode
+  openEmojiPicker?: (pos: EmojiPickerPosition) => void
 }) {
   const {isTabletOrDesktop} = useWebMediaQueries()
   const {_} = useLingui()
@@ -40,6 +49,7 @@ export function MessageInput({
   const [isFocused, setIsFocused] = React.useState(false)
   const [isHovered, setIsHovered] = React.useState(false)
   const [textAreaHeight, setTextAreaHeight] = React.useState(38)
+  const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
 
   const onSubmit = React.useCallback(() => {
     if (!hasEmbed && message.trim() === '') {
@@ -94,6 +104,23 @@ export function MessageInput({
     [],
   )
 
+  const onEmojiInserted = React.useCallback(
+    (emoji: Emoji) => {
+      const position = textAreaRef.current?.selectionStart ?? 0
+      setMessage(
+        message =>
+          message.slice(0, position) + emoji.native + message.slice(position),
+      )
+    },
+    [setMessage],
+  )
+  React.useEffect(() => {
+    textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
+    return () => {
+      textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
+    }
+  }, [onEmojiInserted])
+
   useSaveMessageDraft(message)
   useExtractEmbedFromFacets(message, setEmbed)
 
@@ -106,7 +133,7 @@ export function MessageInput({
           t.atoms.bg_contrast_25,
           {
             paddingRight: a.p_sm.padding - 2,
-            paddingLeft: a.p_md.padding - 2,
+            paddingLeft: a.p_sm.padding - 2,
             borderWidth: 1,
             borderRadius: 23,
             borderColor: 'transparent',
@@ -118,7 +145,44 @@ export function MessageInput({
         // @ts-expect-error web only
         onMouseEnter={() => setIsHovered(true)}
         onMouseLeave={() => setIsHovered(false)}>
+        <Button
+          onPress={e => {
+            e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
+              openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
+            })
+          }}
+          style={[
+            a.rounded_full,
+            a.overflow_hidden,
+            a.align_center,
+            a.justify_center,
+            {
+              marginTop: 5,
+              height: 30,
+              width: 30,
+            },
+          ]}
+          label={_(msg`Open emoji picker`)}>
+          {state => (
+            <View
+              style={[
+                a.absolute,
+                a.inset_0,
+                a.align_center,
+                a.justify_center,
+                {
+                  backgroundColor:
+                    state.hovered || state.focused || state.pressed
+                      ? t.atoms.bg.backgroundColor
+                      : undefined,
+                },
+              ]}>
+              <EmojiSmile size="lg" />
+            </View>
+          )}
+        </Button>
         <TextareaAutosize
+          ref={textAreaRef}
           style={StyleSheet.flatten([
             a.flex_1,
             a.px_sm,
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index c0e78e978..3034f0290 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
 import {clamp} from 'lib/numbers'
 import {ScrollProvider} from 'lib/ScrollContext'
 import {isWeb} from 'platform/detection'
+import {
+  EmojiPicker,
+  EmojiPickerState,
+} from '#/view/com/composer/text-input/web/EmojiPicker.web'
 import {List} from 'view/com/util/List'
 import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
 import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
@@ -97,6 +101,12 @@ export function MessagesList({
     startContentOffset: 0,
   })
 
+  const [emojiPickerState, setEmojiPickerState] =
+    React.useState<EmojiPickerState>({
+      isOpen: false,
+      pos: {top: 0, left: 0, right: 0, bottom: 0},
+    })
+
   // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
   // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
   // the bottom.
@@ -422,13 +432,22 @@ export function MessagesList({
             <MessageInput
               onSendMessage={onSendMessage}
               hasEmbed={!!embedUri}
-              setEmbed={setEmbed}>
+              setEmbed={setEmbed}
+              openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
               <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
             </MessageInput>
           </>
         )}
       </KeyboardStickyView>
 
+      {isWeb && (
+        <EmojiPicker
+          pinToTop
+          state={emojiPickerState}
+          close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
+        />
+      )}
+
       {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
     </>
   )
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index 74802a993..612388ff8 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -34,7 +34,7 @@ export interface ComposerOpts {
   quote?: ComposerOptsQuote
   quoteCount?: number
   mention?: string // handle of user to mention
-  openPicker?: (pos: DOMRect | undefined) => void
+  openEmojiPicker?: (pos: DOMRect | undefined) => void
   text?: string
   imageUris?: {uri: string; width: number; height: number}[]
 }
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 8ae92b018..3c7868ad2 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -133,7 +133,7 @@ export const ComposePost = observer(function ComposePost({
   quote: initQuote,
   quoteCount,
   mention: initMention,
-  openPicker,
+  openEmojiPicker,
   text: initText,
   imageUris: initImageUris,
   cancelRef,
@@ -520,8 +520,8 @@ export const ComposePost = observer(function ComposePost({
     gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
 
   const onEmojiButtonPress = useCallback(() => {
-    openPicker?.(textInput.current?.getCursorPosition())
-  }, [openPicker])
+    openEmojiPicker?.(textInput.current?.getCursorPosition())
+  }, [openEmojiPicker])
 
   const focusTextInput = useCallback(() => {
     textInput.current?.focus()
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 3c4aaf738..c477ada06 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
 import {Text as TiptapText} from '@tiptap/extension-text'
 import {generateJSON} from '@tiptap/html'
 import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
-import EventEmitter from 'eventemitter3'
 
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {blobToDataUri, isUriImage} from 'lib/media/util'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
 import {
   LinkFacetMatch,
   suggestLinkCardUri,
@@ -46,8 +46,6 @@ interface TextInputProps {
   onError: (err: string) => void
 }
 
-export const textInputWebEmitter = new EventEmitter()
-
 export const TextInput = React.forwardRef(function TextInputImpl(
   {
     richtext,
diff --git a/src/view/com/composer/text-input/textInputWebEmitter.ts b/src/view/com/composer/text-input/textInputWebEmitter.ts
new file mode 100644
index 000000000..fb037cac2
--- /dev/null
+++ b/src/view/com/composer/text-input/textInputWebEmitter.ts
@@ -0,0 +1,3 @@
+import EventEmitter from 'eventemitter3'
+
+export const textInputWebEmitter = new EventEmitter()
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 1f4178f7f..ad3bb30ec 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -7,8 +7,8 @@ import {
 } from 'react-native'
 import Picker from '@emoji-mart/react'
 
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
 import {atoms as a} from '#/alf'
-import {textInputWebEmitter} from '../TextInput.web'
 
 const HEIGHT_OFFSET = 40
 const WIDTH_OFFSET = 100
@@ -26,22 +26,41 @@ export type Emoji = {
   unified: string
 }
 
+export interface EmojiPickerPosition {
+  top: number
+  left: number
+  right: number
+  bottom: number
+}
+
 export interface EmojiPickerState {
   isOpen: boolean
-  pos: {top: number; left: number; right: number; bottom: number}
+  pos: EmojiPickerPosition
 }
 
 interface IProps {
   state: EmojiPickerState
   close: () => void
+  /**
+   * If `true`, overrides position and ensures picker is pinned to the top of
+   * the target element.
+   */
+  pinToTop?: boolean
 }
 
-export function EmojiPicker({state, close}: IProps) {
+export function EmojiPicker({state, close, pinToTop}: IProps) {
   const {height, width} = useWindowDimensions()
 
   const isShiftDown = React.useRef(false)
 
   const position = React.useMemo(() => {
+    if (pinToTop) {
+      return {
+        top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10,
+        left: state.pos.left,
+      }
+    }
+
     const fitsBelow = state.pos.top + PICKER_HEIGHT < height
     const fitsAbove = PICKER_HEIGHT < state.pos.top
     const placeOnLeft = PICKER_WIDTH < state.pos.left
@@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
           : undefined,
       }
     }
-  }, [state.pos, height, width])
+  }, [state.pos, height, width, pinToTop])
 
   React.useEffect(() => {
     if (!state.isOpen) return
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 5d80dc422..42696139e 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) {
           quoteCount={state?.quoteCount}
           onPost={state.onPost}
           mention={state.mention}
-          openPicker={onOpenPicker}
+          openEmojiPicker={onOpenPicker}
           text={state.text}
         />
       </View>