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