Skip to content

Commit

Permalink
Implement better typing indicator
Browse files Browse the repository at this point in the history
  • Loading branch information
diamondburned committed Feb 23, 2024
1 parent af2af2c commit 3987299
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 117 deletions.
112 changes: 1 addition & 111 deletions internal/messages/composer/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ import (
"log"
"log/slog"
"os"
"sort"
"strings"
"time"
"unicode"

"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/chatkit/components/author"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/core/gioutil"
"github.com/diamondburned/gotk4/pkg/gio/v2"
Expand Down Expand Up @@ -107,9 +105,6 @@ type View struct {
leftBox *gtk.Box
uploadButton *gtk.Button

typers []typer
typingHandler glib.SourceHandle

chooser *gtk.FileChooserNative

state struct {
Expand Down Expand Up @@ -241,24 +236,6 @@ func NewView(ctx context.Context, ctrl Controller, chID discord.ChannelID) *View

v.SetPlaceholderMarkup("")

state := gtkcord.FromContext(ctx)
state.BindWidget(v,
func(ev gateway.Event) {
switch ev := ev.(type) {
case *gateway.TypingStartEvent:
if ev.ChannelID == chID {
v.addTyper(ev)
}
case *gateway.MessageCreateEvent:
if ev.ChannelID == chID {
v.removeTyper(ev.Author.ID)
}
}
},
(*gateway.TypingStartEvent)(nil),
(*gateway.MessageCreateEvent)(nil),
)

viewCSS(v)
return v
}
Expand All @@ -275,26 +252,7 @@ func (v *View) SetPlaceholderMarkup(markup string) {
}

func (v *View) ResetPlaceholder() {
if len(v.typers) == 0 {
v.Placeholder.SetText("Message " + gtkcord.ChannelNameFromID(v.ctx, v.chID))
return
}

var typers string
switch len(v.typers) {
case 1:
typers = v.typers[0].Markup + " is typing..."
case 2:
typers = v.typers[0].Markup + " and " +
v.typers[1].Markup + " are typing..."
case 3:
typers = v.typers[0].Markup + ", " +
v.typers[1].Markup + " and " +
v.typers[2].Markup + " are typing..."
default:
typers = "Several people are typing..."
}
v.Placeholder.SetMarkup(typers)
v.Placeholder.SetText("Message " + gtkcord.ChannelNameFromID(v.ctx, v.chID))
}

// actionButton is a button that is used in the composer bar.
Expand Down Expand Up @@ -649,74 +607,6 @@ func (v *View) restart() bool {
return state.editing || state.replying != notReplying
}

func (v *View) addTyper(ev *gateway.TypingStartEvent) {
if t := findTyper(v.typers, ev.UserID); t != nil {
t.Time = ev.Timestamp
} else {
state := gtkcord.FromContext(v.ctx)
mods := []author.MarkupMod{author.WithMinimal()}
var markup string

if ev.Member != nil {
markup = state.MemberMarkup(ev.GuildID, &discord.GuildUser{
User: ev.Member.User,
Member: ev.Member,
}, mods...)
} else {
markup = state.UserIDMarkup(ev.ChannelID, ev.UserID, mods...)
}

v.typers = append(v.typers, typer{
Markup: markup,
UserID: ev.UserID,
Time: ev.Timestamp,
})
}

sort.Slice(v.typers, func(i, j int) bool {
return v.typers[i].Time < v.typers[j].Time
})

v.ResetPlaceholder()

if v.typingHandler == 0 {
v.typingHandler = glib.TimeoutSecondsAdd(1, func() bool {
v.cleanupTypers()

if len(v.typers) == 0 {
v.typingHandler = 0
return false
}

return true
})
}
}

func (v *View) removeTyper(uID discord.UserID) {
for i, typer := range v.typers {
if typer.UserID == uID {
v.typers = append(v.typers[:i], v.typers[i+1:]...)
v.ResetPlaceholder()
return
}
}
}

func (v *View) cleanupTypers() {
createdTime := discord.UnixTimestamp(time.Now().Add(-typerTimeout).Unix())

typers := v.typers[:0]
for _, typer := range v.typers {
if typer.Time > createdTime {
typers = append(typers, typer)
}
}

v.typers = typers
v.ResetPlaceholder()
}

// inputControllerView implements InputController.
type inputControllerView struct {
*View
Expand Down
238 changes: 238 additions & 0 deletions internal/messages/typingindicator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package messages

import (
"context"
"slices"
"time"

"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/chatkit/components/author"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"github.com/diamondburned/gotk4/pkg/pango"
"github.com/diamondburned/gotkit/app/locale"
"github.com/diamondburned/gotkit/gtkutil/cssutil"
"github.com/diamondburned/gtkcord4/internal/gtkcord"
)

const typerTimeout = 10 * time.Second

// TypingIndicator is a struct that represents a typing indicator box.
type TypingIndicator struct {
*gtk.Revealer
child struct {
*gtk.Box
Dots gtk.Widgetter
Label *gtk.Label
}

typers []typingTyper
state *gtkcord.State
chID discord.ChannelID
guildID discord.GuildID
}

type typingTyper struct {
UserMarkup string
UserID discord.UserID
When discord.UnixTimestamp
}

var typingIndicatorCSS = cssutil.Applier("messages-typing-indicator", `
.messages-typing-box {
padding: 1px 15px;
font-size: 0.85em;
}
.messages-typing-box .messages-breathing-dots {
margin-right: 11px;
}
`)

// NewTypingIndicator creates a new TypingIndicator.
func NewTypingIndicator(ctx context.Context, chID discord.ChannelID) *TypingIndicator {
state := gtkcord.FromContext(ctx)

t := &TypingIndicator{
Revealer: gtk.NewRevealer(),
typers: make([]typingTyper, 0, 3),
state: state,
chID: chID,
}

ch, _ := state.Cabinet.Channel(chID)
if ch != nil {
t.guildID = ch.GuildID
}

t.child.Dots = newBreathingDots()

t.child.Label = gtk.NewLabel("")
t.child.Label.AddCSSClass("messages-typing-label")
t.child.Label.SetHExpand(true)
t.child.Label.SetXAlign(0)
t.child.Label.SetWrap(false)
t.child.Label.SetEllipsize(pango.EllipsizeEnd)
t.child.Label.SetSingleLineMode(true)

t.child.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)
t.child.Box.AddCSSClass("messages-typing-box")
t.child.Box.Append(t.child.Dots)
t.child.Box.Append(t.child.Label)

t.SetTransitionType(gtk.RevealerTransitionTypeSlideUp)
t.SetOverflow(gtk.OverflowHidden)
t.SetChild(t.child.Box)
typingIndicatorCSS(t)

state.AddHandlerForWidget(t,
func(ev *gateway.TypingStartEvent) {
if ev.ChannelID != chID {
return
}
t.AddTyper(ev.UserID, ev.Timestamp)
},
func(ev *gateway.MessageCreateEvent) {
if ev.ChannelID != chID {
return
}
t.RemoveTyper(ev.Author.ID)
},
)
t.updateAndScheduleNext()

return t
}

// AddTyper adds a typer to the typing indicator.
func (t *TypingIndicator) AddTyper(userID discord.UserID, when discord.UnixTimestamp) {
t.AddTyperMember(userID, when, nil)
}

// AddTyperMember adds a typer to the typing indicator with a member object.
func (t *TypingIndicator) AddTyperMember(userID discord.UserID, when discord.UnixTimestamp, member *discord.Member) {
defer t.updateAndScheduleNext()

ix := slices.IndexFunc(t.typers, func(t typingTyper) bool { return t.UserID == userID })
if ix != -1 {
t.typers[ix].When = when
return
}

mods := []author.MarkupMod{author.WithMinimal()}

var markup string
if member != nil {
markup = t.state.MemberMarkup(t.guildID, &discord.GuildUser{
User: member.User,
Member: member,
}, mods...)
} else {
markup = t.state.UserIDMarkup(t.chID, userID, mods...)
}

t.typers = append(t.typers, typingTyper{
UserMarkup: markup,
UserID: userID,
When: when,
})
}

// RemoveTyper removes a typer from the typing indicator.
func (t *TypingIndicator) RemoveTyper(userID discord.UserID) {
t.typers = slices.DeleteFunc(t.typers, func(t typingTyper) bool { return t.UserID == userID })
t.updateAndScheduleNext()
}

// updateAndScheduleNext updates the typing indicator and schedules the next
// cleanup using TimeoutAdd.
func (t *TypingIndicator) updateAndScheduleNext() {
now := time.Now()
earliest := discord.UnixTimestamp(now.Add(-typerTimeout).Unix())

nowUnix := discord.UnixTimestamp(now.Unix())
next := nowUnix

typers := t.typers[:0]
for _, typer := range t.typers {
if typer.When > earliest {
typers = append(typers, typer)
next = min(next, typer.When)
}
}
for i := len(typers); i < len(t.typers); i++ {
// Prevent memory leaks.
t.typers[i] = typingTyper{}
}
t.typers = typers

if len(t.typers) == 0 {
t.SetRevealChild(false)
return
}

slices.SortFunc(t.typers, func(a, b typingTyper) int {
return int(a.When - b.When)
})

t.SetRevealChild(true)
t.child.Label.SetMarkup(renderTypingMarkup(t.typers))

// Schedule the next cleanup.
// Prevent rounding errors by adding a small buffer.
cleanUpInSeconds := uint(next-nowUnix) + 1
glib.TimeoutSecondsAdd(cleanUpInSeconds, func() {
t.updateAndScheduleNext()
})
}

func renderTypingMarkup(typers []typingTyper) string {
switch len(typers) {
case 0:
return ""
case 1:
return locale.Sprintf(
"%s is typing...",
typers[0].UserMarkup,
)
case 2:
return locale.Sprintf(
"%s and %s are typing...",
typers[0].UserMarkup, typers[1].UserMarkup,
)
case 3:
return locale.Sprintf(
"%s, %s and %s are typing...",
typers[0].UserMarkup, typers[1].UserMarkup, typers[2].UserMarkup,
)
default:
return locale.Get(
"Several people are typing...",
)
}
}

var breathingDotsCSS = cssutil.Applier("messages-breathing-dots", `
@keyframes messages-breathing {
0% { opacity: 0.66; }
100% { opacity: 0.12; }
}
.messages-breathing-dots label {
animation: messages-breathing 800ms infinite alternate;
}
.messages-breathing-dots label:nth-child(1) { animation-delay: 000ms; }
.messages-breathing-dots label:nth-child(2) { animation-delay: 150ms; }
.messages-breathing-dots label:nth-child(3) { animation-delay: 300ms; }
`)

func newBreathingDots() gtk.Widgetter {
const ch = "●"

box := gtk.NewBox(gtk.OrientationHorizontal, 0)
box.Append(gtk.NewLabel(ch))
box.Append(gtk.NewLabel(ch))
box.Append(gtk.NewLabel(ch))
breathingDotsCSS(box)

return box
}
Loading

0 comments on commit 3987299

Please sign in to comment.