From 43aa4dcef31240db86526702080a5eccabd6e189 Mon Sep 17 00:00:00 2001 From: "diamondburned (Forefront)" Date: Sat, 6 Jun 2020 21:27:28 -0700 Subject: [PATCH] Session restore and icons added --- go.mod | 2 + go.sum | 2 + internal/gts/gts.go | 18 +-- internal/gts/httputil/image.go | 88 ++++++++----- internal/keyring/keyring.go | 57 ++------ .../ui/messages/container/compact/compact.go | 12 +- .../ui/messages/container/compact/message.go | 28 ++-- internal/ui/messages/container/container.go | 123 +++++++++++------- internal/ui/messages/container/cozy/cozy.go | 61 ++++++++- .../ui/messages/container/cozy/message.go | 20 --- .../container/cozy/message_compact.go | 11 ++ .../messages/container/cozy/message_full.go | 116 +++++++++++++++++ internal/ui/messages/input/input.go | 55 +------- internal/ui/messages/input/username.go | 115 ++++++++++++++++ internal/ui/messages/message/message.go | 13 +- internal/ui/messages/message/sending.go | 10 +- internal/ui/messages/view.go | 5 +- internal/ui/primitives/primitives.go | 18 +++ internal/ui/rich/image.go | 25 +++- internal/ui/rich/rich.go | 12 +- internal/ui/service/children.go | 30 +++++ internal/ui/service/header.go | 60 +++++++++ internal/ui/service/service.go | 114 +++++++--------- internal/ui/service/session/server/server.go | 2 + internal/ui/service/session/session.go | 73 ++++++++++- internal/ui/ui.go | 52 +++++++- 26 files changed, 800 insertions(+), 322 deletions(-) delete mode 100644 internal/ui/messages/container/cozy/message.go create mode 100644 internal/ui/messages/container/cozy/message_compact.go create mode 100644 internal/ui/messages/container/cozy/message_full.go create mode 100644 internal/ui/messages/input/username.go create mode 100644 internal/ui/service/children.go create mode 100644 internal/ui/service/header.go diff --git a/go.mod b/go.mod index 1ce794f..e831bf3 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.14 replace github.com/diamondburned/cchat-mock => ../cchat-mock/ +replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a + require ( github.com/Xuanwo/go-locale v0.2.0 github.com/diamondburned/cchat v0.0.15 diff --git a/go.sum b/go.sum index 375547e..83e89b2 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/diamondburned/cchat v0.0.15 h1:1o4OX8zw/CdSv3Idaylz7vjHVOZKEi/xkg8BpEvtsHY= github.com/diamondburned/cchat v0.0.15/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a h1:pH6WLWVhzzvpXjGwPQbPhhp1g0ZMLZFS5S8zLoRGYRg= +github.com/diamondburned/gotk3 v0.0.0-20200606223630-b0c33ec7b10a/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= github.com/diamondburned/imgutil v0.0.0-20200606035324-63abbc0fdea6 h1:APALM1hskCByjOVW9CoUwjg0TIJgKZ62dgFr/9soqss= github.com/diamondburned/imgutil v0.0.0-20200606035324-63abbc0fdea6/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ= github.com/die-net/lrucache v0.0.0-20190707192454-883874fe3947 h1:U/5Sq2nJQ0XDyks+8ATghtHSuquIGq7JYrqSrvtR2dg= diff --git a/internal/gts/gts.go b/internal/gts/gts.go index 357a389..3f7b28b 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -2,9 +2,9 @@ package gts import ( "os" - "sync" "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" ) @@ -12,7 +12,6 @@ import ( const AppID = "com.github.diamondburned.cchat-gtk" var Args = append([]string{}, os.Args...) -var recvPool *sync.Pool var App struct { *gtk.Application @@ -22,13 +21,6 @@ var App struct { func init() { gtk.Init(&Args) - - recvPool = &sync.Pool{ - New: func() interface{} { - return make(chan struct{}) - }, - } - App.Application, _ = gtk.ApplicationNew(AppID, 0) } @@ -98,8 +90,7 @@ func ExecAsync(fn func()) { // ExecSync executes the function asynchronously, but returns a channel that // indicates when the job is done. func ExecSync(fn func()) <-chan struct{} { - ch := recvPool.Get().(chan struct{}) - defer recvPool.Put(ch) + var ch = make(chan struct{}) glib.IdleAdd(func() { fn() @@ -108,3 +99,8 @@ func ExecSync(fn func()) <-chan struct{} { return ch } + +func EventIsRightClick(ev *gdk.Event) bool { + keyev := gdk.EventButtonNewFromEvent(ev) + return keyev.Type() == gdk.EVENT_BUTTON_PRESS && keyev.Button() == gdk.BUTTON_SECONDARY +} diff --git a/internal/gts/httputil/image.go b/internal/gts/httputil/image.go index dc72d05..619c94b 100644 --- a/internal/gts/httputil/image.go +++ b/internal/gts/httputil/image.go @@ -8,16 +8,68 @@ import ( "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/imgutil" "github.com/gotk3/gotk3/gdk" - "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" ) -// AsyncImage loads an image. This method uses the cache. -func AsyncImage(img *gtk.Image, url string, procs ...imgutil.Processor) { - go asyncImage(img, url, procs...) +type ImageContainer interface { + SetFromPixbuf(*gdk.Pixbuf) + SetFromAnimation(*gdk.PixbufAnimation) } -func asyncImage(img *gtk.Image, url string, procs ...imgutil.Processor) { +type ImageContainerSizer interface { + ImageContainer + SetSizeRequest(w, h int) +} + +// AsyncImage loads an image. This method uses the cache. +func AsyncImage(img ImageContainer, url string, procs ...imgutil.Processor) { + go syncImageFn(img, url, procs, func(l *gdk.PixbufLoader, gif bool) { + l.Connect("area-prepared", areaPreparedFn(img, gif)) + }) +} + +// AsyncImageSized resizes using GdkPixbuf. This method does not use the cache. +func AsyncImageSized(img ImageContainerSizer, url string, w, h int, procs ...imgutil.Processor) { + go syncImageFn(img, url, procs, func(l *gdk.PixbufLoader, gif bool) { + l.Connect("size-prepared", func(l *gdk.PixbufLoader, imgW, imgH int) { + w, h = imgutil.MaxSize(imgW, imgH, w, h) + if w != imgW || h != imgH { + l.SetSize(w, h) + img.SetSizeRequest(w, h) + } + }) + + l.Connect("area-prepared", areaPreparedFn(img, gif)) + }) +} + +func areaPreparedFn(img ImageContainer, gif bool) func(l *gdk.PixbufLoader) { + return func(l *gdk.PixbufLoader) { + if !gif { + p, err := l.GetPixbuf() + if err != nil { + log.Error(errors.Wrap(err, "Failed to get pixbuf")) + return + } + gts.ExecAsync(func() { img.SetFromPixbuf(p) }) + } else { + p, err := l.GetAnimation() + if err != nil { + log.Error(errors.Wrap(err, "Failed to get animation")) + return + } + gts.ExecAsync(func() { img.SetFromAnimation(p) }) + } + } +} + +func syncImageFn( + img ImageContainer, + url string, + procs []imgutil.Processor, + middle func(l *gdk.PixbufLoader, gif bool), +) { + r, err := get(url, true) if err != nil { log.Error(err) @@ -36,24 +88,8 @@ func asyncImage(img *gtk.Image, url string, procs ...imgutil.Processor) { // This is a very important signal, so we must do it synchronously. Gotk3's // callback implementation requires all connects to be synchronous to a // certain thread. - gts.ExecSync(func() { - l.Connect("area-prepared", func() { - if gif { - p, err := l.GetPixbuf() - if err != nil { - log.Error(errors.Wrap(err, "Failed to get pixbuf")) - return - } - img.SetFromPixbuf(p) - } else { - p, err := l.GetAnimation() - if err != nil { - log.Error(errors.Wrap(err, "Failed to get animation")) - return - } - img.SetFromAnimation(p) - } - }) + <-gts.ExecSync(func() { + middle(l, gif) }) // If we have processors, then write directly in there. @@ -77,9 +113,3 @@ func asyncImage(img *gtk.Image, url string, procs ...imgutil.Processor) { log.Error(errors.Wrap(err, "Failed to close pixbuf")) } } - -// AsyncImageSized resizes using GdkPixbuf. This method does not use the cache. -func AsyncImageSized(img *gtk.Image, url string, w, h int, procs ...imgutil.Processor) { - // TODO - panic("TODO") -} diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index 2d86319..54c4a7b 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -5,14 +5,12 @@ import ( "encoding/gob" "strings" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/log" "github.com/pkg/errors" "github.com/zalando/go-keyring" ) -func getThenDestroy(service string, v interface{}) error { +func get(service string, v interface{}) error { s, err := keyring.Get("cchat-gtk", service) if err != nil { return err @@ -33,57 +31,24 @@ func set(service string, v interface{}) error { return keyring.Set("cchat-gtk", service, b.String()) } -func SaveSessions(service cchat.Service, sessions []cchat.Session) (saveErrs []error) { - var sessionData = make([]map[string]string, 0, len(sessions)) +type Session struct { + ID string + Name string + Data map[string]string +} - for _, session := range sessions { - sv, ok := session.(cchat.SessionSaver) - if !ok { - continue - } - - d, err := sv.Save() - if err != nil { - saveErrs = append(saveErrs, err) - continue - } - - sessionData = append(sessionData, d) - } - - if err := set(service.Name(), sessionData); err != nil { +func SaveSessions(serviceName string, sessions []Session) { + if err := set(serviceName, sessions); err != nil { log.Warn(errors.Wrap(err, "Error saving session")) - saveErrs = append(saveErrs, err) } - - return } // RestoreSessions restores all sessions of the service asynchronously, then // calls the auth callback inside the Gtk main thread. -func RestoreSessions(service cchat.Service, auth func(cchat.Session)) { - // If the service doesn't support restoring, treat it as a non-error. - restorer, ok := service.(cchat.SessionRestorer) - if !ok { - return - } - - var sessionData []map[string]string - +func RestoreSessions(serviceName string) (sessions []Session) { // Ignore the error, it's not important. - if err := getThenDestroy(service.Name(), &sessionData); err != nil { + if err := get(serviceName, &sessions); err != nil { log.Warn(err) - return - } - - for _, data := range sessionData { - gts.Async(func() (func(), error) { - s, err := restorer.RestoreSession(data) - if err != nil { - return nil, errors.Wrap(err, "Failed to restore") - } - - return func() { auth(s) }, nil - }) } + return } diff --git a/internal/ui/messages/container/compact/compact.go b/internal/ui/messages/container/compact/compact.go index 0bc4216..06db3b7 100644 --- a/internal/ui/messages/container/compact/compact.go +++ b/internal/ui/messages/container/compact/compact.go @@ -11,15 +11,17 @@ type Container struct { } func NewContainer() *Container { - c := &Container{} - c.GridContainer = container.NewGridContainer(c) - return c + return &Container{ + GridContainer: container.NewGridContainer(constructor{}), + } } -func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage { +type constructor struct{} + +func (constructor) NewMessage(msg cchat.MessageCreate) container.GridMessage { return NewMessage(msg) } -func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage { +func (constructor) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage { return NewPresendMessage(msg) } diff --git a/internal/ui/messages/container/compact/message.go b/internal/ui/messages/container/compact/message.go index 9bf48b9..1b5f0eb 100644 --- a/internal/ui/messages/container/compact/message.go +++ b/internal/ui/messages/container/compact/message.go @@ -9,17 +9,17 @@ import ( ) type PresendMessage struct { - *message.GenericPresendContainer + message.PresendContainer + Message } func NewPresendMessage(msg input.PresendMessage) PresendMessage { - return PresendMessage{ - GenericPresendContainer: message.NewPresendContainer(msg), - } -} + var msgc = message.NewPresendContainer(msg) -func (p PresendMessage) Attach(grid *gtk.Grid, row int) { - attachGenericContainer(p.GenericContainer, grid, row) + return PresendMessage{ + PresendContainer: msgc, + Message: Message{msgc.GenericContainer}, + } } type Message struct { @@ -29,15 +29,13 @@ type Message struct { var _ container.GridMessage = (*Message)(nil) func NewMessage(msg cchat.MessageCreate) Message { - return Message{ - GenericContainer: message.NewContainer(msg), - } + msgc := message.NewContainer(msg) + message.FillContainer(msgc, msg) + return Message{msgc} } func NewEmptyMessage() Message { - return Message{ - GenericContainer: message.NewEmptyContainer(), - } + return Message{message.NewEmptyContainer()} } // TODO: fix a bug here related to new messages overlapping @@ -46,7 +44,5 @@ func (m Message) Attach(grid *gtk.Grid, row int) { } func attachGenericContainer(m *message.GenericContainer, grid *gtk.Grid, row int) { - grid.Attach(m.Timestamp, 0, row, 1, 1) - grid.Attach(m.Username, 1, row, 1, 1) - grid.Attach(m.Content, 2, row, 1, 1) + container.AttachRow(grid, row, m.Timestamp, m.Username, m.Content) } diff --git a/internal/ui/messages/container/container.go b/internal/ui/messages/container/container.go index 6c992e5..9f0828d 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -21,13 +21,6 @@ type PresendGridMessage interface { message.PresendContainer } -// gridMessage w/ required internals -type gridMessage struct { - GridMessage - presend message.PresendContainer // this shouldn't be here but i'm lazy - index int -} - // Constructor is an interface for making custom message implementations which // allows GridContainer to generically work with. type Constructor interface { @@ -47,6 +40,14 @@ type Container interface { PresendMessage(input.PresendMessage) (done func(sendError error)) } +func AttachRow(grid *gtk.Grid, row int, widgets ...gtk.IWidget) { + for i, w := range widgets { + grid.Attach(w, i, row, 1, 1) + } +} + +const ColumnSpacing = 10 + // GridContainer is an implementation of Container, which allows flexible // message grids. type GridContainer struct { @@ -55,8 +56,15 @@ type GridContainer struct { construct Constructor - messages map[string]*gridMessage - nonceMsgs map[string]*gridMessage + messages []*gridMessage // sync w/ grid rows + messageIDs map[string]int + nonceMsgs map[string]int +} + +// gridMessage w/ required internals +type gridMessage struct { + GridMessage + presend message.PresendContainer // this shouldn't be here but i'm lazy } var ( @@ -66,7 +74,7 @@ var ( func NewGridContainer(constr Constructor) *GridContainer { grid, _ := gtk.GridNew() - grid.SetColumnSpacing(10) + grid.SetColumnSpacing(ColumnSpacing) grid.SetRowSpacing(5) grid.SetMarginStart(5) grid.SetMarginEnd(5) @@ -82,42 +90,42 @@ func NewGridContainer(constr Constructor) *GridContainer { ScrolledWindow: sw, Main: grid, construct: constr, - messages: map[string]*gridMessage{}, - nonceMsgs: map[string]*gridMessage{}, + messageIDs: map[string]int{}, + nonceMsgs: map[string]int{}, } return &container } func (c *GridContainer) Reset() { - // does this actually work? - var rows = c.len() - for i := 0; i < rows; i++ { - c.Main.RemoveRow(i) - } + c.Main.GetChildren().Foreach(func(v interface{}) { + // Unsafe assertion ftw. + c.Main.Remove(v.(gtk.IWidget)) + }) - c.messages = map[string]*gridMessage{} - c.nonceMsgs = map[string]*gridMessage{} + c.messages = nil + c.messageIDs = map[string]int{} + c.nonceMsgs = map[string]int{} c.ScrolledWindow.Bottomed = true } -func (c *GridContainer) len() int { - return len(c.messages) + len(c.nonceMsgs) -} - // PresendMessage is not thread-safe. func (c *GridContainer) PresendMessage(msg input.PresendMessage) func(error) { presend := c.construct.NewPresendMessage(msg) - msgc := gridMessage{ + msgc := &gridMessage{ GridMessage: presend, presend: presend, - index: c.len(), } - c.nonceMsgs[presend.Nonce()] = &msgc - msgc.Attach(c.Main, msgc.index) + // Grab index before appending, as that'll be where the added message is. + index := len(c.messages) + + c.messages = append(c.messages, msgc) + + c.nonceMsgs[presend.Nonce()] = index + msgc.Attach(c.Main, index) return func(err error) { if err != nil { @@ -127,19 +135,31 @@ func (c *GridContainer) PresendMessage(msg input.PresendMessage) func(error) { } } -// FindMessage is not thread-safe. This exists for backwards compatibility. -func (c *GridContainer) FindMessage(msg cchat.MessageHeader) GridMessage { - if m := c.findMessage(msg); m != nil { +// FindMessage iterates backwards and returns the message if isMessage() returns +// true on that message. +func (c *GridContainer) FindMessage(isMessage func(msg GridMessage) bool) GridMessage { + for i := len(c.messages) - 1; i >= 0; i-- { + if msg := c.messages[i].GridMessage; isMessage(msg) { + return msg + } + } + return nil +} + +// Message finds the message state in the container. It is not thread-safe. This +// exists for backwards compatibility. +func (c *GridContainer) Message(msg cchat.MessageHeader) GridMessage { + if m := c.message(msg); m != nil { return m.GridMessage } return nil } -func (c *GridContainer) findMessage(msg cchat.MessageHeader) *gridMessage { +func (c *GridContainer) message(msg cchat.MessageHeader) *gridMessage { // Search using the ID first. - m, ok := c.messages[msg.ID()] + i, ok := c.messageIDs[msg.ID()] if ok { - return m + return c.messages[i] } // Is this an existing message? @@ -147,11 +167,14 @@ func (c *GridContainer) findMessage(msg cchat.MessageHeader) *gridMessage { var nonce = noncer.Nonce() // Things in this map are guaranteed to have presend != nil. - m, ok := c.nonceMsgs[nonce] + i, ok := c.nonceMsgs[nonce] if ok { - // Move the message outside nonceMsgs. + // Move the message outside nonceMsgs and into messageIDs. delete(c.nonceMsgs, nonce) - c.messages[msg.ID()] = m + c.messageIDs[msg.ID()] = i + + // Get the message pointer. + m := c.messages[i] // Set the right ID. m.presend.SetID(msg.ID()) @@ -168,27 +191,31 @@ func (c *GridContainer) findMessage(msg cchat.MessageHeader) *gridMessage { func (c *GridContainer) CreateMessage(msg cchat.MessageCreate) { gts.ExecAsync(func() { - // Attempt update before insert (aka upsert). - if msgc := c.FindMessage(msg); msgc != nil { + // Attempt to update before insertion (aka upsert). + if msgc := c.Message(msg); msgc != nil { msgc.UpdateAuthor(msg.Author()) msgc.UpdateContent(msg.Content()) msgc.UpdateTimestamp(msg.Time()) return } - msgc := gridMessage{ + msgc := &gridMessage{ GridMessage: c.construct.NewMessage(msg), - index: c.len(), } - c.messages[msgc.ID()] = &msgc - msgc.Attach(c.Main, msgc.index) + // Grab index before appending, as that'll be where the added message is. + index := len(c.messages) + + c.messages = append(c.messages, msgc) + + c.messageIDs[msgc.ID()] = index + msgc.Attach(c.Main, index) }) } func (c *GridContainer) UpdateMessage(msg cchat.MessageUpdate) { gts.ExecAsync(func() { - if msgc := c.FindMessage(msg); msgc != nil { + if msgc := c.Message(msg); msgc != nil { if author := msg.Author(); author != nil { msgc.UpdateAuthor(author) } @@ -202,9 +229,13 @@ func (c *GridContainer) UpdateMessage(msg cchat.MessageUpdate) { func (c *GridContainer) DeleteMessage(msg cchat.MessageDelete) { gts.ExecAsync(func() { // TODO: add nonce check. - if m, ok := c.messages[msg.ID()]; ok { - delete(c.messages, msg.ID()) - c.Main.RemoveRow(m.index) + if i, ok := c.messageIDs[msg.ID()]; ok { + // Remove off the slice. + c.messages = append(c.messages[:i], c.messages[i+1:]...) + + // Remove off the map. + delete(c.messageIDs, msg.ID()) + c.Main.RemoveRow(i) } }) } diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index 05d6989..43edcfa 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -1,13 +1,62 @@ package cozy import ( - "github.com/diamondburned/cchat-gtk/internal/ui/messages/autoscroll" - "github.com/gotk3/gotk3/gtk" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts/httputil" + "github.com/diamondburned/cchat-gtk/internal/ui/messages/container" + "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" +) + +type AvatarPixbufCopier interface { + CopyAvatarPixbuf(httputil.ImageContainer) +} + +const ( + AvatarSize = 40 + AvatarMargin = 10 ) type Container struct { - *autoscroll.ScrolledWindow - main *gtk.Grid - messages map[string]Message - nonceMsgs map[string]Message + *container.GridContainer +} + +func NewContainer() *Container { + c := &Container{} + c.GridContainer = container.NewGridContainer(c) + return c +} + +func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage { + var newmsg = NewFullMessage(msg) + + // Try and reuse an existing avatar. + if author := msg.Author(); !c.reuseAvatar(author.ID(), newmsg.Avatar) { + // Fetch a new avatar if we can't reuse the old one. + newmsg.updateAuthorAvatar(author) + } + + return newmsg +} + +func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage { + var presend = NewFullSendingMessage(msg) + c.reuseAvatar(msg.AuthorID(), presend.Avatar) + return presend +} + +func (c *Container) reuseAvatar(authorID string, img httputil.ImageContainer) (reused bool) { + // Search the old author if we have any. + msgc := c.FindMessage(func(msgc container.GridMessage) bool { + return msgc.AuthorID() == authorID + }) + + // Is this a message that we can work with? We have to assert to + // FullSendingMessage because that's where our messages are. + copier, ok := msgc.(AvatarPixbufCopier) + if ok { + // Borrow the avatar URL. + copier.CopyAvatarPixbuf(img) + } + + return ok } diff --git a/internal/ui/messages/container/cozy/message.go b/internal/ui/messages/container/cozy/message.go deleted file mode 100644 index 6038c6a..0000000 --- a/internal/ui/messages/container/cozy/message.go +++ /dev/null @@ -1,20 +0,0 @@ -package cozy - -import ( - "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" - "github.com/gotk3/gotk3/gtk" -) - -type Message interface { - gtk.IWidget - message.Container -} - -type FullMessage struct { - *gtk.Box - - Avatar *gtk.Image - *message.GenericContainer -} - -func NewFullMessage() diff --git a/internal/ui/messages/container/cozy/message_compact.go b/internal/ui/messages/container/cozy/message_compact.go new file mode 100644 index 0000000..ef41927 --- /dev/null +++ b/internal/ui/messages/container/cozy/message_compact.go @@ -0,0 +1,11 @@ +package cozy + +// CompactMessage is a message that follows after FullMessage. It does not show +// the header, and the avatar is invisible. +type CompactMessage struct { + // Essentially, CompactMessage is just a full message with some things + // hidden. Its Avatar and Timestamp will still be updated. This is a + // trade-off between performance, efficiency and code length. + + *FullMessage +} diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go new file mode 100644 index 0000000..14c1795 --- /dev/null +++ b/internal/ui/messages/container/cozy/message_full.go @@ -0,0 +1,116 @@ +package cozy + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts/httputil" + "github.com/diamondburned/cchat-gtk/internal/ui/messages/container" + "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" + "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" + "github.com/diamondburned/imgutil" + "github.com/gotk3/gotk3/gtk" +) + +type FullMessage struct { + *message.GenericContainer + + // Grid widgets. + Avatar *gtk.Image + MainBox *gtk.Box // wraps header and content + + // Header wraps author and timestamp. + HeaderBox *gtk.Box +} + +var ( + _ AvatarPixbufCopier = (*FullMessage)(nil) + _ message.Container = (*FullMessage)(nil) + _ container.GridMessage = (*FullMessage)(nil) +) + +func NewFullMessage(msg cchat.MessageCreate) *FullMessage { + msgc := WrapFullMessage(message.NewContainer(msg)) + // Don't update the avatar. + msgc.UpdateContent(msg.Content()) + msgc.UpdateAuthorName(msg.Author().Name()) + msgc.UpdateTimestamp(msg.Time()) + return msgc +} + +func WrapFullMessage(gc *message.GenericContainer) *FullMessage { + avatar, _ := gtk.ImageNew() + avatar.SetSizeRequest(AvatarSize, AvatarSize) + avatar.SetVAlign(gtk.ALIGN_START) + avatar.SetMarginStart(container.ColumnSpacing * 2) + avatar.Show() + + header, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + header.PackStart(gc.Username, false, false, 0) + header.PackStart(gc.Timestamp, false, false, 7) // padding + header.Show() + + main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + main.PackStart(header, false, false, 0) + main.PackStart(gc.Content, false, false, 2) + main.SetMarginBottom(2) + main.SetMarginEnd(container.ColumnSpacing * 2) + main.Show() + + return &FullMessage{ + GenericContainer: gc, + Avatar: avatar, + MainBox: main, + HeaderBox: header, + } +} + +func (m *FullMessage) UpdateAuthor(author cchat.MessageAuthor) { + // Call the parent's method to update the labels. + m.GenericContainer.UpdateAuthor(author) + m.updateAuthorAvatar(author) +} + +func (m *FullMessage) updateAuthorAvatar(author cchat.MessageAuthor) { + // If the author has an avatar: + if avatarer, ok := author.(cchat.MessageAuthorAvatar); ok { + // Download the avatar asynchronously. + httputil.AsyncImageSized( + m.Avatar, + avatarer.Avatar(), + AvatarSize, AvatarSize, + imgutil.Round(true), + ) + } +} + +func (m *FullMessage) CopyAvatarPixbuf(dst httputil.ImageContainer) { + switch m.Avatar.GetStorageType() { + case gtk.IMAGE_PIXBUF: + dst.SetFromPixbuf(m.Avatar.GetPixbuf()) + case gtk.IMAGE_ANIMATION: + dst.SetFromAnimation(m.Avatar.GetAnimation()) + } +} + +func (m *FullMessage) Attach(grid *gtk.Grid, row int) { + container.AttachRow(grid, row, m.Avatar, m.MainBox) +} + +type FullSendingMessage struct { + message.PresendContainer + FullMessage +} + +var ( + _ AvatarPixbufCopier = (*FullSendingMessage)(nil) + _ message.Container = (*FullSendingMessage)(nil) + _ container.GridMessage = (*FullSendingMessage)(nil) +) + +func NewFullSendingMessage(msg input.PresendMessage) *FullSendingMessage { + var msgc = message.NewPresendContainer(msg) + + return &FullSendingMessage{ + PresendContainer: msgc, + FullMessage: *WrapFullMessage(msgc.GenericContainer), + } +} diff --git a/internal/ui/messages/input/input.go b/internal/ui/messages/input/input.go index 6281e71..ace2ab9 100644 --- a/internal/ui/messages/input/input.go +++ b/internal/ui/messages/input/input.go @@ -4,51 +4,10 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/log" - "github.com/diamondburned/cchat-gtk/internal/ui/rich" - "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" ) -type usernameContainer struct { - *gtk.Revealer - label *rich.Label -} - -func newUsernameContainer() *usernameContainer { - label := rich.NewLabel(text.Rich{}) - label.SetMaxWidthChars(35) - label.SetVAlign(gtk.ALIGN_START) - label.SetMarginTop(inputmargin) - label.SetMarginBottom(inputmargin) - label.SetMarginStart(10) - label.SetMarginEnd(10) - label.Show() - - rev, _ := gtk.RevealerNew() - rev.SetRevealChild(false) - rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) - rev.SetTransitionDuration(50) - rev.Add(label) - - return &usernameContainer{rev, label} -} - -// GetLabel is not thread-safe. -func (u *usernameContainer) GetLabel() text.Rich { - return u.label.GetLabel() -} - -// SetLabel is thread-safe. -func (u *usernameContainer) SetLabel(content text.Rich) { - gts.ExecAsync(func() { - u.label.SetLabelUnsafe(content) - - // Reveal if the name is not empty. - u.SetRevealChild(!u.label.GetLabel().Empty()) - }) -} - type Field struct { *gtk.Box username *usernameContainer @@ -117,18 +76,8 @@ func NewField(ctrl Controller) *Field { func (f *Field) SetSender(session cchat.Session, sender cchat.ServerMessageSender) { f.UserID = session.ID() - // Does sender (aka Server) implement ServerNickname? - var err error - if nicknamer, ok := sender.(cchat.ServerNickname); ok { - err = errors.Wrap(nicknamer.Nickname(f.username), "Failed to get nickname") - } else { - err = errors.Wrap(session.Name(f.username), "Failed to get username") - } - - // Do a bit of trivial error handling. - if err != nil { - log.Warn(err) - } + // Update the left username container in the input. + f.username.Update(session, sender) // Set the sender. f.sender = sender diff --git a/internal/ui/messages/input/username.go b/internal/ui/messages/input/username.go new file mode 100644 index 0000000..351af82 --- /dev/null +++ b/internal/ui/messages/input/username.go @@ -0,0 +1,115 @@ +package input + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/imgutil" + "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" +) + +const AvatarSize = 20 + +type usernameContainer struct { + *gtk.Revealer + + main *gtk.Box + + avatar *rich.Icon + label *rich.Label +} + +var ( + _ cchat.LabelContainer = (*usernameContainer)(nil) + _ cchat.IconContainer = (*usernameContainer)(nil) +) + +func newUsernameContainer() *usernameContainer { + avatar := rich.NewIcon(AvatarSize, imgutil.Round(true)) + avatar.SetPlaceholderIcon("user-available-symbolic", AvatarSize) + avatar.Show() + + label := rich.NewLabel(text.Rich{}) + label.SetMaxWidthChars(35) + label.Show() + + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5) + box.PackStart(avatar, false, false, 0) + box.PackStart(label, false, false, 0) + box.SetMarginStart(10) + box.SetMarginEnd(10) + box.SetMarginTop(inputmargin) + box.SetMarginBottom(inputmargin) + box.SetVAlign(gtk.ALIGN_START) + box.Show() + + rev, _ := gtk.RevealerNew() + rev.SetRevealChild(false) + rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) + rev.SetTransitionDuration(50) + rev.Add(box) + + return &usernameContainer{ + Revealer: rev, + main: box, + avatar: avatar, + label: label, + } +} + +// Update is not thread-safe. +func (u *usernameContainer) Update(session cchat.Session, sender cchat.ServerMessageSender) { + // Does sender (aka Server) implement ServerNickname? If not, we fallback to + // the username inside session. + var err error + if nicknamer, ok := sender.(cchat.ServerNickname); ok { + err = errors.Wrap(nicknamer.Nickname(u), "Failed to get nickname") + } else { + err = errors.Wrap(session.Name(u), "Failed to get username") + } + + // Do a bit of trivial error handling. + if err != nil { + log.Warn(err) + } + + // Does session implement an icon? Update if so. + if iconer, ok := session.(cchat.Icon); ok { + err = iconer.Icon(u) + } + + if err != nil { + log.Warn(errors.Wrap(err, "Failed to get icon")) + } +} + +// GetLabel is not thread-safe. +func (u *usernameContainer) GetLabel() text.Rich { + return u.label.GetLabel() +} + +// SetLabel is thread-safe. +func (u *usernameContainer) SetLabel(content text.Rich) { + gts.ExecAsync(func() { + u.label.SetLabelUnsafe(content) + + // Reveal if the name is not empty. + u.SetRevealChild(!u.label.GetLabel().Empty()) + }) +} + +// SetIcon is thread-safe. +func (u *usernameContainer) SetIcon(url string) { + gts.ExecAsync(func() { + u.avatar.SetIconUnsafe(url) + + // Reveal if the icon URL is not empty. We don't touch anything if the + // URL is empty, as the name might not be. + if url != "" { + u.SetRevealChild(true) + } + }) +} diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index c5772ef..0408916 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -22,6 +22,12 @@ type Container interface { UpdateTimestamp(time.Time) } +func FillContainer(c Container, msg cchat.MessageCreate) { + c.UpdateAuthor(msg.Author()) + c.UpdateContent(msg.Content()) + c.UpdateTimestamp(msg.Time()) +} + // GenericContainer provides a single generic message container for subpackages // to use. type GenericContainer struct { @@ -36,12 +42,12 @@ type GenericContainer struct { var _ Container = (*GenericContainer)(nil) +// NewContainer creates a new message container with the given ID and nonce. It +// does not update the widgets, so FillContainer should be called afterwards. func NewContainer(msg cchat.MessageCreate) *GenericContainer { c := NewEmptyContainer() c.id = msg.ID() - c.UpdateTimestamp(msg.Time()) - c.UpdateAuthor(msg.Author()) - c.UpdateContent(msg.Content()) + c.authorID = msg.Author().ID() if noncer, ok := msg.(cchat.MessageNonce); ok { c.nonce = noncer.Nonce() @@ -102,7 +108,6 @@ func (m *GenericContainer) UpdateTimestamp(t time.Time) { } func (m *GenericContainer) UpdateAuthor(author cchat.MessageAuthor) { - m.authorID = author.ID() m.UpdateAuthorName(author.Name()) } diff --git a/internal/ui/messages/message/sending.go b/internal/ui/messages/message/sending.go index 38aa02c..3dd85b1 100644 --- a/internal/ui/messages/message/sending.go +++ b/internal/ui/messages/message/sending.go @@ -9,7 +9,6 @@ import ( ) type PresendContainer interface { - Container SetID(id string) SetDone() SetSentError(err error) @@ -24,7 +23,10 @@ type GenericPresendContainer struct { var _ PresendContainer = (*GenericPresendContainer)(nil) func NewPresendContainer(msg input.PresendMessage) *GenericPresendContainer { - c := NewEmptyContainer() + return WrapPresendContainer(NewEmptyContainer(), msg) +} + +func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *GenericPresendContainer { c.nonce = msg.Nonce() c.authorID = msg.AuthorID() c.UpdateContent(text.Rich{Content: msg.Content()}) @@ -52,6 +54,8 @@ func (m *GenericPresendContainer) SetDone() { } func (m *GenericPresendContainer) SetSentError(err error) { - m.Content.SetMarkup(`` + html.EscapeString(m.Content.GetLabel()) + ``) + var content = html.EscapeString(m.Content.GetLabel()) + + m.Content.SetMarkup(`` + content + ``) m.Content.SetTooltipText(err.Error()) } diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index 56e5b29..86c175e 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -4,7 +4,7 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/ui/messages/container" - "github.com/diamondburned/cchat-gtk/internal/ui/messages/container/compact" + "github.com/diamondburned/cchat-gtk/internal/ui/messages/container/cozy" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" @@ -34,7 +34,8 @@ func NewView() *View { view := &View{} // TODO: change - view.Container = compact.NewContainer() + // view.Container = compact.NewContainer() + view.Container = cozy.NewContainer() view.SendInput = input.NewField(view) view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go index 080625c..fe36be6 100644 --- a/internal/ui/primitives/primitives.go +++ b/internal/ui/primitives/primitives.go @@ -40,3 +40,21 @@ func SetImageIcon(img *gtk.Image, icon string, sizepx int) { img.SetProperty("pixel-size", sizepx) img.SetSizeRequest(sizepx, sizepx) } + +type MenuItem struct { + Name string + Fn func() +} + +func AppendMenuItems(menu interface{ Append(gtk.IMenuItem) }, items []MenuItem) { + for _, item := range items { + menu.Append(NewMenuItem(item.Name, item.Fn)) + } +} + +func NewMenuItem(label string, fn func()) *gtk.MenuItem { + mb, _ := gtk.MenuItemNewWithLabel(label) + mb.Show() + mb.Connect("activate", fn) + return mb +} diff --git a/internal/ui/rich/image.go b/internal/ui/rich/image.go index 843831a..c9ecd21 100644 --- a/internal/ui/rich/image.go +++ b/internal/ui/rich/image.go @@ -48,7 +48,21 @@ func NewIcon(sizepx int, procs ...imgutil.Processor) *Icon { } } -// Thread-unsafe methods should only be called right after construction. +// URL is not thread-safe. +func (i *Icon) URL() string { + return i.url +} + +func (i *Icon) CopyPixbuf(dst httputil.ImageContainer) { + switch i.Image.GetStorageType() { + case gtk.IMAGE_PIXBUF: + dst.SetFromPixbuf(i.Image.GetPixbuf()) + case gtk.IMAGE_ANIMATION: + dst.SetFromAnimation(i.Image.GetAnimation()) + } +} + +// Thread-unsafe setter methods should only be called right after construction. // SetPlaceholderIcon is not thread-safe. func (i *Icon) SetPlaceholderIcon(iconName string, iconSzPx int) { @@ -73,7 +87,14 @@ func (i *Icon) AddProcessors(procs ...imgutil.Processor) { // SetIcon is thread-safe. func (i *Icon) SetIcon(url string) { - gts.ExecAsync(func() { i.SetRevealChild(true) }) + gts.ExecAsync(func() { + i.SetIconUnsafe(url) + }) +} + +// SetIconUnsafe is not thread-safe. +func (i *Icon) SetIconUnsafe(url string) { + i.SetRevealChild(true) i.url = url i.updateAsync() } diff --git a/internal/ui/rich/rich.go b/internal/ui/rich/rich.go index 8178d14..dd1689c 100644 --- a/internal/ui/rich/rich.go +++ b/internal/ui/rich/rich.go @@ -1,6 +1,8 @@ package rich import ( + "html" + "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" @@ -11,9 +13,17 @@ import ( // TODO: parser +func MakeRed(content text.Rich) string { + return `` + html.EscapeString(content.Content) + `` +} + type Labeler interface { + // thread-safe cchat.LabelContainer // thread-safe - GetLabel() text.Rich // not thread-safe + + // not thread-safe + SetLabelUnsafe(text.Rich) + GetLabel() text.Rich GetText() string } diff --git a/internal/ui/service/children.go b/internal/ui/service/children.go new file mode 100644 index 0000000..5636fe8 --- /dev/null +++ b/internal/ui/service/children.go @@ -0,0 +1,30 @@ +package service + +import ( + "github.com/diamondburned/cchat-gtk/internal/ui/service/session" + "github.com/gotk3/gotk3/gtk" +) + +type children struct { + *gtk.Box + Sessions map[string]*session.Row +} + +func newChildren() *children { + box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + box.Show() + + return &children{box, map[string]*session.Row{}} +} + +func (c *children) addSessionRow(id string, row *session.Row) { + c.Sessions[id] = row + c.Box.Add(row) +} + +func (c *children) removeSessionRow(id string) { + if row, ok := c.Sessions[id]; ok { + delete(c.Sessions, id) + c.Box.Remove(row) + } +} diff --git a/internal/ui/service/header.go b/internal/ui/service/header.go new file mode 100644 index 0000000..2b5c428 --- /dev/null +++ b/internal/ui/service/header.go @@ -0,0 +1,60 @@ +package service + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/imgutil" + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" +) + +const IconSize = 32 + +type header struct { + *gtk.Box + reveal *rich.ToggleButtonImage // no rich text here but it's left aligned + add *gtk.Button + + Menu *gtk.Menu +} + +func newHeader(svc cchat.Service) *header { + reveal := rich.NewToggleButtonImage(text.Rich{Content: svc.Name()}) + reveal.Box.SetHAlign(gtk.ALIGN_START) + reveal.Image.AddProcessors(imgutil.Round(true)) + reveal.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize) + reveal.SetRelief(gtk.RELIEF_NONE) + reveal.SetMode(true) + reveal.Show() + + add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON) + add.SetRelief(gtk.RELIEF_NONE) + add.SetSizeRequest(IconSize, IconSize) + add.Show() + + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + box.PackStart(reveal, true, true, 0) + box.PackStart(add, false, false, 0) + box.Show() + + if iconer, ok := svc.(cchat.Icon); ok { + if err := iconer.Icon(reveal); err != nil { + log.Error(errors.Wrap(err, "Error getting session logo")) + } + } + + menu, _ := gtk.MenuNew() + + // Spawn the menu on right click. + reveal.Connect("event", func(_ *gtk.ToggleButton, ev *gdk.Event) { + if gts.EventIsRightClick(ev) { + menu.PopupAtPointer(ev) + } + }) + + return &header{box, reveal, add, menu} +} diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 5384e40..14f6042 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -2,21 +2,23 @@ package service import ( "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/cchat-gtk/internal/keyring" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" - "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb" "github.com/diamondburned/cchat-gtk/internal/ui/service/session" - "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/gotk3/gotk3/gtk" - "github.com/pkg/errors" ) -const IconSize = 32 - type Controller interface { session.Controller + + // MessageRowSelected is wrapped around session's MessageRowSelected. + MessageRowSelected(*session.Row, *server.Row, cchat.ServerMessage) + // AuthenticateSession is called to spawn the authentication dialog. AuthenticateSession(*Container, cchat.Service) + // SaveAllSessions is called to save all available sessions from the menu. + SaveAllSessions(*Container) } type View struct { @@ -52,10 +54,14 @@ func (v *View) AddService(svc cchat.Service, ctrl Controller) *Container { type Container struct { *gtk.Box + Service cchat.Service + header *header revealer *gtk.Revealer children *children - rowctrl Controller + + // Embed controller and extend it to override RestoreSession. + Controller } func NewContainer(svc cchat.Service, ctrl Controller) *Container { @@ -76,7 +82,14 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container { primitives.AddClass(box, "service") - var container = &Container{box, header, chrev, children, ctrl} + container := &Container{ + Box: box, + Service: svc, + header: header, + revealer: chrev, + children: children, + Controller: ctrl, + } // On click, toggle reveal. header.reveal.Connect("clicked", func() { @@ -90,71 +103,42 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container { ctrl.AuthenticateSession(container, svc) }) + // Make menu items. + primitives.AppendMenuItems(header.Menu, []primitives.MenuItem{ + {Name: "Save Sessions", Fn: func() { + ctrl.SaveAllSessions(container) + }}, + }) + return container } -func (c *Container) AddSession(ses cchat.Session) { - srow := session.New(c, ses, c.rowctrl) - c.children.addSessionRow(srow) +func (c *Container) AddSession(ses cchat.Session) *session.Row { + srow := session.New(c, ses, c) + c.children.addSessionRow(ses.ID(), srow) + + return srow } -func (c *Container) Sessions() []cchat.Session { - var sessions = make([]cchat.Session, len(c.children.Sessions)) - for i, s := range c.children.Sessions { - sessions[i] = s.Session +func (c *Container) AddLoadingSession(id, name string) *session.Row { + srow := session.NewLoading(c, name, c) + c.children.addSessionRow(id, srow) + + return srow +} + +// KeyringSessions returns all known keyring sessions. Sessions that can't be +// saved will not be in the slice. +func (c *Container) KeyringSessions() []keyring.Session { + var ksessions = make([]keyring.Session, 0, len(c.children.Sessions)) + for _, s := range c.children.Sessions { + if k := s.KeyringSession(); k != nil { + ksessions = append(ksessions, *k) + } } - return sessions + return ksessions } func (c *Container) Breadcrumb() breadcrumb.Breadcrumb { return breadcrumb.Try(nil, c.header.reveal.GetText()) } - -type header struct { - *gtk.Box - reveal *rich.ToggleButtonImage // no rich text here but it's left aligned - add *gtk.Button -} - -func newHeader(svc cchat.Service) *header { - reveal := rich.NewToggleButtonImage(text.Rich{Content: svc.Name()}) - reveal.Box.SetHAlign(gtk.ALIGN_START) - reveal.Image.SetPlaceholderIcon("folder-remote-symbolic", IconSize) - reveal.SetRelief(gtk.RELIEF_NONE) - reveal.SetMode(true) - reveal.Show() - - if iconer, ok := svc.(cchat.Icon); ok { - if err := iconer.Icon(reveal); err != nil { - log.Error(errors.Wrap(err, "Error getting session logo")) - } - } - - add, _ := gtk.ButtonNewFromIconName("list-add-symbolic", gtk.ICON_SIZE_BUTTON) - add.SetRelief(gtk.RELIEF_NONE) - add.Show() - - box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) - box.PackStart(reveal, true, true, 0) - box.PackStart(add, false, false, 0) - box.Show() - - return &header{box, reveal, add} -} - -type children struct { - *gtk.Box - Sessions []*session.Row -} - -func newChildren() *children { - box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - box.Show() - - return &children{box, nil} -} - -func (c *children) addSessionRow(row *session.Row) { - c.Sessions = append(c.Sessions, row) - c.Box.Add(row) -} diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index 2637788..1bebece 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -8,6 +8,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/imgutil" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" ) @@ -37,6 +38,7 @@ type Row struct { func NewRow(parent breadcrumb.Breadcrumber, server cchat.Server, ctrl Controller) *Row { button := rich.NewToggleButtonImage(text.Rich{}) button.Box.SetHAlign(gtk.ALIGN_START) + button.Image.AddProcessors(imgutil.Round(true)) button.Image.SetSize(IconSize) button.SetRelief(gtk.RELIEF_NONE) button.Show() diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index ca21153..8d1cee3 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -2,12 +2,16 @@ package session import ( "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/keyring" + "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/service/breadcrumb" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/imgutil" "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" ) const IconSize = 32 @@ -15,6 +19,7 @@ const IconSize = 32 // Controller extends server.RowController to add session. type Controller interface { MessageRowSelected(*Row, *server.Row, cchat.ServerMessage) + RestoreSession(*Row, cchat.SessionRestorer) // async } type Row struct { @@ -29,16 +34,30 @@ type Row struct { } func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Row { + row := new(parent, ctrl) + row.SetSession(ses) + return row +} + +func NewLoading(parent breadcrumb.Breadcrumber, name string, ctrl Controller) *Row { + row := new(parent, ctrl) + row.Button.SetLabelUnsafe(text.Rich{Content: name}) + row.Button.Image.SetPlaceholderIcon("content-loading-symbolic", IconSize) + row.SetSensitive(false) + + return row +} + +func new(parent breadcrumb.Breadcrumber, ctrl Controller) *Row { row := &Row{ - Session: ses, - ctrl: ctrl, - parent: parent, + ctrl: ctrl, + parent: parent, } - row.Servers = server.NewChildren(row, ses, row) row.Button = rich.NewToggleButtonImage(text.Rich{}) row.Button.Box.SetHAlign(gtk.ALIGN_START) - row.Button.Image.SetPlaceholderIcon("user-available-symbolic", IconSize) + row.Button.Image.AddProcessors(imgutil.Round(true)) + // Set the loading icon. row.Button.SetRelief(gtk.RELIEF_NONE) // On click, toggle reveal. row.Button.Connect("clicked", func() { @@ -47,12 +66,10 @@ func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Ro row.Button.SetActive(revealed) }) row.Button.Show() - row.Button.Try(ses, "session") row.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) row.Box.SetMarginStart(server.ChildrenMargin) row.Box.PackStart(row.Button, false, false, 0) - row.Box.PackStart(row.Servers, false, false, 0) row.Box.Show() primitives.AddClass(row.Box, "session") @@ -60,6 +77,48 @@ func New(parent breadcrumb.Breadcrumber, ses cchat.Session, ctrl Controller) *Ro return row } +// KeyringSession returns a keyring session, or nil if the session cannot be +// saved. This function is not cached, as I'd rather not keep the map in memory. +func (r *Row) KeyringSession() *keyring.Session { + // Is the session saveable? + saver, ok := r.Session.(cchat.SessionSaver) + if !ok { + return nil + } + + ks := keyring.Session{ + ID: r.Session.ID(), + Name: r.Button.GetText(), + } + + s, err := saver.Save() + if err != nil { + log.Error(errors.Wrapf(err, "Failed to save session ID %s (%s)", ks.ID, ks.Name)) + return nil + } + ks.Data = s + + return &ks +} + +func (r *Row) SetSession(ses cchat.Session) { + r.Session = ses + r.Servers = server.NewChildren(r, ses, r) + r.Button.Image.SetPlaceholderIcon("user-available-symbolic", IconSize) + r.Box.PackStart(r.Servers, false, false, 0) + r.SetSensitive(true) + + // Set the session's name to the button. + r.Button.Try(ses, "session") +} + +func (r *Row) SetFailed(err error) { + r.SetTooltipText(err.Error()) + // TODO: setting the label directly here is kind of shitty, as it screws up + // the getter. Fix? + r.Button.Label.SetMarkup(rich.MakeRed(r.Button.GetLabel())) +} + func (r *Row) MessageRowSelected(server *server.Row, smsg cchat.ServerMessage) { r.ctrl.MessageRowSelected(r, server, smsg) } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 87e10b9..dcadbce 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -11,6 +11,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/gotk3/gotk3/gtk" "github.com/markbates/pkger" + "github.com/pkg/errors" ) func init() { @@ -46,8 +47,45 @@ func NewApplication() *App { func (app *App) AddService(svc cchat.Service) { var container = app.window.Services.AddService(svc, app) - // Attempt to restore sessions asynchronously. - keyring.RestoreSessions(svc, container.AddSession) + // Can this session be restored? If not, exit. + restorer, ok := container.Service.(cchat.SessionRestorer) + if !ok { + return + } + + var sessions = keyring.RestoreSessions(container.Service.Name()) + + for _, krs := range sessions { + // Copy the session to avoid race conditions. + krs := krs + row := container.AddLoadingSession(krs.ID, krs.Name) + + go app.restoreSession(row, restorer, krs) + } +} + +// RestoreSession attempts to restore the session asynchronously. +func (app *App) RestoreSession(row *session.Row, r cchat.SessionRestorer) { + // Get the restore data. + ks := row.KeyringSession() + if ks == nil { + log.Warn(errors.New("Attempted restore in ui.go")) + return + } + go app.restoreSession(row, r, *ks) +} + +// synchronous op +func (app *App) restoreSession(row *session.Row, r cchat.SessionRestorer, k keyring.Session) { + s, err := r.RestoreSession(k.Data) + if err != nil { + err = errors.Wrapf(err, "Failed to restore session %s (%s)", k.ID, k.Name) + log.Error(err) + + gts.ExecAsync(func() { row.SetFailed(err) }) + } else { + gts.ExecAsync(func() { row.SetSession(s) }) + } } func (app *App) MessageRowSelected(ses *session.Row, srv *server.Row, smsg cchat.ServerMessage) { @@ -70,13 +108,15 @@ func (app *App) AuthenticateSession(container *service.Container, svc cchat.Serv auth.NewDialog(svc.Name(), svc.Authenticate(), func(ses cchat.Session) { container.AddSession(ses) - // Save all sessions. - for _, err := range keyring.SaveSessions(svc, container.Sessions()) { - log.Error(err) - } + // Try and save all keyring sessions. + app.SaveAllSessions(container) }) } +func (app *App) SaveAllSessions(container *service.Container) { + keyring.SaveSessions(container.Service.Name(), container.KeyringSessions()) +} + func (app *App) Header() gtk.IWidget { return app.header }