view.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. package chat
  2. import (
  3. "fmt"
  4. "log/slog"
  5. "sync"
  6. "time"
  7. "github.com/ayn2op/discordo/internal/config"
  8. "github.com/ayn2op/discordo/internal/keyring"
  9. "github.com/ayn2op/discordo/internal/ui"
  10. "github.com/ayn2op/tview"
  11. "github.com/diamondburned/arikawa/v3/discord"
  12. "github.com/diamondburned/ningen/v3"
  13. "github.com/diamondburned/ningen/v3/states/read"
  14. "github.com/gdamore/tcell/v3"
  15. )
  16. const typingDuration = 10 * time.Second
  17. const (
  18. flexPageName = "flex"
  19. mentionsListPageName = "mentionsList"
  20. attachmentsListPageName = "attachmentsList"
  21. confirmModalPageName = "confirmModal"
  22. )
  23. type View struct {
  24. *tview.Pages
  25. mainFlex *tview.Flex
  26. rightFlex *tview.Flex
  27. guildsTree *guildsTree
  28. messagesList *messagesList
  29. messageInput *messageInput
  30. selectedChannel *discord.Channel
  31. selectedChannelMu sync.RWMutex
  32. typersMu sync.RWMutex
  33. typers map[discord.UserID]*time.Timer
  34. app *tview.Application
  35. cfg *config.Config
  36. state *ningen.State
  37. onLogout func()
  38. }
  39. func NewView(app *tview.Application, cfg *config.Config, onLogout func()) *View {
  40. v := &View{
  41. Pages: tview.NewPages(),
  42. mainFlex: tview.NewFlex(),
  43. rightFlex: tview.NewFlex(),
  44. typers: make(map[discord.UserID]*time.Timer),
  45. app: app,
  46. cfg: cfg,
  47. onLogout: onLogout,
  48. }
  49. v.guildsTree = newGuildsTree(cfg, v)
  50. v.messagesList = newMessagesList(cfg, v)
  51. v.messageInput = newMessageInput(cfg, v)
  52. v.SetInputCapture(v.onInputCapture)
  53. v.buildLayout()
  54. return v
  55. }
  56. func (v *View) SelectedChannel() *discord.Channel {
  57. v.selectedChannelMu.RLock()
  58. defer v.selectedChannelMu.RUnlock()
  59. return v.selectedChannel
  60. }
  61. func (v *View) SetSelectedChannel(channel *discord.Channel) {
  62. v.selectedChannelMu.Lock()
  63. v.selectedChannel = channel
  64. v.selectedChannelMu.Unlock()
  65. }
  66. func (v *View) buildLayout() {
  67. v.Clear()
  68. v.rightFlex.Clear()
  69. v.mainFlex.Clear()
  70. v.rightFlex.
  71. SetDirection(tview.FlexRow).
  72. AddItem(v.messagesList, 0, 1, false).
  73. AddItem(v.messageInput, 3, 1, false)
  74. // The guilds tree is always focused first at start-up.
  75. v.mainFlex.
  76. AddItem(v.guildsTree, 0, 1, true).
  77. AddItem(v.rightFlex, 0, 4, false)
  78. v.AddAndSwitchToPage(flexPageName, v.mainFlex, true)
  79. }
  80. func (v *View) toggleGuildsTree() {
  81. // The guilds tree is visible if the number of items is two.
  82. if v.mainFlex.GetItemCount() == 2 {
  83. v.mainFlex.RemoveItem(v.guildsTree)
  84. if v.guildsTree.HasFocus() {
  85. v.app.SetFocus(v.mainFlex)
  86. }
  87. } else {
  88. v.buildLayout()
  89. v.app.SetFocus(v.guildsTree)
  90. }
  91. }
  92. func (v *View) focusGuildsTree() bool {
  93. // The guilds tree is not hidden if the number of items is two.
  94. if v.mainFlex.GetItemCount() == 2 {
  95. v.app.SetFocus(v.guildsTree)
  96. return true
  97. }
  98. return false
  99. }
  100. func (v *View) focusMessageInput() bool {
  101. if !v.messageInput.GetDisabled() {
  102. v.app.SetFocus(v.messageInput)
  103. return true
  104. }
  105. return false
  106. }
  107. func (v *View) focusPrevious() {
  108. switch v.app.GetFocus() {
  109. case v.guildsTree:
  110. v.focusMessageInput()
  111. case v.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is).
  112. if ok := v.focusGuildsTree(); !ok {
  113. v.app.SetFocus(v.messageInput)
  114. }
  115. case v.messageInput:
  116. v.app.SetFocus(v.messagesList)
  117. }
  118. }
  119. func (v *View) focusNext() {
  120. switch v.app.GetFocus() {
  121. case v.guildsTree:
  122. v.app.SetFocus(v.messagesList)
  123. case v.messagesList:
  124. v.focusMessageInput()
  125. case v.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is).
  126. if ok := v.focusGuildsTree(); !ok {
  127. v.app.SetFocus(v.messagesList)
  128. }
  129. }
  130. }
  131. func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
  132. switch event.Name() {
  133. case v.cfg.Keys.FocusGuildsTree:
  134. v.messageInput.removeMentionsList()
  135. v.focusGuildsTree()
  136. return nil
  137. case v.cfg.Keys.FocusMessagesList:
  138. v.messageInput.removeMentionsList()
  139. v.app.SetFocus(v.messagesList)
  140. return nil
  141. case v.cfg.Keys.FocusMessageInput:
  142. v.focusMessageInput()
  143. return nil
  144. case v.cfg.Keys.FocusPrevious:
  145. v.focusPrevious()
  146. return nil
  147. case v.cfg.Keys.FocusNext:
  148. v.focusNext()
  149. return nil
  150. case v.cfg.Keys.Logout:
  151. if v.onLogout != nil {
  152. v.onLogout()
  153. }
  154. if err := keyring.DeleteToken(); err != nil {
  155. slog.Error("failed to delete token from keyring", "err", err)
  156. return nil
  157. }
  158. return nil
  159. case v.cfg.Keys.ToggleGuildsTree:
  160. v.toggleGuildsTree()
  161. return nil
  162. }
  163. return event
  164. }
  165. func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
  166. previousFocus := v.app.GetFocus()
  167. modal := tview.NewModal().
  168. SetText(prompt).
  169. AddButtons(buttons).
  170. SetDoneFunc(func(_ int, buttonLabel string) {
  171. v.RemovePage(confirmModalPageName).SwitchToPage(flexPageName)
  172. v.app.SetFocus(previousFocus)
  173. if onDone != nil {
  174. onDone(buttonLabel)
  175. }
  176. })
  177. v.
  178. AddAndSwitchToPage(confirmModalPageName, ui.Centered(modal, 0, 0), true).
  179. ShowPage(flexPageName)
  180. }
  181. func (v *View) onReadUpdate(event *read.UpdateEvent) {
  182. var guildNode *tview.TreeNode
  183. v.guildsTree.
  184. GetRoot().
  185. Walk(func(node, parent *tview.TreeNode) bool {
  186. switch node.GetReference() {
  187. case event.GuildID:
  188. node.SetTextStyle(v.guildsTree.getGuildNodeStyle(event.GuildID))
  189. guildNode = node
  190. return false
  191. case event.ChannelID:
  192. // private channel
  193. if !event.GuildID.IsValid() {
  194. style := v.guildsTree.getChannelNodeStyle(event.ChannelID)
  195. node.SetTextStyle(style)
  196. return false
  197. }
  198. }
  199. return true
  200. })
  201. if guildNode != nil {
  202. guildNode.Walk(func(node, parent *tview.TreeNode) bool {
  203. if node.GetReference() == event.ChannelID {
  204. node.SetTextStyle(v.guildsTree.getChannelNodeStyle(event.ChannelID))
  205. return false
  206. }
  207. return true
  208. })
  209. }
  210. v.app.Draw()
  211. }
  212. func (v *View) clearTypers() {
  213. v.typersMu.Lock()
  214. for _, timer := range v.typers {
  215. timer.Stop()
  216. }
  217. clear(v.typers)
  218. v.typersMu.Unlock()
  219. v.updateFooter()
  220. }
  221. func (v *View) addTyper(userID discord.UserID) {
  222. v.typersMu.Lock()
  223. typer, ok := v.typers[userID]
  224. if ok {
  225. typer.Reset(typingDuration)
  226. } else {
  227. v.typers[userID] = time.AfterFunc(typingDuration, func() {
  228. v.removeTyper(userID)
  229. })
  230. }
  231. v.typersMu.Unlock()
  232. v.updateFooter()
  233. }
  234. func (v *View) removeTyper(userID discord.UserID) {
  235. v.typersMu.Lock()
  236. if typer, ok := v.typers[userID]; ok {
  237. typer.Stop()
  238. delete(v.typers, userID)
  239. }
  240. v.typersMu.Unlock()
  241. v.updateFooter()
  242. }
  243. func (v *View) updateFooter() {
  244. selectedChannel := v.SelectedChannel()
  245. if selectedChannel == nil {
  246. return
  247. }
  248. guildID := selectedChannel.GuildID
  249. v.typersMu.RLock()
  250. defer v.typersMu.RUnlock()
  251. var footer string
  252. if len(v.typers) > 0 {
  253. var names []string
  254. for userID := range v.typers {
  255. var name string
  256. if guildID.IsValid() {
  257. member, err := v.state.Cabinet.Member(guildID, userID)
  258. if err != nil {
  259. slog.Error("failed to get member from state", "err", err, "guild_id", guildID, "user_id", userID)
  260. continue
  261. }
  262. if member.Nick != "" {
  263. name = member.Nick
  264. } else {
  265. name = member.User.DisplayOrUsername()
  266. }
  267. } else {
  268. for _, recipient := range selectedChannel.DMRecipients {
  269. if recipient.ID == userID {
  270. name = recipient.DisplayOrUsername()
  271. break
  272. }
  273. }
  274. }
  275. if name != "" {
  276. names = append(names, name)
  277. }
  278. }
  279. switch len(names) {
  280. case 1:
  281. footer = fmt.Sprintf("%s is typing...", names[0])
  282. case 2:
  283. footer = fmt.Sprintf("%s and %s are typing...", names[0], names[1])
  284. case 3:
  285. footer = fmt.Sprintf("%s, %s, and %s are typing...", names[0], names[1], names[2])
  286. default:
  287. footer = "Several people are typing..."
  288. }
  289. }
  290. go v.app.QueueUpdateDraw(func() { v.messagesList.SetFooter(footer) })
  291. }