about summary refs log tree commit diff
path: root/src/components/dms
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/dms')
-rw-r--r--src/components/dms/ActionsWrapper.tsx82
-rw-r--r--src/components/dms/ActionsWrapper.web.tsx86
-rw-r--r--src/components/dms/MessageItem.tsx166
-rw-r--r--src/components/dms/MessageMenu.tsx99
4 files changed, 433 insertions, 0 deletions
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
new file mode 100644
index 000000000..107e5eb8e
--- /dev/null
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -0,0 +1,82 @@
+import React, {useCallback} from 'react'
+import {Pressable, View} from 'react-native'
+import Animated, {
+  cancelAnimation,
+  runOnJS,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+
+import {useHaptics} from 'lib/haptics'
+import {atoms as a} from '#/alf'
+import {MessageMenu} from '#/components/dms/MessageMenu'
+import {useMenuControl} from '#/components/Menu'
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
+
+export function ActionsWrapper({
+  message,
+  isFromSelf,
+  children,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  isFromSelf: boolean
+  children: React.ReactNode
+}) {
+  const playHaptic = useHaptics()
+  const menuControl = useMenuControl()
+
+  const scale = useSharedValue(1)
+  const animationDidComplete = useSharedValue(false)
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    transform: [{scale: scale.value}],
+  }))
+
+  // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
+  // function
+  const open = useCallback(() => {
+    menuControl.open()
+  }, [menuControl])
+
+  const shrink = useCallback(() => {
+    'worklet'
+    cancelAnimation(scale)
+    scale.value = withTiming(1, {duration: 200}, () => {
+      animationDidComplete.value = false
+    })
+  }, [animationDidComplete, scale])
+
+  const grow = React.useCallback(() => {
+    'worklet'
+    scale.value = withTiming(1.05, {duration: 750}, finished => {
+      if (!finished) return
+      animationDidComplete.value = true
+      runOnJS(playHaptic)()
+      runOnJS(open)()
+
+      shrink()
+    })
+  }, [scale, animationDidComplete, playHaptic, shrink, open])
+
+  return (
+    <View
+      style={[
+        {
+          maxWidth: '65%',
+        },
+        isFromSelf ? a.self_end : a.self_start,
+      ]}>
+      <AnimatedPressable
+        style={animatedStyle}
+        unstable_pressDelay={200}
+        onPressIn={grow}
+        onTouchEnd={shrink}>
+        {children}
+      </AnimatedPressable>
+      <MessageMenu message={message} control={menuControl} hideTrigger={true} />
+    </View>
+  )
+}
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
new file mode 100644
index 000000000..f4c85ab94
--- /dev/null
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -0,0 +1,86 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+
+import {atoms as a} from '#/alf'
+import {MessageMenu} from '#/components/dms/MessageMenu'
+import {useMenuControl} from '#/components/Menu'
+
+export function ActionsWrapper({
+  message,
+  isFromSelf,
+  children,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  isFromSelf: boolean
+  children: React.ReactNode
+}) {
+  const menuControl = useMenuControl()
+  const viewRef = React.useRef(null)
+
+  const [showActions, setShowActions] = React.useState(false)
+
+  const onMouseEnter = React.useCallback(() => {
+    setShowActions(true)
+  }, [])
+
+  const onMouseLeave = React.useCallback(() => {
+    setShowActions(false)
+  }, [])
+
+  // We need to handle the `onFocus` separately because we want to know if there is a related target (the element
+  // that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
+  const onFocus = React.useCallback<React.FocusEventHandler>(e => {
+    if (e.nativeEvent.relatedTarget == null) return
+    setShowActions(true)
+  }, [])
+
+  return (
+    <View
+      // @ts-expect-error web only
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      onFocus={onFocus}
+      onBlur={onMouseLeave}
+      style={StyleSheet.flatten([a.flex_1, a.flex_row])}
+      ref={viewRef}>
+      {isFromSelf && (
+        <View
+          style={[
+            a.mr_xl,
+            a.justify_center,
+            {
+              marginLeft: 'auto',
+            },
+          ]}>
+          <MessageMenu
+            message={message}
+            control={menuControl}
+            triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
+            onTriggerPress={onMouseEnter}
+            // @ts-expect-error web only
+            onMouseLeave={onMouseLeave}
+          />
+        </View>
+      )}
+      <View
+        style={{
+          maxWidth: '65%',
+        }}>
+        {children}
+      </View>
+      {!isFromSelf && (
+        <View style={[a.flex_row, a.align_center, a.ml_xl]}>
+          <MessageMenu
+            message={message}
+            control={menuControl}
+            triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
+            onTriggerPress={onMouseEnter}
+            // @ts-expect-error web only
+            onMouseLeave={onMouseLeave}
+          />
+        </View>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
new file mode 100644
index 000000000..3a1d8eab7
--- /dev/null
+++ b/src/components/dms/MessageItem.tsx
@@ -0,0 +1,166 @@
+import React, {useCallback, useMemo} from 'react'
+import {StyleProp, TextStyle, View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useSession} from '#/state/session'
+import {TimeElapsed} from 'view/com/util/TimeElapsed'
+import {atoms as a, useTheme} from '#/alf'
+import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
+import {Text} from '#/components/Typography'
+
+export function MessageItem({
+  item,
+  next,
+}: {
+  item: ChatBskyConvoDefs.MessageView
+  next:
+    | ChatBskyConvoDefs.MessageView
+    | ChatBskyConvoDefs.DeletedMessageView
+    | null
+}) {
+  const t = useTheme()
+  const {currentAccount} = useSession()
+
+  const isFromSelf = item.sender?.did === currentAccount?.did
+
+  const isNextFromSelf =
+    ChatBskyConvoDefs.isMessageView(next) &&
+    next.sender?.did === currentAccount?.did
+
+  const isLastInGroup = useMemo(() => {
+    // if the next message is from a different sender, then it's the last in the group
+    if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
+      return true
+    }
+
+    // or, if there's a 10 minute gap between this message and the next
+    if (ChatBskyConvoDefs.isMessageView(next)) {
+      const thisDate = new Date(item.sentAt)
+      const nextDate = new Date(next.sentAt)
+
+      const diff = nextDate.getTime() - thisDate.getTime()
+
+      // 10 minutes
+      return diff > 10 * 60 * 1000
+    }
+
+    return true
+  }, [item, next, isFromSelf, isNextFromSelf])
+
+  return (
+    <View>
+      <ActionsWrapper isFromSelf={isFromSelf} message={item}>
+        <View
+          style={[
+            a.py_sm,
+            a.px_lg,
+            a.my_2xs,
+            a.rounded_md,
+            {
+              backgroundColor: isFromSelf
+                ? t.palette.primary_500
+                : t.palette.contrast_50,
+              borderRadius: 17,
+            },
+            isFromSelf
+              ? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
+              : {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
+          ]}>
+          <Text
+            style={[
+              a.text_md,
+              a.leading_snug,
+              isFromSelf && {color: t.palette.white},
+            ]}>
+            {item.text}
+          </Text>
+        </View>
+      </ActionsWrapper>
+      <MessageItemMetadata
+        message={item}
+        isLastInGroup={isLastInGroup}
+        style={isFromSelf ? a.text_right : a.text_left}
+      />
+    </View>
+  )
+}
+
+export function MessageItemMetadata({
+  message,
+  isLastInGroup,
+  style,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  isLastInGroup: boolean
+  style: StyleProp<TextStyle>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const relativeTimestamp = useCallback(
+    (timestamp: string) => {
+      const date = new Date(timestamp)
+      const now = new Date()
+
+      const time = new Intl.DateTimeFormat(undefined, {
+        hour: 'numeric',
+        minute: 'numeric',
+        hour12: true,
+      }).format(date)
+
+      const diff = now.getTime() - date.getTime()
+
+      // if under 1 minute
+      if (diff < 1000 * 60) {
+        return _(msg`Now`)
+      }
+
+      // if in the last day
+      if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) {
+        return time
+      }
+
+      // if yesterday
+      const yesterday = new Date(now)
+      yesterday.setDate(yesterday.getDate() - 1)
+      if (
+        yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)
+      ) {
+        return _(msg`Yesterday, ${time}`)
+      }
+
+      return new Intl.DateTimeFormat(undefined, {
+        hour: 'numeric',
+        minute: 'numeric',
+        hour12: true,
+        day: 'numeric',
+        month: 'numeric',
+        year: 'numeric',
+      }).format(date)
+    },
+    [_],
+  )
+
+  if (!isLastInGroup) {
+    return null
+  }
+
+  return (
+    <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
+      {({timeElapsed}) => (
+        <Text
+          style={[
+            t.atoms.text_contrast_medium,
+            a.text_xs,
+            a.mt_2xs,
+            a.mb_lg,
+            style,
+          ]}>
+          {timeElapsed}
+        </Text>
+      )}
+    </TimeElapsed>
+  )
+}
diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx
new file mode 100644
index 000000000..a21324204
--- /dev/null
+++ b/src/components/dms/MessageMenu.tsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import {Pressable, View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useSession} from 'state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {usePromptControl} from '#/components/Prompt'
+
+export let MessageMenu = ({
+  message,
+  control,
+  hideTrigger,
+  triggerOpacity,
+}: {
+  hideTrigger?: boolean
+  triggerOpacity?: number
+  onTriggerPress?: () => void
+  message: ChatBskyConvoDefs.MessageView
+  control: Menu.MenuControlProps
+}): React.ReactNode => {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+  const deleteControl = usePromptControl()
+
+  const isFromSelf = message.sender?.did === currentAccount?.did
+
+  const onDelete = React.useCallback(() => {
+    // TODO delete the message
+  }, [])
+
+  const onReport = React.useCallback(() => {
+    // TODO report the message
+  }, [])
+
+  return (
+    <>
+      <Menu.Root control={control}>
+        {!hideTrigger && (
+          <View style={{opacity: triggerOpacity}}>
+            <Menu.Trigger label={_(msg`Chat settings`)}>
+              {({props, state}) => (
+                <Pressable
+                  {...props}
+                  style={[
+                    a.p_sm,
+                    a.rounded_full,
+                    (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
+                  ]}>
+                  <DotsHorizontal size="sm" style={t.atoms.text} />
+                </Pressable>
+              )}
+            </Menu.Trigger>
+          </View>
+        )}
+
+        <Menu.Outer>
+          <Menu.Group>
+            <Menu.Item
+              testID="messageDropdownDeleteBtn"
+              label={_(msg`Delete message`)}
+              onPress={deleteControl.open}>
+              <Menu.ItemText>{_(msg`Delete`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={Trash} position="right" />
+            </Menu.Item>
+            {!isFromSelf && (
+              <Menu.Item
+                testID="messageDropdownReportBtn"
+                label={_(msg`Report message`)}
+                onPress={onReport}>
+                <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={Warning} position="right" />
+              </Menu.Item>
+            )}
+          </Menu.Group>
+        </Menu.Outer>
+      </Menu.Root>
+
+      <Prompt.Basic
+        control={deleteControl}
+        title={_(msg`Delete message`)}
+        description={_(
+          msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
+        )}
+        confirmButtonCta={_(msg`Delete`)}
+        confirmButtonColor="negative"
+        onConfirm={onDelete}
+      />
+    </>
+  )
+}
+MessageMenu = React.memo(MessageMenu)