synapsecns/sanguine

View on GitHub
packages/synapse-interface/components/StateManagedBridge/DestinationAddressInput.tsx

Summary

Maintainability
D
1 day
Test Coverage
import React, { useState, useRef, useEffect } from 'react'
import { isNull, isString } from 'lodash'
import { useTranslations } from 'next-intl'

import { useAppDispatch } from '@/store/hooks'
import { isValidAddress } from '@/utils/isValidAddress'
import { shortenAddress } from '@/utils/shortenAddress'
import { useBridgeState, useBridgeDisplayState } from '@/slices/bridge/hooks'
import {
  setDestinationAddress,
  clearDestinationAddress,
} from '@/slices/bridge/reducer'
import {
  setShowDestinationWarning,
  setIsDestinationWarningAccepted,
} from '@/slices/bridgeDisplaySlice'
import { Address } from 'viem'
import { isEmptyString } from '@/utils/isEmptyString'
import { CloseButton } from '@/components/ui/CloseButton'
import { useTransactionsState } from '@/slices/transactions/hooks'
import { TransactionsState } from '@/slices/transactions/reducer'
import { BridgeTransaction } from '@/slices/api/generated'
import { getValidAddress } from '@/utils/isValidAddress'
import { useKeyPress } from '@/utils/hooks/useKeyPress'
import useCloseOnOutsideClick from '@/utils/hooks/useCloseOnOutsideClick'

export const DestinationAddressInput = ({
  connectedAddress,
}: {
  connectedAddress: string
}) => {
  const dispatch = useAppDispatch()
  const t = useTranslations('Bridge')
  const { destinationAddress } = useBridgeState()
  const { showDestinationWarning } = useBridgeDisplayState()
  const { userHistoricalTransactions }: TransactionsState =
    useTransactionsState()

  const recipientList = filterTxsByRecipient(
    userHistoricalTransactions,
    connectedAddress
  )
  const filteredRecipientList = filterNewestTxByRecipient(recipientList)

  const isInputValidAddress: boolean = destinationAddress
    ? isValidAddress(destinationAddress)
    : false

  const isInputInvalid: boolean =
    (destinationAddress &&
      isString(destinationAddress) &&
      isEmptyString(destinationAddress)) ||
    (destinationAddress && !isInputValidAddress)

  const isSameAddress =
    connectedAddress &&
    isInputValidAddress &&
    getValidAddress(destinationAddress) === getValidAddress(connectedAddress)

  useEffect(() => {
    const showWarning = isInputValidAddress && !isSameAddress

    if (showWarning && !showDestinationWarning) {
      dispatch(setShowDestinationWarning(true))
    }

    if (!isInputValidAddress && showDestinationWarning) {
      dispatch(setShowDestinationWarning(false))
      dispatch(setIsDestinationWarningAccepted(false))
    }
  }, [
    dispatch,
    connectedAddress,
    destinationAddress,
    showDestinationWarning,
    isInputValidAddress,
  ])

  const inputRef = useRef<HTMLInputElement>(null)
  const [isInputFocused, setIsInputFocused] = useState<boolean>(false)

  const handleInputFocus = () => {
    setIsInputFocused(true)
    setShowRecipientList(true)

    if (inputRef.current) {
      inputRef.current.focus()
    }
  }

  const handleInputBlur = () => {
    setIsInputFocused(false)

    if (inputRef.current) {
      inputRef.current.blur()
    }

    if (destinationAddress && isInputInvalid) {
      handleClearInput()
    }
  }

  const handleClearInput = () => {
    dispatch(clearDestinationAddress())

    if (inputRef.current) {
      inputRef.current.value = ''
    }
  }

  const onClearUserInput = () => {
    handleClearInput()
    handleInputBlur()
  }

  useEffect(() => {
    dispatch(clearDestinationAddress())
    handleClearInput()
  }, [connectedAddress])

  useEffect(() => {
    if (!isInputFocused && isSameAddress) {
      handleClearInput()
    }
  }, [isSameAddress, destinationAddress, connectedAddress, isInputFocused])

  let placeholder

  if (isInputFocused) {
    placeholder = ''
  } else {
    placeholder = connectedAddress ? shortenAddress(connectedAddress) : '0x...'
  }

  let inputValue

  if (!destinationAddress) {
    inputValue = ''
  } else {
    inputValue =
      isInputValidAddress && !isInputFocused
        ? shortenAddress(destinationAddress)
        : destinationAddress
  }

  const listRef = useRef(null)
  const [showRecipientList, setShowRecipientList] = useState<boolean>(false)
  const [currentIdx, setCurrentIdx] = useState<number>(null)

  const handleCloseList = () => {
    if (showRecipientList) {
      setShowRecipientList(false)
    }
    setCurrentIdx(null)
  }

  const handlePaste = async () => {
    const pastedValue = await navigator.clipboard.readText()
    dispatch(setDestinationAddress(pastedValue as Address))
  }

  const onSelectRecipient = (address) => {
    dispatch(setDestinationAddress(address))
    handleInputBlur()
    handleCloseList()
  }

  useCloseOnOutsideClick(listRef, handleCloseList)

  const escPressed = useKeyPress('Escape', true)
  const arrowUp = useKeyPress('ArrowUp', true)
  const arrowDown = useKeyPress('ArrowDown', true)
  const enterPressed = useKeyPress('Enter', true)

  function escFunc() {
    if (!showRecipientList) return

    if (escPressed) {
      handleCloseList()
      handleClearInput()
      handleInputBlur()
    }
  }

  function arrowDownFunc() {
    if (filteredRecipientList.length === 0) return
    if (!showRecipientList) return

    const nextIdx = currentIdx + 1
    if (currentIdx === null) {
      setCurrentIdx(0)
    } else if (arrowDown && nextIdx < filteredRecipientList.length) {
      setCurrentIdx(nextIdx)
    }
  }

  function arrowUpFunc() {
    if (filteredRecipientList.length === 0) return
    if (!showRecipientList) return

    const nextIdx = currentIdx - 1

    if (arrowUp && -1 < nextIdx) {
      setCurrentIdx(nextIdx)
    }
  }

  function enterPressedFunc() {
    if (enterPressed && isNull(currentIdx)) {
      onSelectRecipient(destinationAddress)
    }

    if (enterPressed && !isNull(currentIdx) && currentIdx > -1) {
      onSelectRecipient(filteredRecipientList[currentIdx]?.toAddress)
    }
  }

  useEffect(enterPressedFunc, [enterPressed])
  useEffect(escFunc, [escPressed])
  useEffect(arrowDownFunc, [arrowDown])
  useEffect(arrowUpFunc, [arrowUp])

  const adjustInputSize = () => {
    const addressInput: HTMLElement = document.getElementById('address-input')

    if (!addressInput) return

    if (isInputFocused || isInputInvalid) {
      addressInput.style.width = '12rem'
    } else if (inputValue.length > 0) {
      addressInput.style.width = inputValue.length + 2 + 'ch'
    } else {
      addressInput.style.width = placeholder.length + 'ch'
    }
  }

  useEffect(() => {
    adjustInputSize()
  }, [inputValue, placeholder, isInputFocused, showRecipientList])

  return (
    <div id="destination-address-input">
      <div className="relative flex items-center">
        <div className="mr-1.5 text-secondary text-sm">{t('To')}: </div>
        <div
          className={`
           flex border text-md rounded-sm
           ${isInputFocused ? ' bg-bgBase' : 'bg-transparent hover:opacity-80'}
          ${
            isInputInvalid
              ? 'border-red-500 focus:border-red-500'
              : 'border-separator focus:border-separator'
          }
        `}
        >
          <input
            id="address-input"
            ref={inputRef}
            onChange={(e) =>
              dispatch(setDestinationAddress(e.target.value as Address))
            }
            onFocus={handleInputFocus}
            onBlur={handleInputBlur}
            placeholder={placeholder}
            value={inputValue}
            className={`
              transform-gpu transition-all duration-175 cursor-pointer max-w-36 md:max-w-48
              text-sm rounded-sm text-strong py-0.5 pl-1.5 z-0 border-0 bg-transparent
              focus:text-white focus:border-transparent focus:outline-none focus:ring-0
              ${isInputFocused || isInputInvalid ? 'text-left ' : 'text-center'}
              ${destinationAddress ? 'pr-6' : 'pr-1.5'}
            `}
          />
          {destinationAddress ? (
            <CloseButton
              onClick={onClearUserInput}
              className="!right-0 !w-5 !h-5 mr-0.5 mt-0.5 hover:opacity-70"
            />
          ) : isInputFocused ? (
            <PasteButton onPaste={handlePaste} />
          ) : null}
        </div>
      </div>
      <div className="relative">
        {showRecipientList && (
          <ul
            ref={listRef}
            className={`
              absolute right-0 z-50 p-0 top-1 bg-surface
              border border-solid border-tint rounded shadow
              popover list-none text-left overflow-hidden
            `}
          >
            {filteredRecipientList?.map((recipient, index) => {
              return (
                <ListRecipient
                  key={recipient?.toAddress}
                  index={index}
                  address={recipient?.toAddress}
                  daysAgo={recipient?.daysAgo}
                  selectedListItem={currentIdx}
                  onSelectRecipient={onSelectRecipient}
                />
              )
            })}
          </ul>
        )}
      </div>
    </div>
  )
}

const ListRecipient = ({
  index,
  address,
  daysAgo,
  selectedListItem,
  onSelectRecipient,
}: {
  index: number
  address: string
  daysAgo: number
  selectedListItem: number | null
  onSelectRecipient?: (destinationAddress: Address) => void
}) => {
  const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
    event.preventDefault()
    onSelectRecipient && onSelectRecipient(address as Address)
  }

  const t = useTranslations('Time')

  return (
    <div
      onMouseDown={handleMouseDown}
      className={`
        flex justify-between px-2 py-1 space-x-3.5
        cursor-pointer text-strong text-sm
        hover:bg-separator
        ${selectedListItem === index && 'bg-separator'}
      `}
    >
      <div>{shortenAddress(address)}</div>
      <div>
        {daysAgo}
        {t('d')}
      </div>
    </div>
  )
}

const filterTxsByRecipient = (
  transactions: BridgeTransaction[],
  connectedAddress?: string
): {
  toAddress: string | undefined
  time: string | undefined
  daysAgo: number
}[] => {
  const checkAddress = getValidAddress(connectedAddress)
  return transactions
    ?.filter(
      (transaction) =>
        getValidAddress(transaction.toInfo?.address) !== checkAddress
    )
    .map((transaction) => ({
      toAddress: getValidAddress(transaction.toInfo?.address),
      time: transaction.toInfo?.formattedTime,
      daysAgo: calculateDaysAgo(transaction.toInfo?.formattedTime),
    }))
}

const filterNewestTxByRecipient = (
  transactions: {
    toAddress: string | undefined
    time: string | undefined
    daysAgo: number
  }[]
) => {
  const newestTxMap = new Map()

  transactions?.forEach((tx) => {
    const existingTx = newestTxMap.get(tx.toAddress)

    if (!existingTx || tx.daysAgo < existingTx.daysAgo) {
      newestTxMap.set(tx.toAddress, tx)
    }
  })

  return Array.from(newestTxMap.values())
}

const calculateDaysAgo = (time?: string) => {
  if (time) {
    // Assuming timeString is in "YYYY-MM-DD HH:MM:SS +0000 UTC" format
    const formattedTimeString = time.replace(' +0000 UTC', 'Z')

    const timeDate = new Date(formattedTimeString)
    const now = new Date()

    return calculateDaysBetween(timeDate, now)
  } else {
    return null
  }
}

const calculateDaysBetween = (startDate: Date, endDate: Date) => {
  const msPerDay = 1000 * 60 * 60 * 24
  const utc1 = Date.UTC(
    startDate.getFullYear(),
    startDate.getMonth(),
    startDate.getDate()
  )
  const utc2 = Date.UTC(
    endDate.getFullYear(),
    endDate.getMonth(),
    endDate.getDate()
  )

  return Math.floor((utc2 - utc1) / msPerDay)
}

const PasteButton = ({ onPaste }: { onPaste: () => Promise<void> }) => {
  return (
    <svg
      width="19"
      height="19"
      viewBox="0 0 24 24"
      xmlns="http://www.w3.org/2000/svg"
      onClick={onPaste}
      onMouseDown={(e) => e.preventDefault()}
      className={`
        absolute border-transparent cursor-pointer
        right-0.5 mt-0.5 justify-self-end
        fill-zinc-100 stroke-zinc-100 hover:opacity-70
      `}
    >
      <rect x="5.5" y="5.5" width="13" height="16" rx="2" fill="none" />
      <path d="M9 7.5C8.72386 7.5 8.5 7.27614 8.5 7C8.5 5.067 10.067 3.5 12 3.5C13.933 3.5 15.5 5.067 15.5 7C15.5 7.27614 15.2761 7.5 15 7.5H9Z" />
    </svg>
  )
}