Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/sidebar/channels/channel_item.go
366 views
1
package channels
2
3
import (
4
"context"
5
"fmt"
6
7
"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/core/glib"
11
"github.com/diamondburned/gotk4/pkg/gtk/v4"
12
"github.com/diamondburned/gotk4/pkg/pango"
13
"github.com/diamondburned/gotkit/app"
14
"github.com/diamondburned/gotkit/app/locale"
15
"github.com/diamondburned/gotkit/gtkutil/cssutil"
16
"github.com/diamondburned/gotkit/gtkutil/imgutil"
17
"github.com/diamondburned/ningen/v3"
18
"github.com/diamondburned/ningen/v3/states/read"
19
"libdb.so/dissent/internal/components/hoverpopover"
20
"libdb.so/dissent/internal/gtkcord"
21
"libdb.so/dissent/internal/signaling"
22
)
23
24
var revealStateKey = app.NewStateKey[bool]("collapsed-channels-state")
25
26
type channelItemState struct {
27
state *gtkcord.State
28
reveal *app.TypedState[bool]
29
}
30
31
func newChannelItemFactory(ctx context.Context, model *gtk.TreeListModel) *gtk.ListItemFactory {
32
factory := gtk.NewSignalListItemFactory()
33
state := channelItemState{
34
state: gtkcord.FromContext(ctx),
35
reveal: revealStateKey.Acquire(ctx),
36
}
37
38
unbindFns := make(map[uintptr]func())
39
40
factory.ConnectBind(func(obj *glib.Object) {
41
item := obj.Cast().(*gtk.ListItem)
42
row := model.Row(item.Position())
43
unbind := bindChannelItem(state, item, row)
44
unbindFns[item.Native()] = unbind
45
})
46
47
factory.ConnectUnbind(func(obj *glib.Object) {
48
item := obj.Cast().(*gtk.ListItem)
49
unbind := unbindFns[item.Native()]
50
unbind()
51
delete(unbindFns, item.Native())
52
item.SetChild(nil)
53
})
54
55
return &factory.ListItemFactory
56
}
57
58
func channelIDFromListItem(item *gtk.ListItem) discord.ChannelID {
59
return channelIDFromItem(item.Item())
60
}
61
62
func channelIDFromItem(item *glib.Object) discord.ChannelID {
63
str := item.Cast().(*gtk.StringObject)
64
65
id, err := discord.ParseSnowflake(str.String())
66
if err != nil {
67
panic(fmt.Sprintf("channelIDFromListItem: failed to parse ID: %v", err))
68
}
69
70
return discord.ChannelID(id)
71
}
72
73
var _ = cssutil.WriteCSS(`
74
.channels-viewtree row:hover,
75
.channels-viewtree row:selected {
76
background: none;
77
}
78
.channels-viewtree row:hover .channel-item-outer {
79
background: alpha(@theme_fg_color, 0.075);
80
}
81
.channels-viewtree row:selected .channel-item-outer {
82
background: alpha(@theme_fg_color, 0.125);
83
}
84
.channels-viewtree row:selected:hover .channel-item-outer {
85
background: alpha(@theme_fg_color, 0.175);
86
}
87
.channel-item {
88
padding: 0.35em 0;
89
}
90
.channel-item > :first-child {
91
min-width: 2.5em;
92
margin: 0;
93
}
94
.channel-item expander + * {
95
/* Weird workaround because GTK is adding extra padding here for some
96
* reason. */
97
margin-left: -0.35em;
98
}
99
.channel-item-muted {
100
opacity: 0.35;
101
}
102
.channel-unread-indicator {
103
font-size: 0.75em;
104
font-weight: 700;
105
}
106
.channel-item-unread .channel-unread-indicator,
107
.channel-item-mentioned .channel-unread-indicator {
108
font-size: 0.7em;
109
font-weight: 900;
110
font-family: monospace;
111
112
min-width: 1em;
113
min-height: 1em;
114
line-height: 1em;
115
116
padding: 0;
117
margin: 0 1em;
118
119
outline: 1.5px solid @theme_fg_color;
120
border-radius: 99px;
121
}
122
.channel-item-mentioned .channel-unread-indicator {
123
font-size: 0.8em;
124
outline-color: @mentioned;
125
background: @mentioned;
126
color: @theme_bg_color;
127
}
128
`)
129
130
type channelItem struct {
131
state *gtkcord.State
132
item *gtk.ListItem
133
row *gtk.TreeListRow
134
reveal *app.TypedState[bool]
135
136
child struct {
137
*gtk.Box
138
content gtk.Widgetter
139
indicator *gtk.Label
140
}
141
142
chID discord.ChannelID
143
}
144
145
func bindChannelItem(state channelItemState, item *gtk.ListItem, row *gtk.TreeListRow) func() {
146
i := &channelItem{
147
state: state.state,
148
item: item,
149
row: row,
150
reveal: state.reveal,
151
chID: channelIDFromListItem(item),
152
}
153
154
i.child.indicator = gtk.NewLabel("")
155
i.child.indicator.AddCSSClass("channel-unread-indicator")
156
i.child.indicator.SetHExpand(true)
157
i.child.indicator.SetHAlign(gtk.AlignEnd)
158
i.child.indicator.SetVAlign(gtk.AlignCenter)
159
160
i.child.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)
161
i.child.Box.Append(i.child.indicator)
162
163
hoverpopover.NewMarkupHoverPopover(i.child.Box, func(w *hoverpopover.MarkupHoverPopoverWidget) bool {
164
summary := i.state.SummaryState.LastSummary(i.chID)
165
if summary == nil {
166
return false
167
}
168
169
window := app.GTKWindowFromContext(i.state.Context())
170
if window.Width() > 600 {
171
w.SetPosition(gtk.PosRight)
172
} else {
173
w.SetPosition(gtk.PosBottom)
174
}
175
176
w.Label.SetEllipsize(pango.EllipsizeEnd)
177
w.Label.SetSingleLineMode(true)
178
w.Label.SetMaxWidthChars(50)
179
w.Label.SetMarkup(fmt.Sprintf(
180
"<b>%s</b>%s",
181
locale.Get("Chatting about: "),
182
summary.Topic,
183
))
184
185
return true
186
})
187
188
i.item.SetChild(i.child.Box)
189
190
var unbind signaling.DisconnectStack
191
unbind.Push(
192
i.state.AddHandler(func(ev *read.UpdateEvent) {
193
if ev.ChannelID == i.chID {
194
i.Invalidate()
195
}
196
}),
197
i.state.AddHandler(func(ev *gateway.ChannelUpdateEvent) {
198
if ev.ID == i.chID {
199
i.Invalidate()
200
}
201
}),
202
)
203
204
ch, _ := i.state.Offline().Channel(i.chID)
205
if ch != nil {
206
switch ch.Type {
207
case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
208
unbind.Push(i.state.AddHandler(func(ev *gateway.ThreadUpdateEvent) {
209
if ev.ID == i.chID {
210
i.Invalidate()
211
}
212
}))
213
}
214
215
guildID := ch.GuildID
216
switch ch.Type {
217
case discord.GuildVoice, discord.GuildStageVoice:
218
unbind.Push(i.state.AddHandler(func(ev *gateway.VoiceStateUpdateEvent) {
219
// The channel ID becomes null when the user leaves the channel,
220
// so we'll just update when any guild state changes.
221
if ev.GuildID == guildID {
222
i.Invalidate()
223
}
224
}))
225
}
226
}
227
228
i.Invalidate()
229
return unbind.Disconnect
230
}
231
232
var readCSSClasses = map[ningen.UnreadIndication]string{
233
ningen.ChannelUnread: "channel-item-unread",
234
ningen.ChannelMentioned: "channel-item-mentioned",
235
}
236
237
const channelMutedClass = "channel-item-muted"
238
239
// Invalidate updates the channel item's contents.
240
func (i *channelItem) Invalidate() {
241
if i.child.content != nil {
242
i.child.Box.Remove(i.child.content)
243
}
244
245
i.item.SetSelectable(true)
246
247
ch, _ := i.state.Offline().Channel(i.chID)
248
if ch == nil {
249
i.child.content = newUnknownChannelItem(i.chID.String())
250
i.item.SetSelectable(false)
251
} else {
252
switch ch.Type {
253
case
254
discord.GuildText, discord.GuildAnnouncement,
255
discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
256
257
i.child.content = newChannelItemText(ch)
258
259
case discord.GuildCategory, discord.GuildForum:
260
switch ch.Type {
261
case discord.GuildCategory:
262
i.child.content = newChannelItemCategory(ch, i.row, i.reveal)
263
i.item.SetSelectable(false)
264
case discord.GuildForum:
265
i.child.content = newChannelItemForum(ch, i.row)
266
}
267
268
case discord.GuildVoice, discord.GuildStageVoice:
269
i.child.content = newChannelItemVoice(i.state, ch)
270
271
default:
272
panic("unreachable")
273
}
274
}
275
276
i.child.Box.SetCSSClasses(nil)
277
i.child.Box.Prepend(i.child.content)
278
279
// Steal CSS classes from the child.
280
for _, class := range gtk.BaseWidget(i.child.content).CSSClasses() {
281
i.child.Box.AddCSSClass(class + "-outer")
282
}
283
284
unreadOpts := ningen.UnreadOpts{
285
// We can do this within the channel list itself because it's easy to
286
// expand categories and see the unread channels within them.
287
IncludeMutedCategories: true,
288
}
289
290
unread := i.state.ChannelIsUnread(i.chID, unreadOpts)
291
if unread != ningen.ChannelRead {
292
i.child.Box.AddCSSClass(readCSSClasses[unread])
293
}
294
295
i.updateIndicator(unread)
296
297
if i.state.ChannelIsMuted(i.chID, unreadOpts) {
298
i.child.Box.AddCSSClass(channelMutedClass)
299
} else {
300
i.child.Box.RemoveCSSClass(channelMutedClass)
301
}
302
}
303
304
func (i *channelItem) updateIndicator(unread ningen.UnreadIndication) {
305
if unread == ningen.ChannelMentioned {
306
i.child.indicator.SetText("!")
307
} else {
308
i.child.indicator.SetText("")
309
}
310
}
311
312
var _ = cssutil.WriteCSS(`
313
.channel-item-unknown {
314
opacity: 0.35;
315
font-style: italic;
316
}
317
`)
318
319
func newUnknownChannelItem(name string) gtk.Widgetter {
320
icon := NewChannelIcon(nil)
321
322
label := gtk.NewLabel(name)
323
label.SetEllipsize(pango.EllipsizeEnd)
324
label.SetXAlign(0)
325
326
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
327
box.AddCSSClass("channel-item")
328
box.AddCSSClass("channel-item-unknown")
329
box.Append(icon)
330
box.Append(label)
331
332
return box
333
}
334
335
var _ = cssutil.WriteCSS(`
336
.channel-item-thread {
337
padding: 0.25em 0;
338
opacity: 0.5;
339
}
340
.channel-item-unread .channel-item-thread,
341
.channel-item-mention .channel-item-thread {
342
opacity: 1;
343
}
344
`)
345
346
func newChannelItemText(ch *discord.Channel) gtk.Widgetter {
347
icon := NewChannelIcon(ch)
348
349
label := gtk.NewLabel(ch.Name)
350
label.SetEllipsize(pango.EllipsizeEnd)
351
label.SetXAlign(0)
352
bindLabelTooltip(label, false)
353
354
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
355
box.AddCSSClass("channel-item")
356
box.Append(icon)
357
box.Append(label)
358
359
switch ch.Type {
360
case discord.GuildText:
361
box.AddCSSClass("channel-item-text")
362
case discord.GuildAnnouncement:
363
box.AddCSSClass("channel-item-announcement")
364
case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
365
box.AddCSSClass("channel-item-thread")
366
}
367
368
return box
369
}
370
371
var _ = cssutil.WriteCSS(`
372
.channel-item-forum {
373
padding: 0.35em 0;
374
}
375
.channel-item-forum label {
376
padding: 0;
377
}
378
`)
379
380
func newChannelItemForum(ch *discord.Channel, row *gtk.TreeListRow) gtk.Widgetter {
381
label := gtk.NewLabel(ch.Name)
382
label.SetEllipsize(pango.EllipsizeEnd)
383
label.SetXAlign(0)
384
bindLabelTooltip(label, false)
385
386
expander := gtk.NewTreeExpander()
387
expander.AddCSSClass("channel-item")
388
expander.AddCSSClass("channel-item-forum")
389
expander.SetHExpand(true)
390
expander.SetListRow(row)
391
expander.SetChild(label)
392
393
// GTK 4.10 or later only.
394
expander.SetObjectProperty("indent-for-depth", false)
395
396
return expander
397
}
398
399
var _ = cssutil.WriteCSS(`
400
.channels-viewtree row:not(:first-child) .channel-item-category-outer {
401
margin-top: 0.75em;
402
}
403
.channels-viewtree row:hover .channel-item-category-outer {
404
background: none;
405
}
406
.channel-item-category {
407
padding: 0.4em 0;
408
}
409
.channel-item-category label {
410
margin-bottom: -0.2em;
411
padding: 0;
412
font-size: 0.85em;
413
font-weight: 700;
414
text-transform: uppercase;
415
}
416
`)
417
418
func newChannelItemCategory(ch *discord.Channel, row *gtk.TreeListRow, reveal *app.TypedState[bool]) gtk.Widgetter {
419
label := gtk.NewLabel(ch.Name)
420
label.SetEllipsize(pango.EllipsizeEnd)
421
label.SetXAlign(0)
422
bindLabelTooltip(label, false)
423
424
expander := gtk.NewTreeExpander()
425
expander.AddCSSClass("channel-item")
426
expander.AddCSSClass("channel-item-category")
427
expander.SetHExpand(true)
428
expander.SetListRow(row)
429
expander.SetChild(label)
430
431
ref := glib.NewWeakRef[*gtk.TreeListRow](row)
432
chID := ch.ID
433
434
// Add this notifier after a small delay so GTK can initialize the row.
435
// Otherwise, it will falsely emit the signal.
436
glib.TimeoutSecondsAdd(1, func() {
437
row := ref.Get()
438
if row == nil {
439
return
440
}
441
442
row.NotifyProperty("expanded", func() {
443
row := ref.Get()
444
if row == nil {
445
return
446
}
447
448
// Only retain collapsed states. Expanded states are assumed to be
449
// the default.
450
if !row.Expanded() {
451
reveal.Set(chID.String(), true)
452
} else {
453
reveal.Delete(chID.String())
454
}
455
})
456
})
457
458
reveal.Get(ch.ID.String(), func(collapsed bool) {
459
if collapsed {
460
// GTK will actually explode if we set the expanded property without
461
// waiting for it to load for some reason?
462
glib.IdleAdd(func() { row.SetExpanded(false) })
463
}
464
})
465
466
return expander
467
}
468
469
var _ = cssutil.WriteCSS(`
470
.channel-item-voice .mauthor-chip {
471
margin: 0.15em 0;
472
margin-left: 2.5em;
473
margin-right: 1em;
474
}
475
.channel-item-voice .mauthor-chip:nth-child(2) {
476
margin-top: 0;
477
}
478
.channel-item-voice .mauthor-chip:last-child {
479
margin-bottom: 0.3em;
480
}
481
.channel-item-voice-counter {
482
margin-left: 0.5em;
483
margin-right: 0.5em;
484
font-size: 0.8em;
485
opacity: 0.75;
486
}
487
`)
488
489
func newChannelItemVoice(state *gtkcord.State, ch *discord.Channel) gtk.Widgetter {
490
icon := NewChannelIcon(ch)
491
492
label := gtk.NewLabel(ch.Name)
493
label.SetEllipsize(pango.EllipsizeEnd)
494
label.SetXAlign(0)
495
label.SetTooltipText(ch.Name)
496
497
top := gtk.NewBox(gtk.OrientationHorizontal, 0)
498
top.AddCSSClass("channel-item")
499
top.Append(icon)
500
top.Append(label)
501
502
var voiceParticipants int
503
voiceStates, _ := state.VoiceStates(ch.GuildID)
504
for _, voiceState := range voiceStates {
505
if voiceState.ChannelID == ch.ID {
506
voiceParticipants++
507
}
508
}
509
510
if voiceParticipants > 0 {
511
counter := gtk.NewLabel(fmt.Sprintf("%d", voiceParticipants))
512
counter.AddCSSClass("channel-item-voice-counter")
513
counter.SetVExpand(true)
514
counter.SetXAlign(0)
515
counter.SetYAlign(1)
516
top.Append(counter)
517
}
518
519
return top
520
521
// TODO: fix read indicator alignment. This probably should be in a separate
522
// ListModel instead.
523
524
// box := gtk.NewBox(gtk.OrientationVertical, 0)
525
// box.AddCSSClass("channel-item-voice")
526
// box.Append(top)
527
528
// voiceStates, _ := state.VoiceStates(ch.GuildID)
529
// for _, voiceState := range voiceStates {
530
// if voiceState.ChannelID == ch.ID {
531
// box.Append(newVoiceParticipant(state, voiceState))
532
// }
533
// }
534
535
// return box
536
}
537
538
func newVoiceParticipant(state *gtkcord.State, voiceState discord.VoiceState) gtk.Widgetter {
539
chip := author.NewChip(context.Background(), imgutil.HTTPProvider)
540
chip.Unpad()
541
542
member := voiceState.Member
543
if member == nil {
544
member, _ = state.Member(voiceState.GuildID, voiceState.UserID)
545
}
546
547
if member != nil {
548
chip.SetName(member.User.DisplayOrUsername())
549
chip.SetAvatar(gtkcord.InjectAvatarSize(member.AvatarURL(voiceState.GuildID)))
550
if color, ok := state.MemberColor(voiceState.GuildID, voiceState.UserID); ok {
551
chip.SetColor(color.String())
552
}
553
} else {
554
chip.SetName(voiceState.UserID.String())
555
}
556
557
return chip
558
}
559
560
func bindLabelTooltip(label *gtk.Label, markup bool) {
561
ref := glib.NewWeakRef(label)
562
label.NotifyProperty("label", func() {
563
label := ref.Get()
564
inner := label.Label()
565
if markup {
566
label.SetTooltipMarkup(inner)
567
} else {
568
label.SetTooltipText(inner)
569
}
570
})
571
}
572
573