emoji_picker.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. // emoji_picker.go implements the emoji picker overlay for adding reactions
  2. // to messages. It displays a searchable list of common Unicode emoji plus
  3. // guild-specific custom emoji, with a favorites section for quick access.
  4. package chat
  5. import (
  6. "fmt"
  7. "log/slog"
  8. "strings"
  9. "github.com/ayn2op/discordo/internal/config"
  10. "github.com/ayn2op/tview"
  11. "github.com/ayn2op/tview/help"
  12. "github.com/ayn2op/tview/keybind"
  13. "github.com/ayn2op/tview/picker"
  14. "github.com/diamondburned/arikawa/v3/discord"
  15. "github.com/gdamore/tcell/v3"
  16. )
  17. const emojiPickerLayerName = "emojiPicker"
  18. type emojiPicker struct {
  19. *picker.Model
  20. chatView *Model
  21. browseMode bool
  22. favorites *emojiFavorites
  23. // allItems is cached on picker open; only favorites change on toggle.
  24. allItems []picker.Item
  25. targetMessageID discord.MessageID
  26. targetChannelID discord.ChannelID
  27. }
  28. var _ help.KeyMap = (*emojiPicker)(nil)
  29. func newEmojiPicker(cfg *config.Config, chatView *Model) *emojiPicker {
  30. ep := &emojiPicker{
  31. Model: picker.NewModel(),
  32. chatView: chatView,
  33. favorites: loadEmojiFavorites(),
  34. }
  35. ConfigurePicker(ep.Model, cfg, "Emoji")
  36. return ep
  37. }
  38. // resetBrowse starts the emoji picker in browse (scroll) mode by default.
  39. func (ep *emojiPicker) resetBrowse() { ep.browseMode = true }
  40. var commonEmoji = []struct {
  41. emoji string
  42. names string
  43. }{
  44. {"👍", "thumbsup thumbs_up +1 like"},
  45. {"👎", "thumbsdown thumbs_down -1 dislike"},
  46. {"❤️", "heart love red_heart"},
  47. {"😂", "joy laughing tears"},
  48. {"😭", "sob crying"},
  49. {"😊", "blush smile happy"},
  50. {"😍", "heart_eyes love"},
  51. {"🤣", "rofl rolling"},
  52. {"🙂", "slightly_smiling_face"},
  53. {"😔", "pensive sad"},
  54. {"🔥", "fire lit hot"},
  55. {"✅", "white_check_mark check yes"},
  56. {"❌", "x cross no"},
  57. {"👀", "eyes look"},
  58. {"🎉", "tada party celebration"},
  59. {"💀", "skull dead"},
  60. {"🤔", "thinking hmm"},
  61. {"😐", "neutral_face"},
  62. {"🤷", "shrug"},
  63. {"👋", "wave hello hi"},
  64. {"🙏", "pray please thanks"},
  65. {"💯", "100 hundred perfect"},
  66. {"😎", "sunglasses cool"},
  67. {"🥳", "partying_face party"},
  68. {"😢", "cry sad"},
  69. {"😅", "sweat_smile nervous"},
  70. {"🤗", "hugs hugging"},
  71. {"😤", "triumph angry"},
  72. {"🥺", "pleading_face"},
  73. {"💪", "muscle strong flex"},
  74. {"✨", "sparkles stars"},
  75. {"💜", "purple_heart"},
  76. {"💙", "blue_heart"},
  77. {"💚", "green_heart"},
  78. {"🧡", "orange_heart"},
  79. {"💛", "yellow_heart"},
  80. {"🖤", "black_heart"},
  81. {"🤍", "white_heart"},
  82. {"⭐", "star"},
  83. {"🌟", "star2 glowing_star"},
  84. {"😮", "open_mouth surprised"},
  85. {"😱", "scream shocked"},
  86. {"😈", "smiling_imp devil"},
  87. {"🤡", "clown clown_face"},
  88. {"🫡", "salute saluting_face"},
  89. {"🫠", "melting_face"},
  90. {"💤", "zzz sleep"},
  91. {"🤝", "handshake"},
  92. {"🏳️", "white_flag surrender"},
  93. }
  94. // update loads all emoji items (cached) and rebuilds with favorites.
  95. func (ep *emojiPicker) update() {
  96. if ep.allItems == nil {
  97. ep.loadItems()
  98. }
  99. ep.rebuildWithFavorites()
  100. }
  101. // loadItems builds the static emoji list (common + guild). Called once per picker open.
  102. func (ep *emojiPicker) loadItems() {
  103. items := make(picker.Items, 0, len(commonEmoji)+50)
  104. for _, e := range commonEmoji {
  105. firstName := e.names
  106. if i := strings.IndexByte(e.names, ' '); i != -1 {
  107. firstName = e.names[:i]
  108. }
  109. items = append(items, picker.Item{
  110. Text: e.emoji + " " + firstName,
  111. FilterText: e.names,
  112. Reference: discord.APIEmoji(e.emoji),
  113. })
  114. }
  115. selectedChannel := ep.chatView.SelectedChannel()
  116. if selectedChannel != nil && selectedChannel.GuildID.IsValid() {
  117. emojis, err := ep.chatView.state.Cabinet.Emojis(selectedChannel.GuildID)
  118. if err == nil {
  119. for _, emoji := range emojis {
  120. if !emoji.Available {
  121. continue
  122. }
  123. items = append(items, picker.Item{
  124. Text: ":" + emoji.Name + ":",
  125. FilterText: emoji.Name,
  126. Reference: emoji.APIString(),
  127. })
  128. }
  129. }
  130. }
  131. ep.allItems = items
  132. }
  133. // rebuildWithFavorites prepends favorites to the cached item list.
  134. func (ep *emojiPicker) rebuildWithFavorites() {
  135. byKey := make(map[string]picker.Item, len(ep.allItems))
  136. for _, item := range ep.allItems {
  137. byKey[emojiItemKey(item)] = item
  138. }
  139. items := make(picker.Items, 0, maxFavoriteEmoji+len(ep.allItems))
  140. favSet := make(map[string]struct{})
  141. for _, key := range ep.favorites.list() {
  142. if item, ok := byKey[key]; ok {
  143. items = append(items, picker.Item{
  144. Text: "★ " + item.Text,
  145. FilterText: "favorite " + item.FilterText,
  146. Reference: item.Reference,
  147. })
  148. favSet[key] = struct{}{}
  149. }
  150. }
  151. for _, item := range ep.allItems {
  152. if _, isFav := favSet[emojiItemKey(item)]; !isFav {
  153. items = append(items, item)
  154. }
  155. }
  156. ep.Model.SetItems(items)
  157. }
  158. func emojiItemKey(item picker.Item) string {
  159. if ref, ok := item.Reference.(discord.APIEmoji); ok {
  160. return string(ref)
  161. }
  162. return ""
  163. }
  164. func (ep *emojiPicker) HandleEvent(event tview.Event) tview.Command {
  165. switch event := event.(type) {
  166. case *tview.KeyEvent:
  167. if cmd, handled := pickerBrowseHandleKey(
  168. event, &ep.browseMode, ep.Model,
  169. func() { ep.chatView.closeEmojiPicker() },
  170. ep.handleFavoriteKey,
  171. ); handled {
  172. return cmd
  173. }
  174. case *picker.SelectedEvent:
  175. apiEmoji, ok := event.Reference.(discord.APIEmoji)
  176. if !ok {
  177. return nil
  178. }
  179. channelID := ep.targetChannelID
  180. messageID := ep.targetMessageID
  181. // Check if user already reacted with this emoji.
  182. alreadyReacted := false
  183. if msg, err := ep.chatView.state.Cabinet.Message(channelID, messageID); err == nil {
  184. for _, r := range msg.Reactions {
  185. if r.Me && r.Emoji.APIString() == apiEmoji {
  186. alreadyReacted = true
  187. break
  188. }
  189. }
  190. }
  191. ep.chatView.closeEmojiPicker()
  192. return func() tview.Event {
  193. if alreadyReacted {
  194. if err := ep.chatView.state.Unreact(channelID, messageID, apiEmoji); err != nil {
  195. slog.Error("failed to remove reaction", "err", err, "emoji", fmt.Sprint(apiEmoji))
  196. }
  197. } else {
  198. if err := ep.chatView.state.React(channelID, messageID, apiEmoji); err != nil {
  199. slog.Error("failed to add reaction", "err", err, "emoji", fmt.Sprint(apiEmoji))
  200. }
  201. }
  202. if cmd := ep.chatView.focusMessagesList(); cmd != nil {
  203. return cmd()
  204. }
  205. return nil
  206. }
  207. case *picker.CancelEvent:
  208. ep.chatView.closeEmojiPicker()
  209. return nil
  210. }
  211. return ep.Model.HandleEvent(event)
  212. }
  213. func (ep *emojiPicker) handleFavoriteKey(event *tview.KeyEvent) (tview.Command, bool) {
  214. if event.Key() != tcell.KeyRune || event.Str() != "f" {
  215. return nil, false
  216. }
  217. // Trigger a select to get the highlighted item's reference without
  218. // dispatching the event through the normal loop.
  219. cmd := ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone))
  220. if cmd == nil {
  221. return nil, true
  222. }
  223. if sel, ok := cmd().(*picker.SelectedEvent); ok {
  224. key := emojiItemKey(sel.Item)
  225. if key != "" {
  226. ep.favorites.toggle(key)
  227. ep.update()
  228. }
  229. }
  230. return nil, true
  231. }
  232. func (ep *emojiPicker) ShortHelp() []keybind.Keybind {
  233. return pickerShortHelp(ep.chatView.cfg.Keybinds.Picker)
  234. }
  235. func (ep *emojiPicker) FullHelp() [][]keybind.Keybind {
  236. return pickerFullHelp(ep.chatView.cfg.Keybinds.Picker)
  237. }