ui.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. package main
  2. import (
  3. "sort"
  4. "strings"
  5. "github.com/atotto/clipboard"
  6. "github.com/ayntgl/discordgo"
  7. "github.com/gdamore/tcell/v2"
  8. "github.com/rivo/tview"
  9. )
  10. var (
  11. selectedChannel *discordgo.Channel
  12. selectedMessage int = -1
  13. )
  14. func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey {
  15. if hasKeybinding(conf.Keybindings.FocusChannelsTree, e.Name()) {
  16. app.SetFocus(channelsTree)
  17. return nil
  18. } else if hasKeybinding(conf.Keybindings.FocusMessagesView, e.Name()) {
  19. app.SetFocus(messagesView)
  20. return nil
  21. } else if hasKeybinding(conf.Keybindings.FocusMessageInputField, e.Name()) {
  22. app.SetFocus(messageInputField)
  23. return nil
  24. }
  25. return e
  26. }
  27. func newChannelsTree() *tview.TreeView {
  28. treeView := tview.NewTreeView()
  29. treeView.
  30. SetTopLevel(1).
  31. SetRoot(tview.NewTreeNode("")).
  32. SetTitle("Channels").
  33. SetTitleAlign(tview.AlignLeft).
  34. SetBorder(true).
  35. SetBorderPadding(0, 0, 1, 0)
  36. return treeView
  37. }
  38. func onChannelsTreeSelected(n *tview.TreeNode) {
  39. selectedChannel = nil
  40. selectedMessage = 0
  41. messagesView.
  42. Clear().
  43. SetTitle("")
  44. messageInputField.SetText("")
  45. // Unhighlight the already-highlighted regions.
  46. messagesView.Highlight()
  47. id := n.GetReference()
  48. switch n.GetLevel() {
  49. case 1: // Guilds or Direct Messages
  50. if len(n.GetChildren()) == 0 {
  51. // If the reference of the selected `*TreeNode` is `nil`, it is the direct messages `*TreeNode`.
  52. if id == nil {
  53. cs := session.State.PrivateChannels
  54. sort.Slice(cs, func(i, j int) bool {
  55. return cs[i].LastMessageID > cs[j].LastMessageID
  56. })
  57. for _, c := range cs {
  58. tag := "[::d]"
  59. if channelIsUnread(session.State, c) {
  60. tag = "[::b]"
  61. }
  62. cn := tview.NewTreeNode(tag + channelToString(c) + "[::-]").
  63. SetReference(c.ID).
  64. Collapse()
  65. n.AddChild(cn)
  66. }
  67. } else {
  68. g, err := session.State.Guild(id.(string))
  69. if err != nil {
  70. return
  71. }
  72. sort.Slice(g.Channels, func(i, j int) bool {
  73. return g.Channels[i].Position < g.Channels[j].Position
  74. })
  75. // Top-level channels
  76. createTopLevelChannelsNodes(channelsTree, session.State, n, g.Channels)
  77. // Category channels
  78. createCategoryChannelsNodes(channelsTree, session.State, n, g.Channels)
  79. // Second-level channels
  80. createSecondLevelChannelsNodes(channelsTree, session.State, g.Channels)
  81. }
  82. }
  83. n.SetExpanded(!n.IsExpanded())
  84. default: // Channels
  85. c, err := session.State.Channel(id.(string))
  86. if err != nil {
  87. return
  88. }
  89. selectedChannel = c
  90. app.SetFocus(messageInputField)
  91. switch c.Type {
  92. case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews:
  93. title := channelToString(c)
  94. if c.Topic != "" {
  95. title += " - " + c.Topic
  96. }
  97. messagesView.SetTitle(title)
  98. case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM:
  99. messagesView.SetTitle(channelToString(c))
  100. }
  101. if strings.HasPrefix(n.GetText(), "[::b]") {
  102. n.SetText("[::d]" + channelToString(c) + "[::-]")
  103. }
  104. go func() {
  105. ms, err := session.ChannelMessages(c.ID, conf.GetMessagesLimit, "", "", "")
  106. if err != nil {
  107. return
  108. }
  109. for i := len(ms) - 1; i >= 0; i-- {
  110. selectedChannel.Messages = append(selectedChannel.Messages, ms[i])
  111. messagesView.Write(buildMessage(ms[i]))
  112. }
  113. // Scroll to the end of the text after the messages have been written to the TextView.
  114. messagesView.ScrollToEnd()
  115. if len(ms) != 0 && channelIsUnread(session.State, c) {
  116. session.ChannelMessageAck(c.ID, c.LastMessageID, "")
  117. }
  118. }()
  119. }
  120. }
  121. // createTopLevelChannelsNodes builds and creates `*tview.TreeNode`s for top-level (channels that have an empty parent ID and of type GUILD_TEXT, GUILD_NEWS) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent.
  122. func createTopLevelChannelsNodes(treeView *tview.TreeView, s *discordgo.State, n *tview.TreeNode, cs []*discordgo.Channel) {
  123. for _, c := range cs {
  124. if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) &&
  125. (c.ParentID == "") {
  126. if !hasPermission(s, c.ID, discordgo.PermissionViewChannel) {
  127. continue
  128. }
  129. n.AddChild(createChannelNode(s, c))
  130. continue
  131. }
  132. }
  133. }
  134. // createCategoryChannelsNodes builds and creates `*tview.TreeNode`s for category (type: GUILD_CATEGORY) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent.
  135. func createCategoryChannelsNodes(treeView *tview.TreeView, s *discordgo.State, n *tview.TreeNode, cs []*discordgo.Channel) {
  136. CategoryLoop:
  137. for _, c := range cs {
  138. if c.Type == discordgo.ChannelTypeGuildCategory {
  139. if !hasPermission(s, c.ID, discordgo.PermissionViewChannel) {
  140. continue
  141. }
  142. for _, child := range cs {
  143. if child.ParentID == c.ID {
  144. n.AddChild(createChannelNode(s, c))
  145. continue CategoryLoop
  146. }
  147. }
  148. n.AddChild(createChannelNode(s, c))
  149. }
  150. }
  151. }
  152. // createSecondLevelChannelsNodes builds and creates `*tview.TreeNode`s for second-level (channels that have a non-empty parent ID and of type GUILD_TEXT, GUILD_NEWS) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent.
  153. func createSecondLevelChannelsNodes(treeView *tview.TreeView, s *discordgo.State, cs []*discordgo.Channel) {
  154. for _, c := range cs {
  155. if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) &&
  156. (c.ParentID != "") {
  157. if !hasPermission(s, c.ID, discordgo.PermissionViewChannel) {
  158. continue
  159. }
  160. pn := getTreeNodeByReference(treeView, c.ParentID)
  161. if pn != nil {
  162. pn.AddChild(createChannelNode(s, c))
  163. }
  164. }
  165. }
  166. }
  167. func newMessagesView() *tview.TextView {
  168. textView := tview.NewTextView()
  169. textView.
  170. SetRegions(true).
  171. SetDynamicColors(true).
  172. SetWordWrap(true).
  173. SetBorder(true).
  174. SetBorderPadding(0, 0, 1, 0).
  175. SetTitleAlign(tview.AlignLeft)
  176. return textView
  177. }
  178. func onMessagesViewInputCapture(e *tcell.EventKey) *tcell.EventKey {
  179. if selectedChannel == nil {
  180. return nil
  181. }
  182. ms := selectedChannel.Messages
  183. if len(ms) == 0 {
  184. return nil
  185. }
  186. if hasKeybinding(conf.Keybindings.SelectPreviousMessage, e.Name()) {
  187. if len(messagesView.GetHighlights()) == 0 {
  188. selectedMessage = len(ms) - 1
  189. } else {
  190. selectedMessage--
  191. if selectedMessage < 0 {
  192. selectedMessage = 0
  193. }
  194. }
  195. messagesView.
  196. Highlight(ms[selectedMessage].ID).
  197. ScrollToHighlight()
  198. return nil
  199. } else if hasKeybinding(conf.Keybindings.SelectNextMessage, e.Name()) {
  200. if len(messagesView.GetHighlights()) == 0 {
  201. selectedMessage = len(ms) - 1
  202. } else {
  203. selectedMessage++
  204. if selectedMessage >= len(ms) {
  205. selectedMessage = len(ms) - 1
  206. }
  207. }
  208. messagesView.
  209. Highlight(ms[selectedMessage].ID).
  210. ScrollToHighlight()
  211. return nil
  212. } else if hasKeybinding(conf.Keybindings.SelectFirstMessage, e.Name()) {
  213. selectedMessage = 0
  214. messagesView.
  215. Highlight(ms[selectedMessage].ID).
  216. ScrollToHighlight()
  217. return nil
  218. } else if hasKeybinding(conf.Keybindings.SelectLastMessage, e.Name()) {
  219. selectedMessage = len(ms) - 1
  220. messagesView.
  221. Highlight(ms[selectedMessage].ID).
  222. ScrollToHighlight()
  223. return nil
  224. } else if hasKeybinding(conf.Keybindings.SelectMessageReference, e.Name()) {
  225. hs := messagesView.GetHighlights()
  226. if len(hs) == 0 {
  227. return nil
  228. }
  229. _, m := findMessageByID(selectedChannel.Messages, hs[0])
  230. if m.ReferencedMessage != nil {
  231. selectedMessage, _ = findMessageByID(selectedChannel.Messages, m.ReferencedMessage.ID)
  232. messagesView.
  233. Highlight(m.ReferencedMessage.ID).
  234. ScrollToHighlight()
  235. }
  236. return nil
  237. } else if hasKeybinding(conf.Keybindings.ReplySelectedMessage, e.Name()) {
  238. hs := messagesView.GetHighlights()
  239. if len(hs) == 0 {
  240. return nil
  241. }
  242. _, m := findMessageByID(selectedChannel.Messages, hs[0])
  243. messageInputField.SetTitle("Replying to " + m.Author.String())
  244. app.SetFocus(messageInputField)
  245. return nil
  246. } else if hasKeybinding(conf.Keybindings.MentionReplySelectedMessage, e.Name()) {
  247. hs := messagesView.GetHighlights()
  248. if len(hs) == 0 {
  249. return nil
  250. }
  251. _, m := findMessageByID(selectedChannel.Messages, hs[0])
  252. messageInputField.SetTitle("[@] Replying to " + m.Author.String())
  253. app.SetFocus(messageInputField)
  254. return nil
  255. } else if hasKeybinding(conf.Keybindings.CopySelectedMessage, e.Name()) {
  256. hs := messagesView.GetHighlights()
  257. if len(hs) == 0 {
  258. return nil
  259. }
  260. _, m := findMessageByID(selectedChannel.Messages, hs[0])
  261. err := clipboard.WriteAll(m.Content)
  262. if err != nil {
  263. return nil
  264. }
  265. return nil
  266. }
  267. return e
  268. }
  269. func newMessageInputField() *tview.InputField {
  270. inputField := tview.NewInputField()
  271. inputField.
  272. SetPlaceholder("Message...").
  273. SetPlaceholderTextColor(tcell.ColorWhite).
  274. SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
  275. SetBorder(true).
  276. SetBorderPadding(0, 0, 1, 0).
  277. SetTitleAlign(tview.AlignLeft)
  278. return inputField
  279. }
  280. func onMessageInputFieldInputCapture(e *tcell.EventKey) *tcell.EventKey {
  281. switch e.Key() {
  282. case tcell.KeyEnter:
  283. if selectedChannel == nil {
  284. return nil
  285. }
  286. t := strings.TrimSpace(messageInputField.GetText())
  287. if t == "" {
  288. return nil
  289. }
  290. if len(messagesView.GetHighlights()) != 0 {
  291. m := selectedChannel.Messages[selectedMessage]
  292. d := &discordgo.MessageSend{
  293. Content: t,
  294. Reference: m.Reference(),
  295. AllowedMentions: &discordgo.MessageAllowedMentions{RepliedUser: false},
  296. }
  297. if strings.HasPrefix(messageInputField.GetTitle(), "[@]") {
  298. d.AllowedMentions.RepliedUser = true
  299. } else {
  300. d.AllowedMentions.RepliedUser = false
  301. }
  302. go session.ChannelMessageSendComplex(m.ChannelID, d)
  303. selectedMessage = -1
  304. messagesView.Highlight()
  305. messageInputField.SetTitle("")
  306. } else {
  307. go session.ChannelMessageSend(selectedChannel.ID, t)
  308. }
  309. messageInputField.SetText("")
  310. return nil
  311. case tcell.KeyCtrlV:
  312. text, _ := clipboard.ReadAll()
  313. text = messageInputField.GetText() + text
  314. messageInputField.SetText(text)
  315. return nil
  316. case tcell.KeyEscape:
  317. messageInputField.SetText("")
  318. messageInputField.SetTitle("")
  319. selectedMessage = -1
  320. messagesView.Highlight()
  321. return nil
  322. }
  323. return e
  324. }
  325. func newLoginForm(onLoginFormLoginButtonSelected func(), mfa bool) *tview.Form {
  326. w := tview.NewForm()
  327. w.
  328. AddButton("Login", onLoginFormLoginButtonSelected).
  329. SetButtonsAlign(tview.AlignCenter).
  330. SetBorder(true).
  331. SetBorderPadding(0, 0, 1, 0)
  332. if mfa {
  333. w.AddPasswordField("Code", "", 0, 0, nil)
  334. } else {
  335. w.
  336. AddInputField("Email", "", 0, nil, nil).
  337. AddPasswordField("Password", "", 0, 0, nil)
  338. }
  339. return w
  340. }
  341. // getTreeNodeByReference walks the root `*TreeNode` of the given `*TreeView` *treeView* and returns the TreeNode whose reference is equal to the given reference *r*. If the `*TreeNode` is not found, `nil` is returned instead.
  342. func getTreeNodeByReference(treeView *tview.TreeView, r interface{}) (mn *tview.TreeNode) {
  343. treeView.GetRoot().Walk(func(n, _ *tview.TreeNode) bool {
  344. if n.GetReference() == r {
  345. mn = n
  346. return false
  347. }
  348. return true
  349. })
  350. return
  351. }
  352. // createChannelNode builds (encorporates unread channels in bold tag, otherwise dim, etc.) and returns a node according to the type of the given channel *c*.
  353. func createChannelNode(s *discordgo.State, c *discordgo.Channel) *tview.TreeNode {
  354. var cn *tview.TreeNode
  355. switch c.Type {
  356. case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews:
  357. tag := "[::d]"
  358. if channelIsUnread(s, c) {
  359. tag = "[::b]"
  360. }
  361. cn = tview.NewTreeNode(tag + channelToString(c) + "[::-]").
  362. SetReference(c.ID)
  363. case discordgo.ChannelTypeGuildCategory:
  364. cn = tview.NewTreeNode(c.Name).
  365. SetReference(c.ID)
  366. }
  367. return cn
  368. }
  369. // hasPermission returns a boolean that indicates whether the client user has the given permission *p* in the given channel ID *cID*.
  370. func hasPermission(s *discordgo.State, cID string, p int64) bool {
  371. perm, err := s.UserChannelPermissions(s.User.ID, cID)
  372. if err != nil {
  373. return false
  374. }
  375. return perm&p == p
  376. }
  377. func hasKeybinding(sl []string, s string) bool {
  378. for _, str := range sl {
  379. if str == s {
  380. return true
  381. }
  382. }
  383. return false
  384. }