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
}