reaction-picker.tsx
1 'use client' 2 3 import { useState, useEffect, useRef, useMemo } from 'react' 4 5 const CATEGORIES: Array<{ id: string; label: string; icon: string; emojis: string[] }> = [ 6 { 7 id: 'frequent', 8 label: 'Frequently Used', 9 icon: '๐', 10 emojis: ['๐', 'โค๏ธ', '๐', '๐ฅ', '๐', '๐', '๐', 'โ ', '๐ฏ', '๐ค'], 11 }, 12 { 13 id: 'smileys', 14 label: 'Smileys & People', 15 icon: '๐', 16 emojis: [ 17 '๐', '๐', '๐', '๐', '๐', '๐ ', '๐คฃ', '๐', '๐', '๐', 18 '๐', '๐ฅฐ', '๐', '๐คฉ', '๐', '๐', '๐', '๐', '๐ฅฒ', '๐', 19 '๐', '๐', '๐คช', '๐', '๐ค', '๐ค', '๐คญ', '๐ซข', '๐คซ', '๐ค', 20 '๐ซก', '๐ค', '๐คจ', '๐', '๐', '๐ถ', '๐ซฅ', '๐', '๐', '๐', 21 '๐ฌ', '๐คฅ', '๐ซจ', '๐', '๐', '๐ช', '๐คค', '๐ด', '๐ท', '๐ค', 22 '๐ค', '๐คข', '๐คฎ', '๐ฅด', '๐ต', '๐คฏ', '๐ฅณ', '๐ฅธ', '๐', '๐ค', 23 '๐ง', '๐', '๐ซค', '๐', '๐', '๐ฎ', '๐ฏ', '๐ฒ', '๐ณ', '๐ฅบ', 24 '๐ฅน', '๐ฆ', '๐ง', '๐จ', '๐ฐ', '๐ฅ', '๐ข', '๐ญ', '๐ฑ', '๐', 25 '๐ฃ', '๐', '๐', '๐ฉ', '๐ซ', '๐ฅฑ', '๐ค', '๐ก', '๐ ', '๐คฌ', 26 '๐', '๐ฟ', '๐', 'โ ๏ธ', '๐ฉ', '๐คก', '๐น', '๐บ', '๐ป', '๐ฝ', 27 '๐ค', '๐บ', '๐ธ', '๐น', '๐ป', '๐ผ', '๐ฝ', '๐', '๐ฟ', '๐พ', 28 '๐', '๐', '๐', '๐', '๐ค', '๐๏ธ', 'โ', '๐', '๐ซฑ', '๐ซฒ', 29 '๐ซณ', '๐ซด', '๐', '๐ค', '๐ค', 'โ๏ธ', '๐ค', '๐ซฐ', '๐ค', '๐ค', 30 '๐ค', '๐', '๐', '๐', '๐', '๐', 'โ๏ธ', '๐ซต', '๐', '๐', 31 'โ', '๐', '๐ค', '๐ค', '๐', '๐', '๐ซถ', '๐', '๐คฒ', '๐ค', 32 '๐', 'โ๏ธ', '๐ช', '๐ฆพ', '๐ง ', '๐', '๐๏ธ', '๐ ', '๐', '๐ซฆ', 33 ], 34 }, 35 { 36 id: 'nature', 37 label: 'Animals & Nature', 38 icon: '๐ถ', 39 emojis: [ 40 '๐ถ', '๐ฑ', '๐ญ', '๐น', '๐ฐ', '๐ฆ', '๐ป', '๐ผ', '๐ปโโ๏ธ', '๐จ', 41 '๐ฏ', '๐ฆ', '๐ฎ', '๐ท', '๐ธ', '๐ต', '๐', '๐ง', '๐ฆ', '๐ค', 42 '๐ฆ', '๐ฆ ', '๐ฆ', '๐ฆ', '๐บ', '๐', '๐ด', '๐ฆ', '๐', '๐ชฑ', 43 '๐', '๐ฆ', '๐', '๐', '๐', '๐ชฒ', '๐ชณ', '๐ท๏ธ', '๐ฆ', '๐ข', 44 '๐', '๐ฆ', '๐', '๐ฆ', '๐ฆ', '๐ฆ', '๐ฆ', '๐ก', '๐ ', '๐', 45 '๐ฌ', '๐ณ', '๐', '๐ฆ', '๐ชธ', '๐', '๐ ', '๐', '๐ฆ', '๐ฆ', 46 '๐', '๐ฆ', '๐ฆ', '๐ช', '๐ซ', '๐ฆ', '๐ฆ', '๐ฆฌ', '๐', '๐', 47 '๐', '๐', '๐', '๐', '๐', '๐ฆ', '๐', '๐ฆ', '๐', '๐ฉ', 48 '๐ต', '๐', '๐ฒ', '๐ณ', '๐ด', '๐ชต', '๐ฑ', '๐ฟ', 'โ๏ธ', '๐', 49 '๐', '๐', '๐', '๐ชน', '๐ชบ', '๐บ', '๐ป', '๐น', '๐ฅ', '๐ท', 50 '๐ผ', '๐ธ', '๐', '๐', '๐ฐ', '๐', '๐', '๐', 'โญ', '๐', 51 '๐ซ', 'โจ', 'โก', 'โ๏ธ', '๐ค๏ธ', '๐', 'โ๏ธ', '๐ง๏ธ', 'โ๏ธ', '๐ฅ', 52 ], 53 }, 54 { 55 id: 'food', 56 label: 'Food & Drink', 57 icon: '๐', 58 emojis: [ 59 '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐ซ', '๐', 60 '๐', '๐', '๐ฅญ', '๐', '๐ฅฅ', '๐ฅ', '๐ ', '๐ฅ', '๐', '๐ฅฆ', 61 '๐ฅฌ', '๐ฅ', '๐ถ๏ธ', '๐ซ', '๐ฝ', '๐ฅ', '๐ง', '๐ง ', '๐ฅ', '๐ ', 62 '๐ฅ', '๐', '๐ฅ', '๐ฅจ', '๐ง', '๐ฅ', '๐ณ', '๐ฅ', '๐ง', '๐ฅ', 63 '๐ฅฉ', '๐', '๐', '๐ญ', '๐', '๐', '๐', '๐ซ', '๐ฅช', '๐ฅ', 64 '๐ง', '๐ฎ', '๐ฏ', '๐ซ', '๐ฅ', '๐', '๐', '๐ฒ', '๐', '๐ฃ', 65 '๐ฑ', '๐ฅ', '๐ค', '๐', '๐', '๐', '๐ฅ', '๐ฅ ', '๐ฅฎ', '๐ก', 66 '๐ง', '๐จ', '๐ฆ', '๐ฅง', '๐ง', '๐ฐ', '๐', '๐ฎ', '๐ญ', '๐ฌ', 67 '๐ซ', '๐ฟ', '๐ง', '๐ฅค', 'โ', '๐ต', '๐ง', '๐ง', '๐ถ', '๐บ', 68 '๐ป', '๐ฅ', '๐ท', '๐ธ', '๐น', '๐พ', '๐ง', '๐ฅ', '๐ด', '๐ฅข', 69 ], 70 }, 71 { 72 id: 'activity', 73 label: 'Activities', 74 icon: 'โฝ', 75 emojis: [ 76 'โฝ', '๐', '๐', 'โพ', '๐ฅ', '๐พ', '๐', '๐', '๐ฅ', '๐ฑ', 77 '๐', '๐ธ', '๐', '๐ฅ', '๐ฅ', '๐ฅ ', 'โณ', 'โธ๏ธ', '๐ฃ', '๐คฟ', 78 '๐ฟ', '๐ท', '๐ฅ', '๐ฏ', '๐ช', '๐ช', '๐ฎ', '๐น๏ธ', '๐ฐ', '๐งฉ', 79 'โ๏ธ', '๐ฒ', '๐ญ', '๐จ', '๐ฌ', '๐ค', '๐ง', '๐ผ', '๐น', '๐ฅ', 80 '๐ท', '๐บ', '๐ช', '๐ธ', '๐ป', '๐ช', '๐ซ', '๐๏ธ', '๐', '๐ฅ', 81 '๐ฅ', '๐ฅ', '๐ ', '๐๏ธ', '๐ต๏ธ', '๐๏ธ', '๐', '๐', '๐', '๐', 82 ], 83 }, 84 { 85 id: 'travel', 86 label: 'Travel & Places', 87 icon: 'โ๏ธ', 88 emojis: [ 89 '๐', '๐', '๐', '๐', '๐', '๐๏ธ', '๐', '๐', '๐', '๐', 90 '๐ป', '๐', '๐', '๐', '๐๏ธ', '๐ต', '๐ฒ', '๐ด', '๐บ', '๐', 91 '๐', '๐', '๐', 'โ๏ธ', '๐', '๐ธ', '๐', '๐ถ', 'โต', '๐ข', 92 '๐ ', '๐ก', '๐๏ธ', '๐ข', '๐ฃ', '๐ฅ', '๐ฆ', '๐ช', '๐ซ', '๐ฉ', 93 '๐', '๐๏ธ', 'โช', '๐', '๐', '๐', 'โฉ๏ธ', '๐ฐ', '๐ฏ', '๐ผ', 94 '๐ฝ', '๐ฟ', '๐๏ธ', '๐ก', '๐ข', '๐ ', 'โฒ', 'โฑ๏ธ', '๐๏ธ', '๐๏ธ', 95 '๐๏ธ', '๐ป', '๐', '๐๏ธ', '๐ค๏ธ', '๐ฃ๏ธ', '๐ ', '๐', '๐', '๐', 96 ], 97 }, 98 { 99 id: 'objects', 100 label: 'Objects', 101 icon: '๐ก', 102 emojis: [ 103 'โ', '๐ฑ', '๐ป', 'โจ๏ธ', '๐ฅ๏ธ', '๐จ๏ธ', '๐ฑ๏ธ', '๐ฒ๏ธ', '๐ฝ', '๐พ', 104 '๐ฟ', '๐', '๐ฅ', '๐ท', '๐ธ', '๐น', '๐ผ', '๐', '๐', '๐ฏ๏ธ', 105 '๐ก', '๐ฆ', '๐ฎ', '๐ช', '๐', '๐', '๐', '๐', '๐', '๐', 106 '๐', '๐', '๐', '๐', '๐', '๐', '๐ฐ', '๐', '๐', '๐ฐ', 107 '๐ช', '๐ด', '๐ต', '๐ถ', '๐ท', '๐ธ', '๐ณ', 'โ๏ธ', '๐ง', '๐จ', 108 '๐ฉ', '๐ค', '๐ฅ', '๐ฆ', '๐ซ', '๐ช', '๐ฌ', '๐ญ', '๐ฎ', '๐ณ๏ธ', 109 'โ๏ธ', 'โ๏ธ', '๐๏ธ', '๐๏ธ', '๐๏ธ', '๐๏ธ', '๐', '๐', '๐', '๐๏ธ', 110 '๐ ', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', '๐', 111 '๐', '๐', '๐๏ธ', '๐จ', '๐ช', 'โ๏ธ', 'โ๏ธ', '๐ ๏ธ', '๐ก๏ธ', 'โ๏ธ', 112 '๐ง', '๐ช', '๐ฉ', 'โ๏ธ', '๐๏ธ', 'โ๏ธ', '๐ฆฏ', '๐', 'โ๏ธ', '๐ช', 113 ], 114 }, 115 { 116 id: 'symbols', 117 label: 'Symbols', 118 icon: 'โค๏ธ', 119 emojis: [ 120 'โค๏ธ', '๐งก', '๐', '๐', '๐', '๐', '๐ค', '๐ค', '๐ค', '๐', 121 'โค๏ธโ๐ฅ', 'โค๏ธโ๐ฉน', 'โฃ๏ธ', '๐', '๐', '๐', '๐', '๐', '๐', '๐', 122 '๐', 'โฎ๏ธ', 'โ๏ธ', 'โช๏ธ', '๐๏ธ', 'โธ๏ธ', 'โก๏ธ', '๐ฏ', '๐', 'โฏ๏ธ', 123 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 124 'โ', 'โ', 'โ', '๐', '๐', '๐', 'โถ๏ธ', 'โฉ', 'โญ๏ธ', 'โฏ๏ธ', 125 'โ๏ธ', 'โช', 'โฎ๏ธ', '๐ผ', 'โซ', '๐ฝ', 'โฌ', 'โธ๏ธ', 'โน๏ธ', 'โบ๏ธ', 126 'โ๏ธ', '๐ฆ', '๐ ', '๐', '๐ถ', '๐', '๐ณ', '๐ด', 'โ๏ธ', 'โ๏ธ', 127 'โง๏ธ', 'โ๏ธ', 'โ', 'โ', 'โ', '๐ฐ', 'โพ๏ธ', 'โผ๏ธ', 'โ๏ธ', 'โ', 128 'โ', 'โ', 'โ', 'ใฐ๏ธ', '๐ฑ', '๐ฒ', 'โ๏ธ', 'โป๏ธ', 'โ๏ธ', '๐ฑ', 129 'โ๏ธ', 'โ๏ธ', 'โ ', 'โ', 'โ', 'โฐ', 'โฟ', 'ใฝ๏ธ', 'โณ๏ธ', 'โด๏ธ', 130 'โ๏ธ', 'ยฉ๏ธ', 'ยฎ๏ธ', 'โข๏ธ', '#๏ธโฃ', '*๏ธโฃ', '0๏ธโฃ', '1๏ธโฃ', '2๏ธโฃ', '3๏ธโฃ', 131 '๐ด', '๐ ', '๐ก', '๐ข', '๐ต', '๐ฃ', 'โซ', 'โช', '๐ค', '๐ถ', 132 '๐ท', '๐ธ', '๐น', '๐บ', '๐ป', '๐ ', '๐', '๐ณ', '๐ฒ', '๐', 133 '๐ฉ', '๐', '๐ด', '๐ณ๏ธ', '๐ณ๏ธโ๐', '๐ณ๏ธโโง๏ธ', '๐ดโโ ๏ธ', '๐บ๐ธ', '๐ฌ๐ง', '๐ฏ๐ต', 134 ], 135 }, 136 ] 137 138 interface Props { 139 onSelect: (emoji: string) => void 140 onClose: () => void 141 } 142 143 export function ReactionPicker({ onSelect, onClose }: Props) { 144 const ref = useRef<HTMLDivElement>(null) 145 const searchRef = useRef<HTMLInputElement>(null) 146 const [search, setSearch] = useState('') 147 const [activeCategory, setActiveCategory] = useState('frequent') 148 149 useEffect(() => { 150 const handler = (e: MouseEvent) => { 151 if (ref.current && !ref.current.contains(e.target as Node)) { 152 onClose() 153 } 154 } 155 document.addEventListener('mousedown', handler) 156 return () => document.removeEventListener('mousedown', handler) 157 }, [onClose]) 158 159 // Auto-focus search on open 160 useEffect(() => { 161 setTimeout(() => searchRef.current?.focus(), 50) 162 }, []) 163 164 const filteredEmojis = useMemo(() => { 165 if (!search.trim()) return null 166 const q = search.toLowerCase() 167 const directEmojiMatches: string[] = [] 168 const seen = new Set<string>() 169 for (const cat of CATEGORIES) { 170 if (cat.id === 'frequent') continue 171 for (const emoji of cat.emojis) { 172 if (seen.has(emoji)) continue 173 seen.add(emoji) 174 if (emoji.includes(search.trim())) directEmojiMatches.push(emoji) 175 } 176 } 177 if (directEmojiMatches.length > 0) return directEmojiMatches 178 179 // This lightweight picker only understands category labels, not emoji names. 180 const matchingCats = CATEGORIES.filter( 181 (c) => c.id !== 'frequent' && c.label.toLowerCase().includes(q) 182 ) 183 const catResults: string[] = [] 184 const catSeen = new Set<string>() 185 for (const cat of matchingCats) { 186 for (const emoji of cat.emojis) { 187 if (!catSeen.has(emoji)) { 188 catSeen.add(emoji) 189 catResults.push(emoji) 190 } 191 } 192 } 193 return catResults 194 }, [search]) 195 196 return ( 197 <div 198 ref={ref} 199 className="absolute right-0 bottom-8 z-50 bg-[#13131e] border border-white/[0.1] rounded-[12px] shadow-[0_8px_40px_rgba(0,0,0,0.6)] w-[320px] flex flex-col overflow-hidden" 200 style={{ animation: 'msg-in 0.15s ease-out both' }} 201 > 202 {/* Search */} 203 <div className="px-3 pt-3 pb-2"> 204 <input 205 ref={searchRef} 206 type="text" 207 value={search} 208 onChange={(e) => setSearch(e.target.value)} 209 placeholder="Filter by category or paste emoji..." 210 className="w-full px-2.5 py-1.5 rounded-[8px] bg-white/[0.06] border border-white/[0.08] text-[12px] text-text placeholder:text-text-3 focus:outline-none focus:border-accent-bright/40" 211 /> 212 {search.trim() && ( 213 <p className="mt-1 px-0.5 text-[10px] text-text-3/55"> 214 This picker filters category labels rather than emoji names. 215 </p> 216 )} 217 </div> 218 219 {/* Category tabs */} 220 {!search.trim() && ( 221 <div className="flex px-2 gap-0.5 pb-1"> 222 {CATEGORIES.map((cat) => ( 223 <button 224 key={cat.id} 225 onClick={() => setActiveCategory(cat.id)} 226 title={cat.label} 227 className={`flex-1 py-1 flex items-center justify-center rounded-[6px] text-[14px] cursor-pointer transition-all ${ 228 activeCategory === cat.id ? 'bg-white/[0.08]' : 'hover:bg-white/[0.04]' 229 }`} 230 > 231 {cat.icon} 232 </button> 233 ))} 234 </div> 235 )} 236 237 {/* Emoji grid */} 238 <div className="px-2 pb-2 max-h-[220px] overflow-y-auto"> 239 {search.trim() ? ( 240 filteredEmojis && filteredEmojis.length > 0 ? ( 241 <div className="grid grid-cols-8 gap-0.5"> 242 {filteredEmojis.map((emoji, i) => ( 243 <button 244 key={`${emoji}-${i}`} 245 onClick={() => onSelect(emoji)} 246 className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] hover:bg-white/[0.08] transition-all cursor-pointer text-[18px]" 247 > 248 {emoji} 249 </button> 250 ))} 251 </div> 252 ) : ( 253 <div className="px-2 py-6 text-center text-[11px] text-text-3/60"> 254 No category matches. Try terms like <span className="text-text-3">food</span>, <span className="text-text-3">travel</span>, or paste an emoji. 255 </div> 256 ) 257 ) : ( 258 CATEGORIES.filter((c) => c.id === activeCategory).map((cat) => ( 259 <div key={cat.id}> 260 <div className="text-[10px] font-600 text-text-3 uppercase tracking-wider px-1 py-1.5">{cat.label}</div> 261 <div className="grid grid-cols-8 gap-0.5"> 262 {cat.emojis.map((emoji, i) => ( 263 <button 264 key={`${emoji}-${i}`} 265 onClick={() => onSelect(emoji)} 266 className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] hover:bg-white/[0.08] transition-all cursor-pointer text-[18px]" 267 > 268 {emoji} 269 </button> 270 ))} 271 </div> 272 </div> 273 )) 274 )} 275 </div> 276 </div> 277 ) 278 }