Mentions.tsx raw

   1  import { Button } from '@/components/ui/button'
   2  import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
   3  import {
   4    DropdownMenu,
   5    DropdownMenuCheckboxItem,
   6    DropdownMenuContent,
   7    DropdownMenuTrigger
   8  } from '@/components/ui/dropdown-menu'
   9  import { cn } from '@/lib/utils'
  10  import { useMuteList } from '@/providers/MuteListProvider'
  11  import { useNostr } from '@/providers/NostrProvider'
  12  import { useScreenSize } from '@/providers/ScreenSizeProvider'
  13  import client from '@/services/client.service'
  14  import { Check } from 'lucide-react'
  15  import { Event, nip19 } from 'nostr-tools'
  16  import { useEffect, useMemo, useState } from 'react'
  17  import { useTranslation } from 'react-i18next'
  18  import { SimpleUserAvatar } from '../UserAvatar'
  19  import { SimpleUsername } from '../Username'
  20  
  21  export default function Mentions({
  22    content,
  23    mentions,
  24    setMentions,
  25    parentEvent
  26  }: {
  27    content: string
  28    mentions: string[]
  29    setMentions: (mentions: string[]) => void
  30    parentEvent?: Event
  31  }) {
  32    const { t } = useTranslation()
  33    const { isSmallScreen } = useScreenSize()
  34    const [isDrawerOpen, setIsDrawerOpen] = useState(false)
  35    const { pubkey } = useNostr()
  36    const { mutePubkeySet } = useMuteList()
  37    const [potentialMentions, setPotentialMentions] = useState<string[]>([])
  38    const [parentEventPubkey, setParentEventPubkey] = useState<string | undefined>()
  39    const [removedPubkeys, setRemovedPubkeys] = useState<string[]>([])
  40  
  41    useEffect(() => {
  42      extractMentions(content, parentEvent).then(({ pubkeys, relatedPubkeys, parentEventPubkey }) => {
  43        const _parentEventPubkey = parentEventPubkey !== pubkey ? parentEventPubkey : undefined
  44        setParentEventPubkey(_parentEventPubkey)
  45        const potentialMentions = [...pubkeys, ...relatedPubkeys].filter((p) => p !== pubkey)
  46        if (_parentEventPubkey) {
  47          potentialMentions.push(_parentEventPubkey)
  48        }
  49        setPotentialMentions(potentialMentions)
  50        setRemovedPubkeys((pubkeys) => {
  51          return Array.from(
  52            new Set(
  53              pubkeys
  54                .filter((p) => potentialMentions.includes(p))
  55                .concat(
  56                  potentialMentions.filter((p) => mutePubkeySet.has(p) && p !== _parentEventPubkey)
  57                )
  58            )
  59          )
  60        })
  61      })
  62    }, [content, parentEvent, pubkey, mutePubkeySet])
  63  
  64    useEffect(() => {
  65      const newMentions = potentialMentions.filter((pubkey) => !removedPubkeys.includes(pubkey))
  66      setMentions(newMentions)
  67    }, [potentialMentions, removedPubkeys])
  68  
  69    const items = useMemo(() => {
  70      return potentialMentions.map((_, index) => {
  71        const pubkey = potentialMentions[potentialMentions.length - 1 - index]
  72        const isParentPubkey = pubkey === parentEventPubkey
  73        return (
  74          <MenuItem
  75            key={`${pubkey}-${index}`}
  76            checked={isParentPubkey ? true : mentions.includes(pubkey)}
  77            onCheckedChange={(checked) => {
  78              if (isParentPubkey) {
  79                return
  80              }
  81              if (checked) {
  82                setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey))
  83              } else {
  84                setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey])
  85              }
  86            }}
  87            disabled={isParentPubkey}
  88          >
  89            <SimpleUserAvatar userId={pubkey} size="small" />
  90            <SimpleUsername
  91              userId={pubkey}
  92              className="font-semibold text-sm truncate"
  93              skeletonClassName="h-3"
  94            />
  95          </MenuItem>
  96        )
  97      })
  98    }, [potentialMentions, parentEventPubkey, mentions])
  99  
 100    if (isSmallScreen) {
 101      return (
 102        <>
 103          <Button
 104            className="px-3"
 105            variant="ghost"
 106            disabled={potentialMentions.length === 0}
 107            onClick={() => setIsDrawerOpen(true)}
 108          >
 109            {t('Mentions')}{' '}
 110            {potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
 111          </Button>
 112          <Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
 113            <DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
 114            <DrawerContent className="max-h-[80vh]" hideOverlay>
 115              <div
 116                className="overflow-y-auto overscroll-contain py-2"
 117                style={{ touchAction: 'pan-y' }}
 118              >
 119                {items}
 120              </div>
 121            </DrawerContent>
 122          </Drawer>
 123        </>
 124      )
 125    }
 126  
 127    return (
 128      <DropdownMenu>
 129        <DropdownMenuTrigger asChild>
 130          <Button
 131            className="px-3"
 132            variant="ghost"
 133            disabled={potentialMentions.length === 0}
 134            onClick={(e) => e.stopPropagation()}
 135          >
 136            {t('Mentions')}{' '}
 137            {potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
 138          </Button>
 139        </DropdownMenuTrigger>
 140        <DropdownMenuContent align="start" className="max-w-96 max-h-[50vh]" showScrollButtons>
 141          {items}
 142        </DropdownMenuContent>
 143      </DropdownMenu>
 144    )
 145  }
 146  
 147  function MenuItem({
 148    children,
 149    checked,
 150    disabled,
 151    onCheckedChange
 152  }: {
 153    children: React.ReactNode
 154    checked: boolean
 155    disabled?: boolean
 156    onCheckedChange: (checked: boolean) => void
 157  }) {
 158    const { isSmallScreen } = useScreenSize()
 159  
 160    if (isSmallScreen) {
 161      return (
 162        <div
 163          onClick={() => {
 164            if (disabled) return
 165            onCheckedChange(!checked)
 166          }}
 167          className={cn(
 168            'flex items-center gap-2 px-4 py-3 clickable',
 169            disabled ? 'opacity-50 pointer-events-none' : ''
 170          )}
 171        >
 172          <div className="flex items-center justify-center size-4 shrink-0">
 173            {checked && <Check className="size-4" />}
 174          </div>
 175          {children}
 176        </div>
 177      )
 178    }
 179  
 180    return (
 181      <DropdownMenuCheckboxItem
 182        checked={checked}
 183        disabled={disabled}
 184        onSelect={(e) => e.preventDefault()}
 185        onCheckedChange={onCheckedChange}
 186        className="flex items-center gap-2"
 187      >
 188        {children}
 189      </DropdownMenuCheckboxItem>
 190    )
 191  }
 192  
 193  async function extractMentions(content: string, parentEvent?: Event) {
 194    const parentEventPubkey = parentEvent ? parentEvent.pubkey : undefined
 195    const pubkeys: string[] = []
 196    const relatedPubkeys: string[] = []
 197    const matches = content.match(
 198      /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
 199    )
 200  
 201    const addToSet = (arr: string[], pubkey: string) => {
 202      if (pubkey === parentEventPubkey) return
 203      if (!arr.includes(pubkey)) arr.push(pubkey)
 204    }
 205  
 206    for (const m of matches || []) {
 207      try {
 208        const id = m.split(':')[1]
 209        const { type, data } = nip19.decode(id)
 210        if (type === 'nprofile') {
 211          addToSet(pubkeys, data.pubkey)
 212        } else if (type === 'npub') {
 213          addToSet(pubkeys, data)
 214        } else if (['nevent', 'note'].includes(type)) {
 215          const event = await client.fetchEvent(id)
 216          if (event) {
 217            addToSet(pubkeys, event.pubkey)
 218          }
 219        }
 220      } catch (e) {
 221        console.error(e)
 222      }
 223    }
 224  
 225    if (parentEvent) {
 226      parentEvent.tags.forEach(([tagName, tagValue]) => {
 227        if (['p', 'P'].includes(tagName) && !!tagValue) {
 228          addToSet(relatedPubkeys, tagValue)
 229        }
 230      })
 231    }
 232  
 233    return {
 234      pubkeys,
 235      relatedPubkeys: relatedPubkeys.filter((p) => !pubkeys.includes(p)),
 236      parentEventPubkey
 237    }
 238  }
 239