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