Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/messages/typingindicator.go
366 views
1
package messages
2
3
import (
4
"context"
5
"slices"
6
"time"
7
8
"github.com/diamondburned/arikawa/v3/discord"
9
"github.com/diamondburned/arikawa/v3/gateway"
10
"github.com/diamondburned/chatkit/components/author"
11
"github.com/diamondburned/gotk4/pkg/glib/v2"
12
"github.com/diamondburned/gotk4/pkg/gtk/v4"
13
"github.com/diamondburned/gotk4/pkg/pango"
14
"github.com/diamondburned/gotkit/app/locale"
15
"github.com/diamondburned/gotkit/gtkutil/cssutil"
16
"libdb.so/dissent/internal/gtkcord"
17
)
18
19
const typerTimeout = 10 * time.Second
20
21
// TypingIndicator is a struct that represents a typing indicator box.
22
type TypingIndicator struct {
23
*gtk.Revealer
24
child struct {
25
*gtk.Box
26
Dots gtk.Widgetter
27
Label *gtk.Label
28
}
29
30
typers []typingTyper
31
state *gtkcord.State
32
chID discord.ChannelID
33
guildID discord.GuildID
34
}
35
36
type typingTyper struct {
37
UserMarkup string
38
UserID discord.UserID
39
When discord.UnixTimestamp
40
}
41
42
var typingIndicatorCSS = cssutil.Applier("messages-typing-indicator", `
43
.messages-typing-box {
44
padding: 1px 15px;
45
font-size: 0.85em;
46
}
47
.messages-typing-box .messages-breathing-dots {
48
margin-right: 11px;
49
}
50
`)
51
52
// NewTypingIndicator creates a new TypingIndicator.
53
func NewTypingIndicator(ctx context.Context, chID discord.ChannelID) *TypingIndicator {
54
state := gtkcord.FromContext(ctx)
55
56
t := &TypingIndicator{
57
Revealer: gtk.NewRevealer(),
58
typers: make([]typingTyper, 0, 3),
59
state: state,
60
chID: chID,
61
}
62
63
ch, _ := state.Cabinet.Channel(chID)
64
if ch != nil {
65
t.guildID = ch.GuildID
66
}
67
68
t.child.Dots = newBreathingDots()
69
70
t.child.Label = gtk.NewLabel("")
71
t.child.Label.AddCSSClass("messages-typing-label")
72
t.child.Label.SetHExpand(true)
73
t.child.Label.SetXAlign(0)
74
t.child.Label.SetWrap(false)
75
t.child.Label.SetEllipsize(pango.EllipsizeEnd)
76
t.child.Label.SetSingleLineMode(true)
77
78
t.child.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)
79
t.child.Box.AddCSSClass("messages-typing-box")
80
t.child.Box.Append(t.child.Dots)
81
t.child.Box.Append(t.child.Label)
82
83
t.SetTransitionType(gtk.RevealerTransitionTypeCrossfade)
84
t.SetCanTarget(false)
85
t.SetOverflow(gtk.OverflowHidden)
86
t.SetChild(t.child.Box)
87
typingIndicatorCSS(t)
88
89
state.AddHandlerForWidget(t,
90
func(ev *gateway.TypingStartEvent) {
91
if ev.ChannelID != chID {
92
return
93
}
94
t.AddTyperMember(ev.UserID, ev.Timestamp, ev.Member)
95
},
96
func(ev *gateway.MessageCreateEvent) {
97
if ev.ChannelID != chID {
98
return
99
}
100
t.RemoveTyper(ev.Author.ID)
101
},
102
)
103
t.updateAndScheduleNext()
104
105
return t
106
}
107
108
// AddTyper adds a typer to the typing indicator.
109
func (t *TypingIndicator) AddTyper(userID discord.UserID, when discord.UnixTimestamp) {
110
t.AddTyperMember(userID, when, nil)
111
}
112
113
// AddTyperMember adds a typer to the typing indicator with a member object.
114
func (t *TypingIndicator) AddTyperMember(userID discord.UserID, when discord.UnixTimestamp, member *discord.Member) {
115
defer t.updateAndScheduleNext()
116
117
ix := slices.IndexFunc(t.typers, func(t typingTyper) bool { return t.UserID == userID })
118
if ix != -1 {
119
t.typers[ix].When = when
120
return
121
}
122
123
mods := []author.MarkupMod{author.WithMinimal()}
124
125
var markup string
126
if member != nil {
127
markup = t.state.MemberMarkup(t.guildID, &discord.GuildUser{
128
User: member.User,
129
Member: member,
130
}, mods...)
131
} else {
132
markup = t.state.UserIDMarkup(t.chID, userID, mods...)
133
}
134
135
markup = "<b>" + markup + "</b>"
136
137
t.typers = append(t.typers, typingTyper{
138
UserMarkup: markup,
139
UserID: userID,
140
When: when,
141
})
142
}
143
144
// RemoveTyper removes a typer from the typing indicator.
145
func (t *TypingIndicator) RemoveTyper(userID discord.UserID) {
146
t.typers = slices.DeleteFunc(t.typers, func(t typingTyper) bool { return t.UserID == userID })
147
t.updateAndScheduleNext()
148
}
149
150
// updateAndScheduleNext updates the typing indicator and schedules the next
151
// cleanup using TimeoutAdd.
152
func (t *TypingIndicator) updateAndScheduleNext() {
153
now := time.Now()
154
155
// We don't keep around typing events that are older than the timeout.
156
earliestPossibleTime := discord.UnixTimestamp(now.Add(-typerTimeout).Unix())
157
158
typers := t.typers[:0]
159
earliestTyper := discord.UnixTimestamp(now.Unix())
160
for _, typer := range t.typers {
161
if typer.When > earliestPossibleTime {
162
typers = append(typers, typer)
163
earliestTyper = min(earliestTyper, typer.When)
164
}
165
}
166
for i := len(typers); i < len(t.typers); i++ {
167
// Prevent memory leaks.
168
t.typers[i] = typingTyper{}
169
}
170
t.typers = typers
171
172
if len(t.typers) == 0 {
173
t.SetRevealChild(false)
174
return
175
}
176
177
slices.SortFunc(t.typers, func(a, b typingTyper) int {
178
return int(a.When - b.When)
179
})
180
181
t.SetRevealChild(true)
182
t.child.Label.SetMarkup(renderTypingMarkup(t.typers))
183
184
// Schedule the next cleanup.
185
// Prevent rounding errors by adding a small buffer.
186
cleanUpInSeconds := uint(
187
earliestTyper.
188
Time().
189
Add(typerTimeout).
190
Sub(now).
191
Seconds()) + 1
192
glib.TimeoutSecondsAdd(cleanUpInSeconds, t.updateAndScheduleNext)
193
}
194
195
func renderTypingMarkup(typers []typingTyper) string {
196
switch len(typers) {
197
case 0:
198
return ""
199
case 1:
200
return locale.Sprintf(
201
"%s is typing...",
202
typers[0].UserMarkup,
203
)
204
case 2:
205
return locale.Sprintf(
206
"%s and %s are typing...",
207
typers[0].UserMarkup, typers[1].UserMarkup,
208
)
209
case 3:
210
return locale.Sprintf(
211
"%s, %s and %s are typing...",
212
typers[0].UserMarkup, typers[1].UserMarkup, typers[2].UserMarkup,
213
)
214
default:
215
return locale.Get(
216
"Several people are typing...",
217
)
218
}
219
}
220
221
var breathingDotsCSS = cssutil.Applier("messages-breathing-dots", `
222
@keyframes messages-breathing {
223
0% { opacity: 0.66; }
224
100% { opacity: 0.12; }
225
}
226
.messages-breathing-dots label {
227
animation: messages-breathing 800ms infinite alternate;
228
}
229
.messages-breathing-dots label:nth-child(1) { animation-delay: 000ms; }
230
.messages-breathing-dots label:nth-child(2) { animation-delay: 150ms; }
231
.messages-breathing-dots label:nth-child(3) { animation-delay: 300ms; }
232
`)
233
234
func newBreathingDots() gtk.Widgetter {
235
const ch = "●"
236
237
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
238
box.Append(gtk.NewLabel(ch))
239
box.Append(gtk.NewLabel(ch))
240
box.Append(gtk.NewLabel(ch))
241
breathingDotsCSS(box)
242
243
return box
244
}
245
246