Forráskód Böngészése

feat(ui/chat): add typing indicators (#702)

Ayyan 3 hónapja
szülő
commit
dc40dc1f14

+ 8 - 2
internal/config/config.go

@@ -31,6 +31,11 @@ type (
 		OnlyOnPing bool `toml:"only_on_ping"`
 	}
 
+	TypingIndicator struct {
+		Send    bool `toml:"send"`
+		Receive bool `toml:"receive"`
+	}
+
 	Config struct {
 		AutoFocus bool   `toml:"auto_focus"`
 		Mouse     bool   `toml:"mouse"`
@@ -46,8 +51,9 @@ type (
 		AutocompleteLimit uint8 `toml:"autocomplete_limit"`
 		MessagesLimit     uint8 `toml:"messages_limit"`
 
-		Timestamps    Timestamps    `toml:"timestamps"`
-		Notifications Notifications `toml:"notifications"`
+		Timestamps      Timestamps      `toml:"timestamps"`
+		Notifications   Notifications   `toml:"notifications"`
+		TypingIndicator TypingIndicator `toml:"typing_indicator"`
 
 		Keys  Keys  `toml:"keys"`
 		Theme Theme `toml:"theme"`

+ 6 - 0
internal/config/config.toml

@@ -35,6 +35,12 @@ duration = 0
 enabled = true
 only_on_ping = true
 
+[typing_indicator]
+# Whether to send typing status or not.
+send = true
+# Whether to receive typing status or not.
+receive = true
+
 # Global shortcuts
 # Esc: Reset message selection or close the channel selection popup.
 [keys]

+ 3 - 1
internal/ui/chat/guilds_tree.go

@@ -18,7 +18,7 @@ import (
 
 type guildsTree struct {
 	*tview.TreeView
-	cfg  *config.Config
+	cfg      *config.Config
 	chatView *View
 }
 
@@ -213,6 +213,8 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 		}
 
 		gt.chatView.SetSelectedChannel(channel)
+		gt.chatView.clearTypers()
+		gt.chatView.messageInput.stopTypingTimer()
 
 		gt.chatView.messagesList.reset()
 		gt.chatView.messagesList.setTitle(*channel)

+ 27 - 0
internal/ui/chat/message_input.go

@@ -11,6 +11,7 @@ import (
 	"regexp"
 	"slices"
 	"strings"
+	"sync"
 	"time"
 	"unicode"
 
@@ -47,6 +48,9 @@ type messageInput struct {
 	cache           *cache.Cache
 	mentionsList    *tview.List
 	lastSearch      time.Time
+
+	typingTimerMu sync.Mutex
+	typingTimer   *time.Timer
 }
 
 func newMessageInput(cfg *config.Config, chatView *View) *messageInput {
@@ -89,6 +93,13 @@ func (mi *messageInput) reset() {
 	mi.SetText("", true)
 }
 
+func (mi *messageInput) stopTypingTimer() {
+	if mi.typingTimer != nil {
+		mi.typingTimer.Stop()
+		mi.typingTimer = nil
+	}
+}
+
 func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	switch event.Name() {
 	case mi.cfg.Keys.MessageInput.Paste:
@@ -122,6 +133,18 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	case mi.cfg.Keys.MessageInput.TabComplete:
 		go mi.chatView.app.QueueUpdateDraw(func() { mi.tabComplete() })
 		return nil
+	default:
+		if mi.cfg.TypingIndicator.Send && mi.typingTimer == nil {
+			mi.typingTimer = time.AfterFunc(typingDuration, func() {
+				mi.typingTimerMu.Lock()
+				mi.typingTimer = nil
+				mi.typingTimerMu.Unlock()
+			})
+
+			if selectedChannel := mi.chatView.SelectedChannel(); selectedChannel != nil {
+				go mi.chatView.state.Typing(selectedChannel.ID)
+			}
+		}
 	}
 
 	if mi.cfg.AutocompleteLimit > 0 {
@@ -192,6 +215,10 @@ func (mi *messageInput) send() {
 		}
 	}
 
+	if mi.typingTimer != nil {
+		mi.typingTimer.Stop()
+		mi.typingTimer = nil
+	}
 	mi.reset()
 	mi.chatView.messagesList.Highlight()
 	mi.chatView.messagesList.ScrollToEnd()

+ 27 - 0
internal/ui/chat/state.go

@@ -40,6 +40,10 @@ func (v *View) OpenState(token string) error {
 	v.state.AddHandler(v.onGuildMembersChunk)
 	v.state.AddHandler(v.onGuildMemberRemove)
 
+	if v.cfg.TypingIndicator.Receive {
+		v.state.AddHandler(v.onTypingStart)
+	}
+
 	v.state.StateLog = func(err error) {
 		slog.Error("state log", "err", err)
 	}
@@ -71,3 +75,26 @@ func (v *View) onRaw(event *ws.RawEvent) {
 		// "data", event.Raw,
 	)
 }
+
+func (v *View) onTypingStart(event *gateway.TypingStartEvent) {
+	selectedChannel := v.SelectedChannel()
+	if selectedChannel == nil {
+		return
+	}
+
+	if selectedChannel.ID != event.ChannelID {
+		return
+	}
+
+	me, err := v.state.Cabinet.Me()
+	if err != nil {
+		slog.Error("failed to get me from state", "err", err)
+		return
+	}
+
+	if event.UserID == me.ID {
+		return
+	}
+
+	v.addTyper(event.UserID)
+}

+ 106 - 5
internal/ui/chat/view.go

@@ -1,8 +1,10 @@
 package chat
 
 import (
+	"fmt"
 	"log/slog"
 	"sync"
+	"time"
 
 	"github.com/ayn2op/discordo/internal/config"
 	"github.com/ayn2op/discordo/internal/keyring"
@@ -16,6 +18,8 @@ import (
 	"github.com/gdamore/tcell/v3"
 )
 
+const typingDuration = 10 * time.Second
+
 const (
 	flexPageName            = "flex"
 	mentionsListPageName    = "mentionsList"
@@ -36,6 +40,9 @@ type View struct {
 	selectedChannel   *discord.Channel
 	selectedChannelMu sync.RWMutex
 
+	typersMu sync.RWMutex
+	typers   map[discord.UserID]*time.Timer
+
 	app   *tview.Application
 	cfg   *config.Config
 	state *ningen.State
@@ -50,6 +57,8 @@ func NewView(app *tview.Application, cfg *config.Config, onLogout func()) *View
 		mainFlex:  tview.NewFlex(),
 		rightFlex: tview.NewFlex(),
 
+		typers: make(map[discord.UserID]*time.Timer),
+
 		app:      app,
 		cfg:      cfg,
 		onLogout: onLogout,
@@ -277,13 +286,15 @@ func (v *View) onReady(r *gateway.ReadyEvent) {
 }
 
 func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
-	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
+	selectedChannel := v.SelectedChannel()
+	if selectedChannel != nil && selectedChannel.ID == message.ChannelID {
+		v.removeTyper(message.Author.ID)
 		v.messagesList.drawMessage(v.messagesList, message.Message)
 		v.app.Draw()
-	}
-
-	if err := notifications.Notify(v.state, message, v.cfg); err != nil {
-		slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
+	} else {
+		if err := notifications.Notify(v.state, message, v.cfg); err != nil {
+			slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
+		}
 	}
 }
 
@@ -314,3 +325,93 @@ func (v *View) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) {
 func (v *View) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
 	v.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, v.state.MemberState.SearchLimit)
 }
+
+func (v *View) clearTypers() {
+	v.typersMu.Lock()
+	for _, timer := range v.typers {
+		timer.Stop()
+	}
+	clear(v.typers)
+	v.typersMu.Unlock()
+	v.updateFooter()
+}
+
+func (v *View) addTyper(userID discord.UserID) {
+	v.typersMu.Lock()
+	typer, ok := v.typers[userID]
+	if ok {
+		typer.Reset(typingDuration)
+	} else {
+		v.typers[userID] = time.AfterFunc(typingDuration, func() {
+			v.removeTyper(userID)
+		})
+	}
+	v.typersMu.Unlock()
+	v.updateFooter()
+}
+
+func (v *View) removeTyper(userID discord.UserID) {
+	v.typersMu.Lock()
+	if typer, ok := v.typers[userID]; ok {
+		typer.Stop()
+		delete(v.typers, userID)
+	}
+	v.typersMu.Unlock()
+	v.updateFooter()
+}
+
+func (v *View) updateFooter() {
+	selectedChannel := v.SelectedChannel()
+	if selectedChannel == nil {
+		return
+	}
+	guildID := selectedChannel.GuildID
+
+	v.typersMu.RLock()
+	defer v.typersMu.RUnlock()
+
+	var footer string
+	if len(v.typers) > 0 {
+		var names []string
+		for userID := range v.typers {
+			var name string
+			if guildID.IsValid() {
+				member, err := v.state.Cabinet.Member(guildID, userID)
+				if err != nil {
+					slog.Error("failed to get member from state", "err", err, "guild_id", guildID, "user_id", userID)
+					continue
+				}
+
+				if member.Nick != "" {
+					name = member.Nick
+				} else {
+					name = member.User.DisplayOrUsername()
+				}
+			} else {
+				for _, recipient := range selectedChannel.DMRecipients {
+					if recipient.ID == userID {
+						name = recipient.DisplayOrUsername()
+						break
+					}
+				}
+			}
+
+			if name != "" {
+				names = append(names, name)
+			}
+		}
+
+		switch len(names) {
+		case 1:
+			footer = fmt.Sprintf("%s is typing...", names[0])
+		case 2:
+			footer = fmt.Sprintf("%s and %s are typing...", names[0], names[1])
+		case 3:
+			footer = fmt.Sprintf("%s, %s, and %s are typing...", names[0], names[1], names[2])
+		default:
+			footer = "Several people are typing..."
+		}
+	}
+
+	go v.app.QueueUpdateDraw(func() { v.messagesList.SetFooter(footer) })
+}