Path: blob/main/internal/messages/typingindicator.go
366 views
package messages12import (3"context"4"slices"5"time"67"github.com/diamondburned/arikawa/v3/discord"8"github.com/diamondburned/arikawa/v3/gateway"9"github.com/diamondburned/chatkit/components/author"10"github.com/diamondburned/gotk4/pkg/glib/v2"11"github.com/diamondburned/gotk4/pkg/gtk/v4"12"github.com/diamondburned/gotk4/pkg/pango"13"github.com/diamondburned/gotkit/app/locale"14"github.com/diamondburned/gotkit/gtkutil/cssutil"15"libdb.so/dissent/internal/gtkcord"16)1718const typerTimeout = 10 * time.Second1920// TypingIndicator is a struct that represents a typing indicator box.21type TypingIndicator struct {22*gtk.Revealer23child struct {24*gtk.Box25Dots gtk.Widgetter26Label *gtk.Label27}2829typers []typingTyper30state *gtkcord.State31chID discord.ChannelID32guildID discord.GuildID33}3435type typingTyper struct {36UserMarkup string37UserID discord.UserID38When discord.UnixTimestamp39}4041var typingIndicatorCSS = cssutil.Applier("messages-typing-indicator", `42.messages-typing-box {43padding: 1px 15px;44font-size: 0.85em;45}46.messages-typing-box .messages-breathing-dots {47margin-right: 11px;48}49`)5051// NewTypingIndicator creates a new TypingIndicator.52func NewTypingIndicator(ctx context.Context, chID discord.ChannelID) *TypingIndicator {53state := gtkcord.FromContext(ctx)5455t := &TypingIndicator{56Revealer: gtk.NewRevealer(),57typers: make([]typingTyper, 0, 3),58state: state,59chID: chID,60}6162ch, _ := state.Cabinet.Channel(chID)63if ch != nil {64t.guildID = ch.GuildID65}6667t.child.Dots = newBreathingDots()6869t.child.Label = gtk.NewLabel("")70t.child.Label.AddCSSClass("messages-typing-label")71t.child.Label.SetHExpand(true)72t.child.Label.SetXAlign(0)73t.child.Label.SetWrap(false)74t.child.Label.SetEllipsize(pango.EllipsizeEnd)75t.child.Label.SetSingleLineMode(true)7677t.child.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)78t.child.Box.AddCSSClass("messages-typing-box")79t.child.Box.Append(t.child.Dots)80t.child.Box.Append(t.child.Label)8182t.SetTransitionType(gtk.RevealerTransitionTypeCrossfade)83t.SetCanTarget(false)84t.SetOverflow(gtk.OverflowHidden)85t.SetChild(t.child.Box)86typingIndicatorCSS(t)8788state.AddHandlerForWidget(t,89func(ev *gateway.TypingStartEvent) {90if ev.ChannelID != chID {91return92}93t.AddTyperMember(ev.UserID, ev.Timestamp, ev.Member)94},95func(ev *gateway.MessageCreateEvent) {96if ev.ChannelID != chID {97return98}99t.RemoveTyper(ev.Author.ID)100},101)102t.updateAndScheduleNext()103104return t105}106107// AddTyper adds a typer to the typing indicator.108func (t *TypingIndicator) AddTyper(userID discord.UserID, when discord.UnixTimestamp) {109t.AddTyperMember(userID, when, nil)110}111112// AddTyperMember adds a typer to the typing indicator with a member object.113func (t *TypingIndicator) AddTyperMember(userID discord.UserID, when discord.UnixTimestamp, member *discord.Member) {114defer t.updateAndScheduleNext()115116ix := slices.IndexFunc(t.typers, func(t typingTyper) bool { return t.UserID == userID })117if ix != -1 {118t.typers[ix].When = when119return120}121122mods := []author.MarkupMod{author.WithMinimal()}123124var markup string125if member != nil {126markup = t.state.MemberMarkup(t.guildID, &discord.GuildUser{127User: member.User,128Member: member,129}, mods...)130} else {131markup = t.state.UserIDMarkup(t.chID, userID, mods...)132}133134markup = "<b>" + markup + "</b>"135136t.typers = append(t.typers, typingTyper{137UserMarkup: markup,138UserID: userID,139When: when,140})141}142143// RemoveTyper removes a typer from the typing indicator.144func (t *TypingIndicator) RemoveTyper(userID discord.UserID) {145t.typers = slices.DeleteFunc(t.typers, func(t typingTyper) bool { return t.UserID == userID })146t.updateAndScheduleNext()147}148149// updateAndScheduleNext updates the typing indicator and schedules the next150// cleanup using TimeoutAdd.151func (t *TypingIndicator) updateAndScheduleNext() {152now := time.Now()153154// We don't keep around typing events that are older than the timeout.155earliestPossibleTime := discord.UnixTimestamp(now.Add(-typerTimeout).Unix())156157typers := t.typers[:0]158earliestTyper := discord.UnixTimestamp(now.Unix())159for _, typer := range t.typers {160if typer.When > earliestPossibleTime {161typers = append(typers, typer)162earliestTyper = min(earliestTyper, typer.When)163}164}165for i := len(typers); i < len(t.typers); i++ {166// Prevent memory leaks.167t.typers[i] = typingTyper{}168}169t.typers = typers170171if len(t.typers) == 0 {172t.SetRevealChild(false)173return174}175176slices.SortFunc(t.typers, func(a, b typingTyper) int {177return int(a.When - b.When)178})179180t.SetRevealChild(true)181t.child.Label.SetMarkup(renderTypingMarkup(t.typers))182183// Schedule the next cleanup.184// Prevent rounding errors by adding a small buffer.185cleanUpInSeconds := uint(186earliestTyper.187Time().188Add(typerTimeout).189Sub(now).190Seconds()) + 1191glib.TimeoutSecondsAdd(cleanUpInSeconds, t.updateAndScheduleNext)192}193194func renderTypingMarkup(typers []typingTyper) string {195switch len(typers) {196case 0:197return ""198case 1:199return locale.Sprintf(200"%s is typing...",201typers[0].UserMarkup,202)203case 2:204return locale.Sprintf(205"%s and %s are typing...",206typers[0].UserMarkup, typers[1].UserMarkup,207)208case 3:209return locale.Sprintf(210"%s, %s and %s are typing...",211typers[0].UserMarkup, typers[1].UserMarkup, typers[2].UserMarkup,212)213default:214return locale.Get(215"Several people are typing...",216)217}218}219220var breathingDotsCSS = cssutil.Applier("messages-breathing-dots", `221@keyframes messages-breathing {2220% { opacity: 0.66; }223100% { opacity: 0.12; }224}225.messages-breathing-dots label {226animation: messages-breathing 800ms infinite alternate;227}228.messages-breathing-dots label:nth-child(1) { animation-delay: 000ms; }229.messages-breathing-dots label:nth-child(2) { animation-delay: 150ms; }230.messages-breathing-dots label:nth-child(3) { animation-delay: 300ms; }231`)232233func newBreathingDots() gtk.Widgetter {234const ch = "●"235236box := gtk.NewBox(gtk.OrientationHorizontal, 0)237box.Append(gtk.NewLabel(ch))238box.Append(gtk.NewLabel(ch))239box.Append(gtk.NewLabel(ch))240breathingDotsCSS(box)241242return box243}244245246