diff --git a/go.mod b/go.mod index b9ee9e1d..9ba1bbbf 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,17 @@ module github.com/diamondburned/gtkcord4 go 1.17 +// replace github.com/diamondburned/ningen/v3 => ../../ningen +// replace github.com/diamondburned/arikawa/v3 => ../../arikawa +// replace github.com/diamondburned/gotkit => ../gotkit + require ( github.com/diamondburned/adaptive v0.0.2-0.20220328111603-4f867d7948b2 - github.com/diamondburned/arikawa/v3 v3.0.0-rc.6.0.20220408124721-4108d10b444c - github.com/diamondburned/chatkit v0.0.0-20220410003350-362494224d02 + github.com/diamondburned/arikawa/v3 v3.0.0-rc.6.0.20220412174302-bd0369136f51 + github.com/diamondburned/chatkit v0.0.0-20220412205050-65f5794df56f github.com/diamondburned/gotk4/pkg v0.0.0-20220408070453-08962439fbbc - github.com/diamondburned/gotkit v0.0.0-20220411012151-ec16933b1564 - github.com/diamondburned/ningen/v3 v3.0.0-20220411010635-498c8aa4f724 + github.com/diamondburned/gotkit v0.0.0-20220411230819-e3907853ff4e + github.com/diamondburned/ningen/v3 v3.0.0-20220412001139-82f55294365b ) require ( diff --git a/go.sum b/go.sum index 37d5c1c7..6347e921 100644 --- a/go.sum +++ b/go.sum @@ -34,19 +34,14 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/diamondburned/adaptive v0.0.2-0.20220226002257-ef8720b54399/go.mod h1:cLoCdQtpCp/TSJBJTgXe7W2FJNelXrmPv+X7luB6/Qg= github.com/diamondburned/adaptive v0.0.2-0.20220328111603-4f867d7948b2 h1:xMYuLPsjREtnGwJJik1BiNy28cU7QhGfp5rNbneBmdU= github.com/diamondburned/adaptive v0.0.2-0.20220328111603-4f867d7948b2/go.mod h1:cLoCdQtpCp/TSJBJTgXe7W2FJNelXrmPv+X7luB6/Qg= -github.com/diamondburned/arikawa/v3 v3.0.0-rc.6/go.mod h1:5jBSNnp82Z/EhsKa6Wk9FsOqSxfVkNZDTDBPOj47LpY= -github.com/diamondburned/arikawa/v3 v3.0.0-rc.6.0.20220408124721-4108d10b444c h1:c1Lhm8o6jmW6wpA7/9eNFKGvdvenNBToX+Pxpt9IAtc= -github.com/diamondburned/arikawa/v3 v3.0.0-rc.6.0.20220408124721-4108d10b444c/go.mod h1:5jBSNnp82Z/EhsKa6Wk9FsOqSxfVkNZDTDBPOj47LpY= -github.com/diamondburned/chatkit v0.0.0-20220410003350-362494224d02 h1:IczxKyNUM+qZCmyQVpaBO4oBO24xH7+uBubjUeepVv4= -github.com/diamondburned/chatkit v0.0.0-20220410003350-362494224d02/go.mod h1:QkyQFOy/ndA6XUgVm5Jw8Xu9BKjDw2GE8Zj+octTKF8= +github.com/diamondburned/chatkit v0.0.0-20220412205050-65f5794df56f h1:aAUMQYa21PZqkfCitj66TZH9p0v9X+T9hEebDVJY1P0= +github.com/diamondburned/chatkit v0.0.0-20220412205050-65f5794df56f/go.mod h1:QkyQFOy/ndA6XUgVm5Jw8Xu9BKjDw2GE8Zj+octTKF8= github.com/diamondburned/gotk4/pkg v0.0.0-20220225135826-2bb7260a63bb/go.mod h1:dJ2gfR0gvBsGg4IteP8aMBq/U5Q9boDw0DP7kAjXTwM= github.com/diamondburned/gotk4/pkg v0.0.0-20220408070453-08962439fbbc h1:BkDOBlc7okNAJL5KYPXD7ZB4pB3x23XeCh79wmVC5x8= github.com/diamondburned/gotk4/pkg v0.0.0-20220408070453-08962439fbbc/go.mod h1:rLH6FHos690jFgAM/GYEpMykuE/9NmN6zOvFlr8JTvE= github.com/diamondburned/gotkit v0.0.0-20220409081802-93f160de50f1/go.mod h1:TXAbYeGcMkR6RLo9ZlCR6W1lvD9ZhxA8iSS3KYEes3Q= -github.com/diamondburned/gotkit v0.0.0-20220411012151-ec16933b1564 h1:I8X4y8FBR+rjL9Y3nXX7n2Pe8+wq/Mz9WJ5Gs2BMO1o= -github.com/diamondburned/gotkit v0.0.0-20220411012151-ec16933b1564/go.mod h1:TXAbYeGcMkR6RLo9ZlCR6W1lvD9ZhxA8iSS3KYEes3Q= -github.com/diamondburned/ningen/v3 v3.0.0-20220411010635-498c8aa4f724 h1:5aDwei68fV5SUInrkhGBbadVGZYAZxrXMCOAo/KUk6k= -github.com/diamondburned/ningen/v3 v3.0.0-20220411010635-498c8aa4f724/go.mod h1:WLSUx3megnWk5I6rYLE+yPHN3iPJf4Nag2B5Y7l+Vmc= +github.com/diamondburned/gotkit v0.0.0-20220411230819-e3907853ff4e h1:TU7AzL0fGTgsnyfR72Qt27+AyQXI3K7pjlaoraljDTg= +github.com/diamondburned/gotkit v0.0.0-20220411230819-e3907853ff4e/go.mod h1:TXAbYeGcMkR6RLo9ZlCR6W1lvD9ZhxA8iSS3KYEes3Q= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= diff --git a/internal/gtkcord/dbus.go b/internal/gtkcord/dbus.go new file mode 100644 index 00000000..c4e85fda --- /dev/null +++ b/internal/gtkcord/dbus.go @@ -0,0 +1,12 @@ +package gtkcord + +import "github.com/diamondburned/arikawa/v3/discord" + +// IPC commands go here. + +// OpenChannelCommand is the data type for a command sent over DBus to open a +// message channel. Its action ID is app.open-channel. +type OpenChannelCommand struct { + ChannelID discord.ChannelID + MessageID discord.MessageID // optional, used to highlight message +} diff --git a/internal/gtkcord/message/content.go b/internal/gtkcord/message/content.go index 25ba4635..47a5578d 100644 --- a/internal/gtkcord/message/content.go +++ b/internal/gtkcord/message/content.go @@ -2,17 +2,16 @@ package message import ( "context" - "strings" "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/chatkit/components/author" "github.com/diamondburned/chatkit/md" - "github.com/diamondburned/chatkit/md/block" "github.com/diamondburned/chatkit/md/mdrender" "github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/pango" "github.com/diamondburned/gotkit/components/onlineimage" + "github.com/diamondburned/gotkit/gtkutil" "github.com/diamondburned/gotkit/gtkutil/cssutil" "github.com/diamondburned/gotkit/gtkutil/imgutil" "github.com/diamondburned/gotkit/gtkutil/textutil" @@ -68,13 +67,10 @@ func (c *Content) SetExtraMenu(menu gio.MenuModeller) { } func (c *Content) setMenu() { - // TODO: this doesn't cover embeds. Maybe just walking the widget tree is a - // far better choice. - state := c.view.State() - state.Walk(func(w block.WidgetBlock) bool { - if text, ok := w.(block.TextBlock); ok { - text := text.TextBlock() - text.SetExtraMenu(c.menu) + gtkutil.WalkWidget(c, func(w gtk.Widgetter) bool { + s, ok := w.(interface{ SetExtraWidget(gio.MenuModeller) }) + if ok { + s.SetExtraWidget(c.menu) } return false }) @@ -112,17 +108,12 @@ func (c *Content) Update(m *discord.Message, customs ...gtk.Widgetter) { chip.Unpad() topBox.Append(chip) - b := strings.Builder{} - s := []byte(msg.Content) - n := discordmd.ParseWithMessage(s, *state.Cabinet, msg, true) - discordmd.DefaultRenderer.Render(&b, s, n) - reply := gtk.NewLabel("") reply.AddCSSClass("message-reply-content") reply.SetEllipsize(pango.EllipsizeEnd) reply.SetXAlign(0) reply.SetSingleLineMode(true) - reply.SetText(b.String()) + reply.SetText(state.MessagePreview(msg)) replyBox.Append(reply) } @@ -137,9 +128,6 @@ func (c *Content) Update(m *discord.Message, customs ...gtk.Widgetter) { c.append(v) - c.view = v - c.setMenu() - for i := range m.Attachments { v := newAttachment(c.ctx, &m.Attachments[i]) c.append(v) @@ -153,6 +141,9 @@ func (c *Content) Update(m *discord.Message, customs ...gtk.Widgetter) { for _, custom := range customs { c.append(custom) } + + c.view = v + c.setMenu() } func (c *Content) append(w gtk.Widgetter) { diff --git a/internal/gtkcord/message/view.go b/internal/gtkcord/message/view.go index 745c6c6a..044634a1 100644 --- a/internal/gtkcord/message/view.go +++ b/internal/gtkcord/message/view.go @@ -251,10 +251,19 @@ func NewView(ctx context.Context, chID discord.ChannelID) *View { return v } +// GuildID returns the guild ID of the channel that the message view is +// displaying for. func (v *View) GuildID() discord.GuildID { return v.guildID } +// ChannelID returns the channel ID of the message view. +func (v *View) ChannelID() discord.ChannelID { + return v.chID +} + +// ChannelName returns the name of the channel that the message view is +// displaying for. func (v *View) ChannelName() string { return v.chName } @@ -586,11 +595,9 @@ func (v *View) Delete(id discord.MessageID) { } func (v *View) onScrollBottomed() { - win := app.GTKWindowFromContext(v.ctx.Take()) - if !win.IsActive() { + if !v.IsActive() { return } - v.MarkRead() } @@ -606,3 +613,10 @@ func (v *View) MarkRead() { state := gtkcord.FromContext(v.ctx.Take()) state.ReadState.MarkRead(v.chID, msg.ID) } + +// IsActive returns true if View is active and visible. This implies that the +// window is focused. +func (v *View) IsActive() bool { + win := app.GTKWindowFromContext(v.ctx.Take()) + return win.IsActive() && v.Mapped() +} diff --git a/internal/gtkcord/sidebar/channels/channels.go b/internal/gtkcord/sidebar/channels/channels.go index eb33f663..f2a83e2b 100644 --- a/internal/gtkcord/sidebar/channels/channels.go +++ b/internal/gtkcord/sidebar/channels/channels.go @@ -48,7 +48,8 @@ type View struct { tree *GuildTree cols []*gtk.TreeViewColumn - guildID discord.GuildID + guildID discord.GuildID + selectID discord.ChannelID // delegate to select later } var viewCSS = cssutil.Applier("channels-view", ` @@ -291,6 +292,22 @@ func NewView(ctx context.Context, ctrl Controller, guildID discord.GuildID) *Vie return &v } +// SelectChannel selects a known channel. If none is known, then it is selected +// later when the list is changed or never selected if the user selects +// something else. +func (v *View) SelectChannel(chID discord.ChannelID) { + if v.tree != nil { + node := v.tree.Node(chID) + if node != nil { + selection := v.Child.Tree.Selection() + selection.SelectPath(node.TreePath()) + return + } + } + + v.selectID = chID +} + // GuildID returns the view's guild ID. func (v *View) GuildID() discord.GuildID { return v.guildID @@ -327,7 +344,24 @@ func (v *View) InvalidateChannels() { } v.tree = NewGuildTree(v.ctx.Take()) + v.tree.ConnectRowInserted(func(path *gtk.TreePath, iter *gtk.TreeIter) { + if v.selectID.IsValid() { + node := v.tree.NodeFromPath(path) + if node == nil { + return + } + + if node.ID() == v.selectID { + // Found the channel that we want to select. + selection := v.Child.Tree.Selection() + selection.SelectPath(path) + + v.selectID = 0 + } + } + }) v.tree.Add(chs) + v.Child.Tree.SetModel(v.tree) v.setDone() diff --git a/internal/gtkcord/sidebar/channels/state.go b/internal/gtkcord/sidebar/channels/state.go index f33055bd..9051c97f 100644 --- a/internal/gtkcord/sidebar/channels/state.go +++ b/internal/gtkcord/sidebar/channels/state.go @@ -207,6 +207,12 @@ func (t *GuildTree) state() *gtkcord.State { return gtkcord.FromContext(t.ctx) } +// NodeFromPath quickly looks up the channel tree for a node from the given tree +// path. +func (t *GuildTree) NodeFromPath(path *gtk.TreePath) Node { + return t.paths[path.String()] +} + // Has returns true if the guild tree has the given channel. func (t *GuildTree) Has(id discord.ChannelID) bool { _, ok := t.nodes[id] diff --git a/internal/gtkcord/sidebar/direct/view.go b/internal/gtkcord/sidebar/direct/view.go index 5719ad38..c30e63f2 100644 --- a/internal/gtkcord/sidebar/direct/view.go +++ b/internal/gtkcord/sidebar/direct/view.go @@ -28,6 +28,7 @@ type ChannelView struct { ctx context.Context channels map[discord.ChannelID]*Channel + selectID discord.ChannelID // delegate to be selected later } // Controller is the parent controller that ChannelView controls. @@ -53,8 +54,11 @@ func NewChannelView(ctx context.Context, ctrl Controller) *ChannelView { v.list.SetHExpand(true) v.list.SetSortFunc(v.sort) v.list.SetFilterFunc(v.filter) - v.list.SetActivateOnSingleClick(true) - v.list.ConnectRowActivated(func(r *gtk.ListBoxRow) { + v.list.SetSelectionMode(gtk.SelectionBrowse) + v.list.ConnectRowSelected(func(r *gtk.ListBoxRow) { + // Invalidate our selection state. + v.selectID = 0 + ch := v.rowChannel(r) ctrl.OpenChannel(ch.id) }) @@ -96,6 +100,10 @@ func NewChannelView(ctx context.Context, ctrl Controller) *ChannelView { // TODO: Channel events switch ev := ev.(type) { + case *gateway.ChannelCreateEvent: + if !ev.GuildID.IsValid() { + v.Invalidate() // recreate everything + } case *gateway.ChannelDeleteEvent: v.deleteCh(ev.ID) case *gateway.MessageCreateEvent: @@ -110,6 +118,19 @@ func NewChannelView(ctx context.Context, ctrl Controller) *ChannelView { return &v } +// SelectChannel selects a known channel. If none is known, then it is selected +// later when the list is changed or never selected if the user selects +// something else. +func (v *ChannelView) SelectChannel(chID discord.ChannelID) { + ch, ok := v.channels[chID] + if !ok { + v.selectID = chID + return + } + + v.list.SelectRow(ch.ListBoxRow) +} + // Invalidate invalidates the whole channel view. func (v *ChannelView) Invalidate() { v.SetLoading() @@ -141,10 +162,11 @@ func (v *ChannelView) Invalidate() { ch.Update(&chs[i]) v.channels[channel.ID] = ch - v.list.Append(ch) if _, ok := keep[channel.ID]; ok { keep[channel.ID] = true + } else { + v.list.Append(ch) } } @@ -156,6 +178,14 @@ func (v *ChannelView) Invalidate() { } v.SetChild(v.box) + + // If we have a channel to be selectedd, then select it. + if v.selectID.IsValid() { + if ch, ok := v.channels[v.selectID]; ok { + v.list.SelectRow(ch.ListBoxRow) + v.selectID = 0 + } + } } func (v *ChannelView) deleteCh(id discord.ChannelID) { diff --git a/internal/gtkcord/sidebar/guilds/guilds.go b/internal/gtkcord/sidebar/guilds/guilds.go index f4eada94..1c675e31 100644 --- a/internal/gtkcord/sidebar/guilds/guilds.go +++ b/internal/gtkcord/sidebar/guilds/guilds.go @@ -246,6 +246,28 @@ func (v *View) Guild(id discord.GuildID) *Guild { return nil } +// SelectGuild selects the guild with the given ID. If the guild is not known, +// then the sidebar's guild view is closed. +func (v *View) SelectGuild(id discord.GuildID) { + guild := (*View)(v).Guild(id) + if guild == nil { + v.ctrl.CloseGuild(true) + return + } + + current := currentGuild{ + guild: guild, + folder: guild.ParentFolder(), + } + + if current != v.current { + (*View)(v).Unselect() + v.current = current + } + + v.ctrl.OpenGuild(id) +} + // Unselect unselects any guilds inside this guild view. Use this when the // window is showing a channel that's not from any guild. func (v *View) Unselect() { @@ -263,17 +285,5 @@ func (v *View) Unselect() { type guildOpenerView View func (v *guildOpenerView) OpenGuild(id discord.GuildID) { - guild := (*View)(v).Guild(id) - - current := currentGuild{ - guild: guild, - folder: guild.ParentFolder(), - } - - if current != v.current { - (*View)(v).Unselect() - v.current = current - } - - v.ctrl.OpenGuild(id) + (*View)(v).SelectGuild(id) } diff --git a/internal/gtkcord/sidebar/sidebar.go b/internal/gtkcord/sidebar/sidebar.go index cc5c5356..738d07d2 100644 --- a/internal/gtkcord/sidebar/sidebar.go +++ b/internal/gtkcord/sidebar/sidebar.go @@ -8,6 +8,7 @@ import ( "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotkit/gtkutil" "github.com/diamondburned/gotkit/gtkutil/cssutil" + "github.com/diamondburned/gtkcord4/internal/gtkcord" "github.com/diamondburned/gtkcord4/internal/gtkcord/sidebar/channels" "github.com/diamondburned/gtkcord4/internal/gtkcord/sidebar/direct" "github.com/diamondburned/gtkcord4/internal/gtkcord/sidebar/guilds" @@ -61,7 +62,10 @@ func NewSidebar(ctx context.Context, ctrl Controller) *Sidebar { s.Guilds = guilds.NewView(ctx, (*guildsSidebar)(&s)) s.Guilds.Invalidate() - s.DMButton = NewDMButton(ctx, s.openDMs) + s.DMButton = NewDMButton(ctx, func() { + direct := s.openDMs() + direct.Invalidate() + }) s.DMButton.Invalidate() dmSeparator := gtk.NewSeparator(gtk.OrientationHorizontal) @@ -105,6 +109,16 @@ func NewSidebar(ctx context.Context, ctrl Controller) *Sidebar { return &s } +// GuildID returns the guild ID that the channel list is showing for, if any. +// If not, 0 is returned. +func (s *Sidebar) GuildID() discord.GuildID { + ch, ok := s.current.w.(*channels.View) + if !ok { + return 0 + } + return ch.GuildID() +} + func (s *Sidebar) removeCurrent() { if s.current.w == nil { return @@ -127,43 +141,75 @@ func (s *Sidebar) removeCurrent() { }) } -func (s *Sidebar) openDMs() { +func (s *Sidebar) openDMs() *direct.ChannelView { + if direct, ok := s.current.w.(*direct.ChannelView); ok { + // we're already there + return direct + } + s.ctrl.CloseGuild(true) s.Guilds.Unselect() direct := direct.NewChannelView(s.ctx, s.ctrl) - direct.Invalidate() s.Right.AddChild(direct) s.Right.SetVisibleChild(direct) s.removeCurrent() s.current.w = direct -} -// guildsSidebar implements guilds.Controller. -type guildsSidebar Sidebar + return direct +} -func (s *guildsSidebar) OpenGuild(guildID discord.GuildID) { +func (s *Sidebar) openGuild(guildID discord.GuildID) *channels.View { s.DMButton.Pill.State = 0 s.DMButton.Pill.Invalidate() - if chs, ok := s.current.w.(*channels.View); ok && chs.GuildID() == guildID { + if ch, ok := s.current.w.(*channels.View); ok && ch.GuildID() == guildID { // We're already there. - return + return ch } s.ctrl.CloseGuild(true) ch := channels.NewView(s.ctx, s.ctrl, guildID) ch.InvalidateHeader() - ch.InvalidateChannels() s.Right.AddChild(ch) s.Right.SetVisibleChild(ch) s.removeCurrent() s.current.w = ch + + return ch +} + +// SelectChannel selects and activates the channel with the given ID. It ensures +// that the sidebar is at the right place then activates the controller. +func (s *Sidebar) SelectChannel(chID discord.ChannelID) { + state := gtkcord.FromContext(s.ctx) + ch, _ := state.Cabinet.Channel(chID) + if ch == nil { + return + } + + if ch.GuildID.IsValid() { + guild := s.openGuild(ch.GuildID) + guild.SelectChannel(chID) + guild.InvalidateChannels() + } else { + direct := s.openDMs() + direct.SelectChannel(chID) + direct.Invalidate() + } +} + +// guildsSidebar implements guilds.Controller. +type guildsSidebar Sidebar + +func (s *guildsSidebar) OpenGuild(guildID discord.GuildID) { + ch := (*Sidebar)(s).openGuild(guildID) + ch.InvalidateChannels() } // CloseGuild implements guilds.Controller. diff --git a/internal/gtkcord/state.go b/internal/gtkcord/state.go index 73c583c9..f35bda9f 100644 --- a/internal/gtkcord/state.go +++ b/internal/gtkcord/state.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "os" + "reflect" "runtime" "strconv" "strings" @@ -119,15 +120,22 @@ func (s *State) WithContext(ctx context.Context) *State { // BindHandler is similar to BindWidgetHandler, except the lifetime of the // handler is bound to the context. func (s *State) BindHandler(ctx gtkutil.Cancellable, fn func(gateway.Event), filters ...gateway.Event) { + eventTypes := make([]reflect.Type, len(filters)) + for i, filter := range filters { + eventTypes[i] = reflect.TypeOf(filter) + } ctx.OnRenew(func(context.Context) func() { return s.AddSyncHandler(func(ev gateway.Event) { // Optionally filter out events. - if len(filters) > 0 { - for _, filter := range filters { - if filter.Op() == ev.Op() && filter.EventType() == ev.EventType() { + if len(eventTypes) > 0 { + evType := reflect.TypeOf(ev) + + for _, typ := range eventTypes { + if typ == evType { goto filtered } } + return } @@ -188,6 +196,18 @@ noMember: return author.Markup(name, mods...) } +// MessagePreview renders the message into a short content string. +func (s *State) MessagePreview(msg *discord.Message) string { + b := strings.Builder{} + b.Grow(len(msg.Content)) + + src := []byte(msg.Content) + node := discordmd.ParseWithMessage(src, *s.Cabinet, msg, true) + discordmd.DefaultRenderer.Render(&b, src, node) + + return b.String() +} + // InjectAvatarSize calls InjectSize with size being 64px. func InjectAvatarSize(urlstr string) string { return InjectSize(urlstr, 64) diff --git a/internal/gtkcord/window/chat.go b/internal/gtkcord/window/chat.go index 7912443c..e4b07c2a 100644 --- a/internal/gtkcord/window/chat.go +++ b/internal/gtkcord/window/chat.go @@ -116,6 +116,23 @@ func (p *ChatPage) SwitchToPlaceholder() { p.RightChild.SetVisibleChild(p.placeholder) } +// SwitchToMessages reopens a new message page of the same channel ID if the +// user is opening one. Otherwise, the placeholder is seen. +func (p *ChatPage) SwitchToMessages() { + view, ok := p.prevView.(*message.View) + if ok { + p.OpenChannel(view.ChannelID()) + } else { + p.SwitchToPlaceholder() + } +} + +// OpenChannel opens the channel with the given ID. Use this method to direct +// the user to a new channel when they request to, e.g. through a notification. +func (p *ChatPage) OpenChannel(chID discord.ChannelID) { + p.Left.SelectChannel(chID) +} + func (p *ChatPage) switchTo(w gtk.Widgetter) { old := p.prevView p.prevView = w diff --git a/internal/gtkcord/window/login/login.go b/internal/gtkcord/window/login/login.go index 064e9ff1..e26ccf9c 100644 --- a/internal/gtkcord/window/login/login.go +++ b/internal/gtkcord/window/login/login.go @@ -15,14 +15,13 @@ import ( // LoginController is the parent controller that Page controls. type LoginController interface { + // Hook is called before the state is opened and before Ready is called. It + // is meant to be called for hooking the handlers. + Hook(*gtkcord.State) // Ready is called once the user has fully logged in. The session given to // the controller will have already been opened and have received the Ready // event. - Ready(state *gtkcord.State) - // Reconnecting is called by the login page to indicate that the session is - // currently not working. The parent controller should drop itself into the - // LoadingPage. - Reconnecting() + Ready(*gtkcord.State) // PromptLogin is called by the login page if the user needs to log in // again, either because their credentials are wrong or Discord returns a // server error. @@ -95,11 +94,10 @@ func (p *Page) asyncLoadFromSecrets(driver secret.Driver) { // asyncUseToken connects with the given token. If driver != nil, then the token // is stored. func (p *Page) asyncUseToken(token string) { - p.ctrl.Reconnecting() + state := gtkcord.Wrap(state.New(token)) + p.ctrl.Hook(state) gtkutil.Async(p.ctx, func() func() { - state := gtkcord.Wrap(state.New(token)) - if err := state.Open(p.ctx); err != nil { return func() { p.ctrl.PromptLogin() diff --git a/internal/gtkcord/window/window.go b/internal/gtkcord/window/window.go index 4666bbaa..a4dc8c47 100644 --- a/internal/gtkcord/window/window.go +++ b/internal/gtkcord/window/window.go @@ -4,11 +4,18 @@ import ( "context" "log" + "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/gateway" + "github.com/diamondburned/arikawa/v3/utils/ws" + "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotkit/app" + "github.com/diamondburned/gotkit/app/notify" + "github.com/diamondburned/gotkit/gtkutil" "github.com/diamondburned/gotkit/gtkutil/cssutil" "github.com/diamondburned/gtkcord4/internal/gtkcord" "github.com/diamondburned/gtkcord4/internal/gtkcord/window/login" + "github.com/diamondburned/ningen/v3" ) // Window is the main gtkcord window. @@ -57,6 +64,7 @@ func (w *Window) SwitchToChatPage() { w.Stack.AddChild(w.Chat) } w.Stack.SetVisibleChild(w.Chat) + w.Chat.SwitchToMessages() w.SetTitle("") } @@ -67,6 +75,87 @@ func (w *Window) SwitchToLoginPage() { type loginWindow Window +var monitorEvents = []gateway.Event{ + (*ningen.ConnectedEvent)(nil), + (*ningen.DisconnectedEvent)(nil), + (*ws.CloseEvent)(nil), + (*ws.BackgroundErrorEvent)(nil), + (*gateway.MessageCreateEvent)(nil), // notifications +} + +func (w *loginWindow) Hook(state *gtkcord.State) { + w.ctx = gtkcord.InjectState(w.ctx, state) + w.Reconnecting() + + ctx := gtkutil.WithCanceller(w.ctx) + ctx.Renew() + w.ConnectDestroy(ctx.Cancel) + + var reconnecting glib.SourceHandle + + // When the websocket closes, the screen must be changed to a busy one. The + // websocket may close if it's disconnected unexpectedly. + state.BindHandler(ctx, func(ev gateway.Event) { + switch ev := ev.(type) { + case *ningen.ConnectedEvent: + log.Println("connected:", ev.EventType()) + + // Cancel the 3s delay if we're already connected during that. + if reconnecting != 0 { + glib.SourceRemove(reconnecting) + reconnecting = 0 + } + + w.Connected() + + case *ws.BackgroundErrorEvent: + log.Println("warning: gateway:", ev) + + case *ws.CloseEvent: + log.Println("disconnected (*ws.CloseEvent), err:", ev.Err, ", code:", ev.Code) + + case *ningen.DisconnectedEvent: + log.Println("disconnected, err:", ev.Err, ", code:", ev.Code) + + if ev.IsLoggedOut() { + w.PromptLogin() + return + } + + // Add a 3s delay in case we have a sudden disruption that + // immediately recovers. + reconnecting = glib.TimeoutSecondsAdd(3, func() { + w.Reconnecting() + reconnecting = 0 + }) + + case *gateway.MessageCreateEvent: + mentions := state.MessageMentions(&ev.Message) + if mentions == 0 { + return + } + + if state.Status() == discord.DoNotDisturbStatus { + return + } + + avatarURL := gtkcord.InjectAvatarSize(ev.Author.AvatarURL()) + + notify.Send(w.ctx, notify.Notification{ + ID: notify.HashID(ev.ChannelID), + Title: gtkcord.ChannelNameFromID(w.ctx, ev.ChannelID), + Body: state.MessagePreview(&ev.Message), + Icon: notify.IconURL(w.ctx, avatarURL, "avatar-default-symbolic"), + Sound: notify.MessageSound, + Action: notify.ActionJSONData("app.open-channel", gtkcord.OpenChannelCommand{ + ChannelID: ev.ChannelID, + MessageID: ev.ID, + }), + }) + } + }, monitorEvents...) +} + func (w *loginWindow) Ready(state *gtkcord.State) { app := w.Application() app.ConnectShutdown(func() { @@ -76,9 +165,6 @@ func (w *loginWindow) Ready(state *gtkcord.State) { log.Println("error closing session:", err) } }) - - w.ctx = gtkcord.InjectState(w.ctx, state) - (*Window)(w).SwitchToChatPage() } func (w *loginWindow) Reconnecting() { @@ -86,6 +172,10 @@ func (w *loginWindow) Reconnecting() { w.SetTitle("Connecting") } +func (w *loginWindow) Connected() { + (*Window)(w).SwitchToChatPage() +} + func (w *loginWindow) PromptLogin() { (*Window)(w).SwitchToLoginPage() } diff --git a/main.go b/main.go index e76e4e0e..8e3958f2 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "github.com/diamondburned/adaptive" "github.com/diamondburned/gotkit/app" "github.com/diamondburned/gotkit/gtkutil/cssutil" + "github.com/diamondburned/gtkcord4/internal/gtkcord" "github.com/diamondburned/gtkcord4/internal/gtkcord/window" _ "github.com/diamondburned/gotkit/gtkutil/aggressivegc" @@ -24,6 +25,9 @@ func main() { m := manager{} app := app.New("com.github.diamondburned.gtkcord4", "gtkcord4") + app.AddJSONActions(map[string]interface{}{ + "app.open-channel": m.openChannel, + }) app.ConnectActivate(func() { m.activate(app.Context()) }) app.RunMain(context.Background()) } @@ -33,6 +37,15 @@ type manager struct { ctx context.Context } +func (m *manager) openChannel(cmd gtkcord.OpenChannelCommand) { + if m.Chat == nil { + return + } + + // TODO: highlight message. + m.Chat.OpenChannel(cmd.ChannelID) +} + func (m *manager) activate(ctx context.Context) { adaptive.Init() m.ctx = ctx