Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
diamondburned
GitHub Repository: diamondburned/gtkcord4
Path: blob/main/internal/window/chat.go
366 views
1
package window
2
3
import (
4
"context"
5
"fmt"
6
"log/slog"
7
"strings"
8
9
"github.com/diamondburned/adaptive"
10
"github.com/diamondburned/arikawa/v3/discord"
11
"github.com/diamondburned/arikawa/v3/gateway"
12
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
13
"github.com/diamondburned/gotk4/pkg/gdk/v4"
14
"github.com/diamondburned/gotk4/pkg/gio/v2"
15
"github.com/diamondburned/gotk4/pkg/gtk/v4"
16
"github.com/diamondburned/gotk4/pkg/pango"
17
"github.com/diamondburned/gotkit/app"
18
"github.com/diamondburned/gotkit/gtkutil"
19
"github.com/diamondburned/gotkit/gtkutil/cssutil"
20
"github.com/diamondburned/ningen/v3/states/read"
21
"libdb.so/ctxt"
22
"libdb.so/dissent/internal/gtkcord"
23
"libdb.so/dissent/internal/messages"
24
"libdb.so/dissent/internal/sidebar"
25
"libdb.so/dissent/internal/sidebar/channels"
26
"libdb.so/dissent/internal/window/backbutton"
27
"libdb.so/dissent/internal/window/quickswitcher"
28
)
29
30
var lastGuildKey = app.NewSingleStateKey[discord.GuildID]("last-guild-state")
31
var lastChannelKey = app.NewStateKey[discord.ChannelID]("guild-last-open")
32
33
// TODO: refactor this to support TabOverview. We do this by refactoring Sidebar
34
// out completely and merging it into ChatPage. We can then get rid of the logic
35
// to keep the Sidebar in sync with the ChatPage, since each tab will have its
36
// own Sidebar.
37
38
type ChatPage struct {
39
*adw.OverlaySplitView
40
Sidebar *sidebar.Sidebar
41
RightHeader *adw.HeaderBar
42
rightTitle *adw.Bin
43
44
tabView *adw.TabView
45
46
lastGuildState *app.TypedSingleState[discord.GuildID]
47
lastChannelState *app.TypedState[discord.ChannelID]
48
49
lastGuild discord.GuildID
50
51
// lastButtons keeps tracks of the header buttons of the previous view.
52
// On view change, these buttons will be removed.
53
lastButtons []gtk.Widgetter
54
55
tabs map[uintptr]*chatTab // K: *adw.TabPage
56
ctx context.Context
57
}
58
59
type chatPageView struct {
60
body gtk.Widgetter
61
headerButtons []gtk.Widgetter
62
}
63
64
var chatPageCSS = cssutil.Applier("window-chatpage", `
65
.window-chatpage-rightbox > .top-bar > windowhandle > .collapse-spacing {
66
padding: 0;
67
}
68
.right-header {
69
border-radius: 0;
70
box-shadow: none;
71
}
72
.right-header-label {
73
font-weight: bold;
74
}
75
.right-header-channel-icon {
76
margin-right: 4px;
77
}
78
`)
79
80
func NewChatPage(ctx context.Context, w *Window) *ChatPage {
81
p := ChatPage{
82
ctx: ctx,
83
tabs: make(map[uintptr]*chatTab),
84
lastGuildState: lastGuildKey.Acquire(ctx),
85
lastChannelState: lastChannelKey.Acquire(ctx),
86
}
87
88
p.tabView = adw.NewTabView()
89
p.tabView.AddCSSClass("window-chatpage-tabview")
90
p.tabView.SetDefaultIcon(gio.NewThemedIcon("channel-symbolic"))
91
p.tabView.NotifyProperty("selected-page", func() {
92
p.onActiveTabChange(p.tabView.SelectedPage())
93
})
94
p.tabView.ConnectClosePage(func(page *adw.TabPage) bool {
95
_, ok := p.tabs[page.Native()]
96
if ok {
97
delete(p.tabs, page.Native())
98
p.tabView.ClosePageFinish(page, true)
99
}
100
return gdk.EVENT_STOP
101
})
102
103
p.Sidebar = sidebar.NewSidebar(ctx)
104
p.Sidebar.SetHAlign(gtk.AlignStart)
105
106
p.rightTitle = adw.NewBin()
107
p.rightTitle.AddCSSClass("right-header-bin")
108
p.rightTitle.SetHExpand(true)
109
110
back := backbutton.New()
111
112
newTabButton := gtk.NewButtonFromIconName("list-add-symbolic")
113
newTabButton.SetTooltipText("Open a New Tab")
114
newTabButton.ConnectClicked(func() { p.newTab() })
115
116
p.RightHeader = adw.NewHeaderBar()
117
p.RightHeader.AddCSSClass("titlebar")
118
p.RightHeader.AddCSSClass("right-header")
119
p.RightHeader.SetShowStartTitleButtons(false)
120
p.RightHeader.SetShowEndTitleButtons(true)
121
p.RightHeader.SetShowBackButton(false) // this is useless with OverlaySplitView
122
p.RightHeader.SetShowTitle(false)
123
p.RightHeader.PackStart(back)
124
p.RightHeader.PackStart(p.rightTitle)
125
p.RightHeader.PackEnd(newTabButton)
126
127
tabBar := adw.NewTabBar()
128
tabBar.AddCSSClass("window-chatpage-tabbar")
129
tabBar.SetView(p.tabView)
130
tabBar.SetAutohide(true)
131
132
rightBox := adw.NewToolbarView()
133
rightBox.AddCSSClass("window-chatpage-rightbox")
134
rightBox.SetTopBarStyle(adw.ToolbarFlat)
135
rightBox.SetHExpand(true)
136
rightBox.AddTopBar(p.RightHeader)
137
rightBox.AddTopBar(tabBar)
138
rightBox.SetContent(p.tabView)
139
140
p.OverlaySplitView = adw.NewOverlaySplitView()
141
p.OverlaySplitView.SetSidebar(p.Sidebar)
142
p.OverlaySplitView.SetSidebarPosition(gtk.PackStart)
143
p.OverlaySplitView.SetContent(rightBox)
144
p.OverlaySplitView.SetEnableHideGesture(true)
145
p.OverlaySplitView.SetEnableShowGesture(true)
146
p.OverlaySplitView.SetMinSidebarWidth(200)
147
p.OverlaySplitView.SetMaxSidebarWidth(300)
148
p.OverlaySplitView.SetSidebarWidthFraction(0.5)
149
150
back.ConnectSplitView(p.OverlaySplitView)
151
152
breakpoint := adw.NewBreakpoint(adw.BreakpointConditionParse("max-width: 500sp"))
153
breakpoint.AddSetter(p.OverlaySplitView, "collapsed", true)
154
w.AddBreakpoint(breakpoint)
155
156
state := gtkcord.FromContext(ctx)
157
w.ConnectDestroy(state.AddHandler(
158
func(*gateway.MessageCreateEvent) { p.updateWindowTitle() },
159
func(*gateway.MessageUpdateEvent) { p.updateWindowTitle() },
160
func(*gateway.MessageDeleteEvent) { p.updateWindowTitle() },
161
func(*read.UpdateEvent) { p.updateWindowTitle() },
162
))
163
164
chatPageCSS(p)
165
return &p
166
}
167
168
// OpenQuickSwitcher opens the Quick Switcher dialog.
169
func (p *ChatPage) OpenQuickSwitcher() { quickswitcher.ShowDialog(p.ctx) }
170
171
// ResetView switches out of any channel view and into the placeholder view.
172
// This method is used when the guild becomes unavailable.
173
func (p *ChatPage) ResetView() { p.SwitchToPlaceholder() }
174
175
// SwitchToPlaceholder switches to the empty placeholder view.
176
func (p *ChatPage) SwitchToPlaceholder() {
177
tab := p.currentTab()
178
tab.switchToPlaceholder()
179
180
p.onActiveTabChange(p.tabView.Page(tab))
181
}
182
183
// SwitchToMessages reopens a new message page of the same channel ID if the
184
// user is opening one. Otherwise, the placeholder is seen.
185
func (p *ChatPage) SwitchToMessages() {
186
tab := p.currentTab()
187
tab.switchToPlaceholder()
188
189
// Restore the last opened channel if there is one.
190
p.lastGuildState.Get(func(id discord.GuildID) {
191
if id.IsValid() {
192
p.OpenGuild(id)
193
} else {
194
p.OpenDMs()
195
}
196
})
197
}
198
199
// OpenDMs opens the DMs page.
200
func (p *ChatPage) OpenDMs() {
201
p.lastGuild = 0
202
p.lastGuildState.Set(0)
203
p.Sidebar.OpenDMs()
204
p.restoreLastChannel(0)
205
}
206
207
// OpenGuild opens the guild with the given ID.
208
func (p *ChatPage) OpenGuild(guildID discord.GuildID) {
209
p.lastGuild = guildID
210
p.lastGuildState.Set(guildID)
211
p.Sidebar.SetSelectedGuild(guildID)
212
p.restoreLastChannel(guildID)
213
}
214
215
func (p *ChatPage) restoreLastChannel(guildID discord.GuildID) {
216
k := guildID.String()
217
p.lastChannelState.Exists(k, func(exists bool) {
218
if exists {
219
p.lastChannelState.Get(k, func(chID discord.ChannelID) {
220
slog.Debug(
221
"restoring last channel from state",
222
"guild_id", guildID,
223
"restored_channel_id", chID)
224
p.OpenChannel(chID)
225
})
226
} else {
227
p.SwitchToPlaceholder()
228
}
229
})
230
}
231
232
// OpenChannel opens the channel with the given ID. Use this method to direct
233
// the user to a new channel when they request to, e.g. through a notification.
234
func (p *ChatPage) OpenChannel(chID discord.ChannelID) {
235
var tab *chatTab
236
var reselect bool
237
for _, t := range p.tabs {
238
if t.alreadyOpens(chID) {
239
tab = t
240
reselect = true
241
break
242
}
243
}
244
if tab == nil {
245
tab = p.currentTab()
246
}
247
248
// Open the channel in the message view.
249
tab.switchToChannel(chID)
250
251
page := p.tabView.Page(tab)
252
updateTabInfo(p.ctx, page, chID)
253
if reselect {
254
p.tabView.SetSelectedPage(page)
255
}
256
257
// This method updates the tab and window, but it also updates the sidebar
258
// selection internally.
259
p.onActiveTabChange(page)
260
261
state := gtkcord.FromContext(p.ctx).Offline()
262
ch, _ := state.Channel(chID)
263
if ch != nil {
264
// Save the last opened channel for the guild.
265
p.lastChannelState.Set(ch.GuildID.String(), chID)
266
}
267
}
268
269
func updateTabInfo(ctx context.Context, page *adw.TabPage, chID discord.ChannelID) {
270
if chID.IsValid() {
271
page.SetIcon(gio.NewThemedIcon("channel-symbolic"))
272
273
title := gtkcord.WindowTitleFromID(ctx, chID)
274
// We don't actually want the prefixing # because we already have the
275
// tab icon.
276
title = strings.TrimPrefix(title, "#")
277
page.SetTitle(title)
278
} else {
279
page.SetIcon(nil)
280
page.SetTitle("New Tab")
281
}
282
}
283
284
// currentTab returns the current tab. If there is no tab, then it creates one.
285
func (p *ChatPage) currentTab() *chatTab {
286
var tab *chatTab
287
288
page := p.tabView.SelectedPage()
289
if page != nil {
290
// We already have a tab.
291
// Ensure our window gets updated by the end.
292
tab = p.tabs[page.Native()]
293
} else {
294
// We don't have an active tab right now. Create one.
295
tab = p.newTab()
296
}
297
298
return tab
299
}
300
301
func (p *ChatPage) newTab() *chatTab {
302
tab := newChatTab(p.ctx)
303
304
page := p.tabView.Append(tab)
305
updateTabInfo(p.ctx, page, 0)
306
307
p.tabs[page.Native()] = tab
308
p.tabView.SetSelectedPage(page)
309
310
return tab
311
}
312
313
func (p *ChatPage) onActiveTabChange(page *adw.TabPage) {
314
// Remove the previous header buttons.
315
for _, button := range p.lastButtons {
316
p.RightHeader.Remove(button)
317
}
318
p.lastButtons = nil
319
320
p.updateWindowTitle()
321
322
var tab *chatTab
323
var chID discord.ChannelID
324
325
if page != nil {
326
tab = p.tabs[page.Native()]
327
if tab == nil {
328
// Ignore this. It's possible that we're still initializing.
329
return
330
}
331
332
chID = tab.channelID()
333
334
// Add the new header buttons.
335
if tab.messageView != nil {
336
p.lastButtons = tab.messageView.HeaderButtons()
337
for i := len(p.lastButtons) - 1; i >= 0; i-- {
338
button := p.lastButtons[i]
339
p.RightHeader.PackEnd(button)
340
}
341
}
342
}
343
344
// Update the left guild list and channel list.
345
if chID.IsValid() {
346
// TODO: it really has to get rid of this SelectChannel call...
347
// It's really hard for it to try and have a SetSelectedChannel function
348
// because of how the SelectionChanged signal works.
349
p.Sidebar.SelectChannel(chID)
350
} else {
351
// Hack to ensure that the guild item is selected when we have no
352
// channel on display.
353
if p.lastGuild.IsValid() {
354
p.Sidebar.SetSelectedGuild(p.lastGuild)
355
}
356
}
357
358
// Update the displaying window title.
359
if !chID.IsValid() {
360
p.rightTitle.SetChild(nil)
361
return
362
}
363
364
state := gtkcord.FromContext(p.ctx)
365
ch, _ := state.Cabinet.Channel(chID)
366
367
chName := gtkcord.ChannelNameWithoutHash(ch)
368
label := gtk.NewLabel(chName)
369
label.AddCSSClass("right-header-label")
370
label.SetEllipsize(pango.EllipsizeEnd)
371
label.SetXAlign(0)
372
373
chIcon := channels.NewChannelIcon(ch)
374
chIcon.AddCSSClass("right-header-channel-icon")
375
376
box := gtk.NewBox(gtk.OrientationHorizontal, 0)
377
box.AddCSSClass("right-header-channel-box")
378
box.Append(chIcon)
379
box.Append(label)
380
381
p.rightTitle.SetChild(box)
382
}
383
384
func (p *ChatPage) updateWindowTitle() {
385
var title string
386
if page := p.tabView.SelectedPage(); page != nil {
387
title = page.Title()
388
}
389
390
state := gtkcord.FromContext(p.ctx)
391
392
// Add a ping indicator if the user has pings.
393
mentions := state.ReadState.TotalMentionCount()
394
if mentions > 0 {
395
title = fmt.Sprintf("(%d) %s", mentions, title)
396
}
397
398
win, _ := ctxt.From[*Window](p.ctx)
399
win.SetTitle(title)
400
}
401
402
type chatTab struct {
403
*gtk.Stack
404
placeholder gtk.Widgetter
405
messageView *messages.View // nilable
406
ctx context.Context
407
}
408
409
func newChatTab(ctx context.Context) *chatTab {
410
var t chatTab
411
t.ctx = ctx
412
t.placeholder = newEmptyMessagePlaceholder()
413
414
t.Stack = gtk.NewStack()
415
t.Stack.AddCSSClass("window-message-page")
416
t.Stack.SetTransitionType(gtk.StackTransitionTypeCrossfade)
417
t.Stack.AddChild(t.placeholder)
418
t.Stack.SetVisibleChild(t.placeholder)
419
420
return &t
421
}
422
423
func (t *chatTab) alreadyOpens(id discord.ChannelID) bool {
424
return t.channelID() == id
425
}
426
427
func (t *chatTab) channelID() discord.ChannelID {
428
if t.messageView == nil {
429
return 0
430
}
431
return t.messageView.ChannelID()
432
}
433
434
func (t *chatTab) switchToPlaceholder() bool {
435
return t.switchToChannel(0)
436
}
437
438
func (t *chatTab) switchToChannel(id discord.ChannelID) bool {
439
if t.alreadyOpens(id) {
440
return false
441
}
442
443
old := t.messageView
444
445
if id.IsValid() {
446
t.messageView = messages.NewView(t.ctx, id)
447
t.messageView.FetchBacklog()
448
449
t.Stack.AddChild(t.messageView)
450
t.Stack.SetVisibleChild(t.messageView)
451
452
viewWidget := gtk.BaseWidget(t.messageView)
453
viewWidget.GrabFocus()
454
} else {
455
t.messageView = nil
456
t.Stack.SetVisibleChild(t.placeholder)
457
}
458
459
if old != nil {
460
gtkutil.NotifyProperty(t.Stack, "transition-running", func() bool {
461
if !t.Stack.TransitionRunning() {
462
t.Stack.Remove(old)
463
return true
464
}
465
return false
466
})
467
}
468
469
return true
470
}
471
472
func newEmptyMessagePlaceholder() gtk.Widgetter {
473
status := adaptive.NewStatusPage()
474
status.SetIconName("chat-bubbles-empty-symbolic")
475
status.Icon.SetOpacity(0.45)
476
status.Icon.SetIconSize(gtk.IconSizeLarge)
477
478
return status
479
}
480
481