diff --git a/go.mod b/go.mod index 7273f9a..c844596 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200816 require ( github.com/Xuanwo/go-locale v0.2.0 github.com/alecthomas/chroma v0.7.3 - github.com/diamondburned/cchat v0.0.48 - github.com/diamondburned/cchat-discord v0.0.0-20200816234747-9647ad0709f0 + github.com/diamondburned/cchat v0.0.49 + github.com/diamondburned/cchat-discord v0.0.0-20200820222718-68cfafc4c318 github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 github.com/disintegration/imaging v1.6.2 diff --git a/go.sum b/go.sum index fd87047..86599a7 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,10 @@ github.com/diamondburned/arikawa v0.12.4 h1:lhWJqcGkIIMiOYWdsoEuGlri2UbMkzMeh+Vf github.com/diamondburned/arikawa v0.12.4/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= github.com/diamondburned/arikawa v1.1.6 h1:Y/ioTYipS2v/NXfcAEhCnMTzrpxDjWlkjLKKcX29n6o= github.com/diamondburned/arikawa v1.1.6/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= +github.com/diamondburned/arikawa v1.2.0 h1:3dFmpk/G4UwO+Kto0tXd5AbaCKC9KH2ZfnA8UOdzQ1k= +github.com/diamondburned/arikawa v1.2.0/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= +github.com/diamondburned/arikawa v1.3.0 h1:up5q5Ya/QbiFqhMejvl+c03YdsgzkzspsJOWW30A2lk= +github.com/diamondburned/arikawa v1.3.0/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= github.com/diamondburned/cchat v0.0.43 h1:HetAujSaUSdnQgAUZgprNLARjf/MSWXpCfWdvX2wOCU= github.com/diamondburned/cchat v0.0.43/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.45 h1:HMVSKx1h6lh2OenWaBTvMSK531hWaXAW7I0tKZepYug= @@ -56,6 +60,8 @@ github.com/diamondburned/cchat v0.0.46 h1:fzm2XA9uGasX0uaic1AFfUMGA53PlO+GGmkYbx github.com/diamondburned/cchat v0.0.46/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.48 h1:MAzGzKY20JBh/LnirOZVPwbMq07xfqu4Lb4XsV9/sXQ= github.com/diamondburned/cchat v0.0.48/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.49 h1:zP6QvjdRU3UqDZt3rEqjkR/5M68XRVms7htHfE9tLOc= +github.com/diamondburned/cchat v0.0.49/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat-discord v0.0.0-20200719175346-af912db55401 h1:llmx/8UiJoTcHUw+GE5/rESVVmmnLh1HEPx3wRj+oQY= github.com/diamondburned/cchat-discord v0.0.0-20200719175346-af912db55401/go.mod h1:+hSrIVYj5tIPLAorDsHj2Tbt2fWlZtOanzfEUHX53HM= github.com/diamondburned/cchat-discord v0.0.0-20200730000036-2c93cdc1974e h1:EA5Vg0x57qLURJP80XhABBW+X0sbQSh2gw5qvPbZTs4= @@ -64,6 +70,16 @@ github.com/diamondburned/cchat-discord v0.0.0-20200815223744-cdd9b6804361 h1:vx3 github.com/diamondburned/cchat-discord v0.0.0-20200815223744-cdd9b6804361/go.mod h1:sW8tIqRcKux9bhMCtcqYI1fCMGCB23FoW67gcpr13fk= github.com/diamondburned/cchat-discord v0.0.0-20200816234747-9647ad0709f0 h1:IVL4KUyLG9i5xPGwhIeKYttWHogenKiQLgfXEnYWNvU= github.com/diamondburned/cchat-discord v0.0.0-20200816234747-9647ad0709f0/go.mod h1:Hl5AJstOnuP8rCdrL/Ns9W5MH09PRxQM4tG0BIMCjLw= +github.com/diamondburned/cchat-discord v0.0.0-20200818185457-528f40bb7258 h1:U7eOUozoKBW2ZBMffmWOVajvDi5GQhV3jlSgAW2jCHg= +github.com/diamondburned/cchat-discord v0.0.0-20200818185457-528f40bb7258/go.mod h1:EAsQW54v5lb74rjrTiBPwyF1WtR91k0tmaiCIuZs0po= +github.com/diamondburned/cchat-discord v0.0.0-20200819232051-b9d06fade536 h1:A6B9LX13NweRTd8A4lJqdsArH+RicnwA7BK59fZ6Kb4= +github.com/diamondburned/cchat-discord v0.0.0-20200819232051-b9d06fade536/go.mod h1:ssgZcN6paA8TQkdh91zdZVkOV9kT9PvGFskHLvg6zXw= +github.com/diamondburned/cchat-discord v0.0.0-20200820014012-0b17709222a0 h1:PH3oMGoe25eFyUyuGimeGk9e5IsN80k2ZYx+NA9HfAo= +github.com/diamondburned/cchat-discord v0.0.0-20200820014012-0b17709222a0/go.mod h1:ssgZcN6paA8TQkdh91zdZVkOV9kT9PvGFskHLvg6zXw= +github.com/diamondburned/cchat-discord v0.0.0-20200820222309-c85ad033d174 h1:EgEqbveuCKDBo6J7zaa4TT/dgYs8POojeFbHT1TSXTc= +github.com/diamondburned/cchat-discord v0.0.0-20200820222309-c85ad033d174/go.mod h1:gz+UVcC3VON4j8DjpLwH3xKQyqiDT4fyap8V5Rn+ACg= +github.com/diamondburned/cchat-discord v0.0.0-20200820222718-68cfafc4c318 h1:mRGZ2/Cjgw8O4l2ZOfdYhNUbO9VGBQCfLpxO5QCUoyo= +github.com/diamondburned/cchat-discord v0.0.0-20200820222718-68cfafc4c318/go.mod h1:rhUseXyWXTVw0Da8edbQMHU9I4LRQ2zcRB3zRqg/oe4= github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b h1:sq0MXjJc3yAOZvuolRxOpKQNvpMLyTmsECxQqdYgF5E= github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b/go.mod h1:+bAf0m2o5qH54DmYJ/lR1HeITV53ol0JaoKyFFx3m3E= github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d h1:Ha/I6PMKi+B4hpWclwlXj0tUMehR7Q0TNxPczzBwzPI= @@ -84,6 +100,12 @@ github.com/diamondburned/ningen v0.1.1-0.20200816040956-857988325ce0 h1:c3G8NjcS github.com/diamondburned/ningen v0.1.1-0.20200816040956-857988325ce0/go.mod h1:PIsJWdDhjgN9OiR+qrDPD8KGQ8UyFuRVrgs3Ewu6a3c= github.com/diamondburned/ningen v0.1.1-0.20200816192443-0b6a02d498d2 h1:PodX8lfv7ZffUHUsaQUdIjZ50ONY8uCCXXUL7yzoSMQ= github.com/diamondburned/ningen v0.1.1-0.20200816192443-0b6a02d498d2/go.mod h1:PIsJWdDhjgN9OiR+qrDPD8KGQ8UyFuRVrgs3Ewu6a3c= +github.com/diamondburned/ningen v0.1.1-0.20200818185419-0d3e89f25ee1 h1:ahr5xvEzrBIpRc3mrdihCd0iCUEawPixulbJk6MVAao= +github.com/diamondburned/ningen v0.1.1-0.20200818185419-0d3e89f25ee1/go.mod h1:vglus/9ENunGHSAZxzilq1ApNQMDtYBSG8V4r/vDY1o= +github.com/diamondburned/ningen v0.1.1-0.20200820222120-e86664f3f084 h1:nZiBsM/9Dw9Uxw8iBu3GO5m+BlYmqa3kVIP6LcKJPKQ= +github.com/diamondburned/ningen v0.1.1-0.20200820222120-e86664f3f084/go.mod h1:aXm69MB+Qu04OjBiixQw28zRijDc49vruMJMaHZ2c0Q= +github.com/diamondburned/ningen v0.1.1-0.20200820222640-35796f938a58 h1:sQvq5DgmX8hSyiP4HVZfOhlFY/PgvQWu6USAy04i1Yc= +github.com/diamondburned/ningen v0.1.1-0.20200820222640-35796f938a58/go.mod h1:aXm69MB+Qu04OjBiixQw28zRijDc49vruMJMaHZ2c0Q= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= diff --git a/internal/ui/header.go b/internal/ui/header.go index 1b0d67d..aeabd7a 100644 --- a/internal/ui/header.go +++ b/internal/ui/header.go @@ -7,10 +7,11 @@ import ( "github.com/diamondburned/cchat-gtk/icons" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions" - "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/diamondburned/cchat-gtk/internal/ui/service/session" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" + "github.com/gotk3/gotk3/pango" ) type header struct { @@ -51,7 +52,8 @@ func newHeader() *header { } } -const BreadcrumbSlash = `/` +// const BreadcrumbSlash = `` +const BreadcrumbSlash = " 〉" func (h *header) SetBreadcrumber(b traverse.Breadcrumber) { if b == nil { @@ -60,6 +62,13 @@ func (h *header) SetBreadcrumber(b traverse.Breadcrumber) { } var crumb = b.Breadcrumb() + + if len(crumb) > 0 { + h.left.svcname.SetText(crumb[0]) + } else { + h.left.svcname.SetText("") + } + for i := range crumb { crumb[i] = html.EscapeString(crumb[i]) } @@ -104,6 +113,7 @@ func (a *appMenu) SetSizeRequest(w, h int) { type headerLeft struct { *gtk.Box appmenu *appMenu + svcname *gtk.Label sesmenu *actions.MenuButton } @@ -115,17 +125,25 @@ func newHeaderLeft() *headerLeft { sep.Show() primitives.AddClass(sep, "titlebutton") + svcname, _ := gtk.LabelNew("") + svcname.SetXAlign(0) + svcname.SetEllipsize(pango.ELLIPSIZE_END) + svcname.Show() + svcname.SetMarginStart(14) + sesmenu := actions.NewMenuButton() sesmenu.Show() box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) box.PackStart(appmenu, false, false, 0) box.PackStart(sep, false, false, 0) + box.PackStart(svcname, true, true, 0) box.PackStart(sesmenu, false, false, 5) return &headerLeft{ Box: box, appmenu: appmenu, + svcname: svcname, sesmenu: sesmenu, } } diff --git a/internal/ui/messages/container/container.go b/internal/ui/messages/container/container.go index a8fd7d5..958c0c9 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -15,6 +15,8 @@ const BacklogLimit = 35 type GridMessage interface { message.Container + // Focusable should return a widget that can be focused. + Focusable() gtk.IWidget // Attach should only be called once. Attach(grid *gtk.Grid, row int) // AttachMenu should override the stored constructor. @@ -39,18 +41,28 @@ type Container interface { // Thread-safe methods. cchat.MessagesContainer + cchat.MessagePrepender // Thread-unsafe methods. CreateMessageUnsafe(cchat.MessageCreate) UpdateMessageUnsafe(cchat.MessageUpdate) DeleteMessageUnsafe(cchat.MessageDelete) + PrependMessageUnsafe(cchat.MessageCreate) - Reset() - + // FirstMessage returns the first message in the buffer. Nil is returned if + // there's nothing. + FirstMessage() GridMessage + // TranslateCoordinates is used for scrolling to the message. + TranslateCoordinates(parent gtk.IWidget, msg GridMessage) (y int) // AddPresendMessage adds and displays an unsent message. AddPresendMessage(msg input.PresendMessage) PresendGridMessage // LatestMessageFrom returns the last message ID with that author. LatestMessageFrom(authorID string) (msgID string, ok bool) + + // UI methods. + + SetFocusHAdjustment(*gtk.Adjustment) + SetFocusVAdjustment(*gtk.Adjustment) } // Controller is for menu actions. @@ -132,3 +144,7 @@ func (c *GridContainer) UpdateMessage(msg cchat.MessageUpdate) { func (c *GridContainer) DeleteMessage(msg cchat.MessageDelete) { gts.ExecAsync(func() { c.DeleteMessageUnsafe(msg) }) } + +func (c *GridContainer) PrependMessage(msg cchat.MessageCreate) { + gts.ExecAsync(func() { c.PrependMessageUnsafe(msg) }) +} diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index e1572cd..f218a61 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -57,11 +57,14 @@ func NewContainer(ctrl container.Controller) *Container { } func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage { - // Is the latest message of the same author? If yes, display it as a - // collapsed message. - if c.lastMessageIsAuthor(msg.Author().ID()) { - return NewCollapsedMessage(msg) - } + // We're not checking for a collapsed message here anymore, as the + // CreateMessage method will do that. + + // // Is the latest message of the same author? If yes, display it as a + // // collapsed message. + // if c.lastMessageIsAuthor(msg.Author().ID()) { + // return NewCollapsedMessage(msg) + // } full := NewFullMessage(msg) author := msg.Author() @@ -77,7 +80,9 @@ func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage { } func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage { - if c.lastMessageIsAuthor(msg.AuthorID()) { + // We can do the check here since we're never using NewPresendMessage for + // backlog messages. + if c.lastMessageIsAuthor(msg.AuthorID(), 0) { return NewCollapsedSendingMessage(msg) } @@ -90,11 +95,6 @@ func (c *Container) NewPresendMessage(msg input.PresendMessage) container.Presen return full } -func (c *Container) lastMessageIsAuthor(id string) bool { - var last = c.GridStore.LastMessage() - return last != nil && last.AuthorID() == id -} - func (c *Container) findAuthorID(authorID string) container.GridMessage { // Search the old author if we have any. return c.GridStore.FindMessage(func(msgc container.GridMessage) bool { @@ -120,12 +120,23 @@ func (c *Container) reuseAvatar(authorID, avatarURL string, full *FullMessage) { } } +func (c *Container) lastMessageIsAuthor(id string, offset int) bool { + var last = c.GridStore.NthMessage(c.GridStore.MessagesLen() - (1 + offset)) + return last != nil && last.AuthorID() == id +} + func (c *Container) CreateMessage(msg cchat.MessageCreate) { gts.ExecAsync(func() { // Create the message in the parent's handler. This handler will also // wipe old messages. c.GridContainer.CreateMessageUnsafe(msg) + // Should we collapse this message? Yes, if the current message's author + // is the same as the last author. + if c.lastMessageIsAuthor(msg.Author().ID(), 1) { + c.compact(c.GridContainer.LastMessage()) + } + // Did the handler wipe old messages? It will only do so if the user is // scrolled to the bottom. if !c.Bottomed() { @@ -199,3 +210,37 @@ func (c *Container) uncompact(msg container.GridMessage) { // Swap the old next message out for a new one. c.GridStore.SwapMessage(full) } + +func (c *Container) PrependMessage(msg cchat.MessageCreate) { + gts.ExecAsync(func() { + c.GridContainer.PrependMessageUnsafe(msg) + + // See if we need to uncollapse the second message. + if sec := c.NthMessage(1); sec != nil { + // If the author isn't the same, then ignore. + if sec.AuthorID() != msg.Author().ID() { + return + } + + // The author is the same; collapse. + c.compact(sec) + } + }) +} + +func (c *Container) compact(msg container.GridMessage) { + // Exit if the message is already collapsed. + if collapse, ok := msg.(Collapsible); !ok || collapse.Collapsed() { + return + } + + uw, ok := msg.(Unwrapper) + if !ok { + return + } + + compact := WrapCollapsedMessage(uw.Unwrap(c.Grid)) + message.RefreshContainer(compact, compact.GenericContainer) + + c.GridStore.SwapMessage(compact) +} diff --git a/internal/ui/messages/container/cozy/message_collapsed.go b/internal/ui/messages/container/cozy/message_collapsed.go index 4d57eb9..9c1ffd5 100644 --- a/internal/ui/messages/container/cozy/message_collapsed.go +++ b/internal/ui/messages/container/cozy/message_collapsed.go @@ -59,6 +59,10 @@ func (c *CollapsedMessage) Attach(grid *gtk.Grid, row int) { container.AttachRow(grid, row, c.Timestamp, c.Content) } +func (c *CollapsedMessage) Focusable() gtk.IWidget { + return c.Timestamp +} + type CollapsedSendingMessage struct { *CollapsedMessage message.PresendContainer diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index 1234bab..0c77a4a 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -114,10 +114,26 @@ func (m *FullMessage) Unwrap(grid *gtk.Grid) *message.GenericContainer { m.HeaderBox.Remove(m.Timestamp) m.MainBox.Remove(m.Content) + // Hide the avatar. + m.Avatar.Hide() + + // Remove the message from the grid. + grid.Remove(m.Avatar) + grid.Remove(m.MainBox) + // Return after removing. return m.GenericContainer } +func (m *FullMessage) Attach(grid *gtk.Grid, row int) { + m.Avatar.Show() + container.AttachRow(grid, row, m.Avatar, m.MainBox) +} + +func (m *FullMessage) Focusable() gtk.IWidget { + return m.Avatar +} + func (m *FullMessage) UpdateTimestamp(t time.Time) { m.GenericContainer.UpdateTimestamp(t) m.Timestamp.SetText(humanize.TimeAgoLong(t)) @@ -136,7 +152,7 @@ func (m *FullMessage) UpdateAuthor(author cchat.MessageAuthor) { // CopyAvatarPixbuf sets the pixbuf into the given container. This shares the // same pixbuf, but gtk.Image should take its own reference from the pixbuf. func (m *FullMessage) CopyAvatarPixbuf(dst httputil.ImageContainer) { - switch img := m.Avatar.Image; img.GetStorageType() { + switch img := m.Avatar.Image; img.GetImage().GetStorageType() { case gtk.IMAGE_PIXBUF: dst.SetFromPixbuf(img.GetPixbuf()) case gtk.IMAGE_ANIMATION: @@ -144,11 +160,6 @@ func (m *FullMessage) CopyAvatarPixbuf(dst httputil.ImageContainer) { } } -func (m *FullMessage) Attach(grid *gtk.Grid, row int) { - m.Avatar.Show() - container.AttachRow(grid, row, m.Avatar, m.MainBox) -} - func (m *FullMessage) AttachMenu(items []menu.Item) { // Bind to parent's container as well. m.GenericContainer.AttachMenu(items) @@ -183,17 +194,12 @@ type Avatar struct { } func NewAvatar() *Avatar { - avatar, _ := roundimage.NewEmptyButton() - avatar.SetSizeRequest(AvatarSize, AvatarSize) - avatar.SetVAlign(gtk.ALIGN_START) - - img, _ := roundimage.NewStaticImage(avatar, 0) + img, _ := roundimage.NewStaticImage(nil, 0) img.Show() - avatar.SetImage(img.Image) - - // TODO - // Remove static image; make it internal; make static iamge bind to something else incl button and list + avatar, _ := roundimage.NewCustomButton(img) + avatar.SetSizeRequest(AvatarSize, AvatarSize) + avatar.SetVAlign(gtk.ALIGN_START) // Default icon. primitives.SetImageIcon(img, "user-available-symbolic", AvatarSize) diff --git a/internal/ui/messages/container/grid.go b/internal/ui/messages/container/grid.go index 000f8c6..a9afc46 100644 --- a/internal/ui/messages/container/grid.go +++ b/internal/ui/messages/container/grid.go @@ -8,6 +8,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" ) type GridStore struct { @@ -38,18 +39,6 @@ func NewGridStore(constr Constructor, ctrl Controller) *GridStore { } } -func (c *GridStore) Reset() { - c.Grid.GetChildren().Foreach(func(v interface{}) { - // Unsafe assertion ftw. - w := v.(gtk.IWidget).ToWidget() - c.Grid.Remove(w) - w.Destroy() - }) - - c.messages = map[string]*gridMessage{} - c.messageIDs = []string{} -} - func (c *GridStore) MessagesLen() int { return len(c.messages) } @@ -64,6 +53,34 @@ func (c *GridStore) findIndex(idnonce string) int { return -1 } +type CoordinateTranslator interface { + TranslateCoordinates(dest gtk.IWidget, srcX int, srcY int) (destX int, destY int, e error) +} + +var _ CoordinateTranslator = (*gtk.Widget)(nil) + +func (c *GridStore) TranslateCoordinates(parent gtk.IWidget, msg GridMessage) (y int) { + i := c.findIndex(msg.ID()) + if i < 0 { + return 0 + } + + m, _ := c.messages[c.messageIDs[i]] + w, _ := m.Focusable().(CoordinateTranslator) + + // x is not needed. + _, y, err := w.TranslateCoordinates(parent, 0, 0) + if err != nil { + log.Error(errors.Wrap(err, "Failed to translate coords while focusing")) + return + } + + // log.Println("X:", x) + // log.Println("Y:", y) + + return y +} + // Swap changes the message with the ID to the given message. This provides a // low level API for edits that need a new Attach method. // @@ -75,12 +92,21 @@ func (c *GridStore) SwapMessage(msg GridMessage) bool { return false } + // Wrap msg inside a *gridMessage if it's not already. + mg, ok := msg.(*gridMessage) + if !ok { + mg = &gridMessage{GridMessage: msg} + } + // Add a row at index. The actual row we want to delete will be shifted // downwards. c.Grid.InsertRow(ix) // Let the new message be attached on top of the to-be-replaced message. - msg.Attach(c.Grid, ix) + mg.Attach(c.Grid, ix) + + // Set the message into the map. + c.messages[mg.ID()] = mg // Delete the to-be-replaced message, which we have shifted downwards // earlier, so we add 1. @@ -146,20 +172,22 @@ func (c *GridStore) FindMessage(isMessage func(msg GridMessage) bool) GridMessag return nil } -// FirstMessage returns the first message. -func (c *GridStore) FirstMessage() GridMessage { - if len(c.messageIDs) > 0 { - return c.messages[c.messageIDs[0]].GridMessage +// NthMessage returns the nth message. +func (c *GridStore) NthMessage(n int) GridMessage { + if len(c.messageIDs) > 0 && n >= 0 && n < len(c.messageIDs) { + return c.messages[c.messageIDs[n]].GridMessage } return nil } +// FirstMessage returns the first message. +func (c *GridStore) FirstMessage() GridMessage { + return c.NthMessage(0) +} + // LastMessage returns the latest message. func (c *GridStore) LastMessage() GridMessage { - if l := len(c.messageIDs); l > 0 { - return c.messages[c.messageIDs[l-1]].GridMessage - } - return nil + return c.NthMessage(c.MessagesLen() - 1) } // Message finds the message state in the container. It is not thread-safe. This @@ -228,6 +256,25 @@ func (c *GridStore) AddPresendMessage(msg input.PresendMessage) PresendGridMessa return presend } +func (c *GridStore) PrependMessageUnsafe(msg cchat.MessageCreate) { + msgc := &gridMessage{ + GridMessage: c.Construct.NewMessage(msg), + } + + c.Grid.InsertRow(0) + msgc.Attach(c.Grid, 0) + + // Prepend the message ID. + c.messageIDs = append(c.messageIDs, "") + copy(c.messageIDs[1:], c.messageIDs) + c.messageIDs[0] = msgc.ID() + + // Set the message into the map. + c.messages[msgc.ID()] = msgc + + c.Controller.BindMenu(msgc) +} + func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { // Call the event handler last. defer c.Controller.AuthorEvent(msg.Author()) diff --git a/internal/ui/messages/memberlist/memberlist.go b/internal/ui/messages/memberlist/memberlist.go index 57ff254..2589051 100644 --- a/internal/ui/messages/memberlist/memberlist.go +++ b/internal/ui/messages/memberlist/memberlist.go @@ -22,8 +22,9 @@ import ( var MemberListWidth = 250 type Container struct { - *gtk.ScrolledWindow - Main *gtk.Box + *gtk.Revealer + Scroll *gtk.ScrolledWindow + Main *gtk.Box // map id -> *Section Sections map[string]*Section @@ -47,11 +48,19 @@ func New() *Container { sw, _ := gtk.ScrolledWindowNew(nil, nil) sw.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) sw.Add(main) + sw.Show() + + rev, _ := gtk.RevealerNew() + rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) + rev.SetTransitionDuration(50) + rev.SetRevealChild(false) + rev.Add(sw) return &Container{ - ScrolledWindow: sw, - Main: main, - Sections: map[string]*Section{}, + Revealer: rev, + Scroll: sw, + Main: main, + Sections: map[string]*Section{}, } } @@ -62,6 +71,8 @@ func (c *Container) Reset() { c.stop = nil } + c.Revealer.SetRevealChild(false) + for _, section := range c.Sections { c.Main.Remove(section) } @@ -85,6 +96,7 @@ func (c *Container) TryAsyncList(server cchat.ServerMessage) { return func() { c.stop = f + c.Revealer.SetRevealChild(true) }, nil }) } @@ -363,7 +375,9 @@ func (m *Member) Update(member cchat.ListMember) { func (m *Member) Popup() { if len(m.output.Mentions) > 0 { p := labeluri.NewPopoverMentioner(m, m.output.Input, m.output.Mentions[0]) + p.Ref() // prevent the popover from closing itself p.SetPosition(gtk.POS_LEFT) + p.Connect("closed", p.Unref) p.Popup() } } diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index a05818f..b4fea25 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -210,3 +210,7 @@ func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) { func (m *GenericContainer) AttachMenu(newItems []menu.Item) { m.MenuItems = newItems } + +func (m *GenericContainer) Focusable() gtk.IWidget { + return m.Content +} diff --git a/internal/ui/messages/sadface/sadface.go b/internal/ui/messages/sadface/sadface.go index 9f8f025..5a597d2 100644 --- a/internal/ui/messages/sadface/sadface.go +++ b/internal/ui/messages/sadface/sadface.go @@ -55,6 +55,14 @@ func (v *FaceView) Reset() { v.Stack.SetVisibleChildName("empty") } +// func (v *FaceView) Disable() { +// v.Stack.SetSensitive(false) +// } + +// func (v *FaceView) Enable() { +// v.Stack.SetSensitive(true) +// } + func (v *FaceView) SetMain() { v.ensurePlaceholderDestroyed() v.Loading.Spinner.Stop() diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index be3aa6c..e407797 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -2,6 +2,7 @@ package messages import ( "context" + "time" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/icons" @@ -38,6 +39,15 @@ func init() { )) } +type Controller interface { + // OnMessageBusy is called when the message buffer is busy. This happens + // when it's loading messages. + OnMessageBusy() + // OnMessageDone is called after OnMessageBusy, when the message buffer is + // done with loading. + OnMessageDone() +} + type View struct { *sadface.FaceView Grid *gtk.Grid @@ -54,10 +64,12 @@ type View struct { // Inherit some useful methods. state + + ctrl Controller } -func NewView() *View { - view := &View{} +func NewView(c Controller) *View { + view := &View{ctrl: c} view.Typing = typing.New() view.Typing.Show() @@ -68,16 +80,26 @@ func NewView() *View { view.MsgBox.PackEnd(view.Typing, false, false, 0) view.MsgBox.Show() - // Create the message container, which will use PackEnd to add the widget on - // TOP of the typing indicator. - view.createMessageContainer() - view.Scroller = autoscroll.NewScrolledWindow() view.Scroller.Add(view.MsgBox) view.Scroller.SetVExpand(true) view.Scroller.SetHExpand(true) view.Scroller.Show() + view.MsgBox.SetFocusHAdjustment(view.Scroller.GetHAdjustment()) + view.MsgBox.SetFocusVAdjustment(view.Scroller.GetVAdjustment()) + + // Create the message container, which will use PackEnd to add the widget on + // TOP of the typing indicator. + view.createMessageContainer() + + // Fetch the message backlog when the user has scrolled to the top. + view.Scroller.Connect("edge-reached", func(_ *gtk.ScrolledWindow, p gtk.PositionType) { + if p == gtk.POS_TOP { + view.FetchBacklog() + } + }) + // A separator to go inbetween. sep, _ := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL) sep.SetHExpand(true) @@ -121,6 +143,9 @@ func (v *View) createMessageContainer() { v.Container = compact.NewContainer(v) } + v.Container.SetFocusHAdjustment(v.Scroller.GetHAdjustment()) + v.Container.SetFocusVAdjustment(v.Scroller.GetVAdjustment()) + // Add the new message container. v.MsgBox.PackEnd(v.Container, true, true, 0) } @@ -132,25 +157,23 @@ func (v *View) Reset() { v.Typing.Reset() // Reset the typing state. v.InputView.Reset() // Reset the input. v.MemberList.Reset() // Reset the member list. - v.Container.Reset() // Clean all messages. v.FaceView.Reset() // Switch back to the main screen. // Keep the scroller at the bottom. v.Scroller.Bottomed = true - // Recreate the message container if the type is different. - if v.contType != msgIndex { - v.createMessageContainer() - } + // Reallocate the entire message container. + v.createMessageContainer() } // JoinServer is not thread-safe, but it calls backend functions asynchronously. -func (v *View) JoinServer(session cchat.Session, server ServerMessage, done func()) { +func (v *View) JoinServer(session cchat.Session, server ServerMessage) { // Reset before setting. v.Reset() // Set the screen to loading. v.FaceView.SetLoading() + v.ctrl.OnMessageBusy() // Bind the state. v.state.bind(session, server) @@ -171,12 +194,12 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage, done func err = errors.Wrap(err, "Failed to join server") // Even if we're erroring out, we're running the done() callback // anyway. - return func() { done(); v.SetError(err) }, err + return func() { v.ctrl.OnMessageDone(); v.SetError(err) }, err } return func() { // Run the done() callback. - done() + v.ctrl.OnMessageDone() // Set the screen to the main one. v.FaceView.SetMain() @@ -193,6 +216,34 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage, done func }) } +func (v *View) FetchBacklog() { + var backlogger = v.state.Backlogger() + if backlogger == nil { + return + } + + var firstMsg = v.Container.FirstMessage() + if firstMsg == nil { + return + } + + // Set the window as busy. TODO: loading circles. + v.ctrl.OnMessageBusy() + + var done = func() { + v.ctrl.OnMessageDone() + + // Restore scrolling. + y := v.Container.TranslateCoordinates(v.MsgBox, firstMsg) + v.Scroller.GetVAdjustment().SetValue(float64(y)) + } + + gts.Async(func() (func(), error) { + err := backlogger.MessagesBefore(context.Background(), firstMsg.ID(), v.Container) + return done, errors.Wrap(err, "Failed to get messages before ID") + }) +} + func (v *View) AddPresendMessage(msg input.PresendMessage) func(error) { var presend = v.Container.AddPresendMessage(msg) @@ -290,10 +341,13 @@ type state struct { session cchat.Session server cchat.Server - actioner cchat.ServerMessageActioner + actioner cchat.ServerMessageActioner + backlogger cchat.ServerMessageBacklogger current func() // stop callback author string + + lastBacklogged time.Time } func (s *state) Reset() { @@ -318,10 +372,30 @@ func (s *state) SessionID() string { return "" } +const backloggingFreq = time.Second * 3 + +// Backlogger returns the backlogger instance if it's allowed to fetch more +// backlogs. +func (s *state) Backlogger() cchat.ServerMessageBacklogger { + if s.backlogger == nil || s.current == nil { + return nil + } + + var now = time.Now() + + if s.lastBacklogged.Add(backloggingFreq).After(now) { + return nil + } + + s.lastBacklogged = now + return s.backlogger +} + func (s *state) bind(session cchat.Session, server ServerMessage) { s.session = session s.server = server s.actioner, _ = server.(cchat.ServerMessageActioner) + s.backlogger, _ = server.(cchat.ServerMessageBacklogger) } func (s *state) setcurrent(fn func()) { diff --git a/internal/ui/primitives/autoscroll/autoscroll.go b/internal/ui/primitives/autoscroll/autoscroll.go index adaafb1..013dccd 100644 --- a/internal/ui/primitives/autoscroll/autoscroll.go +++ b/internal/ui/primitives/autoscroll/autoscroll.go @@ -11,6 +11,7 @@ type ScrolledWindow struct { func NewScrolledWindow() *ScrolledWindow { gtksw, _ := gtk.ScrolledWindowNew(nil, nil) gtksw.SetProperty("propagate-natural-height", true) + gtksw.SetProperty("window-placement", gtk.CORNER_BOTTOM_LEFT) sw := &ScrolledWindow{*gtksw, *gtksw.GetVAdjustment(), true} // bottomed by default sw.Connect("size-allocate", func(_ *gtk.ScrolledWindow) { diff --git a/internal/ui/primitives/roundimage/roundimage.go b/internal/ui/primitives/roundimage/roundimage.go index f0ea597..681a72d 100644 --- a/internal/ui/primitives/roundimage/roundimage.go +++ b/internal/ui/primitives/roundimage/roundimage.go @@ -19,7 +19,7 @@ const ( // supports a full circle for rounding. type Button struct { *gtk.Button - Image *Image + Image Imager } var roundButtonCSS = primitives.PrepareClassCSS("round-button", ` @@ -47,7 +47,21 @@ func NewEmptyButton() (*Button, error) { return &Button{Button: b}, nil } -func (b *Button) SetImage(img *Image) { +// NewCustomButton creates a new rounded button with the given Imager. If the +// given Imager implements the Connector interface (aka *StaticImage), then the +// function will implicitly connect its handlers to the button. +func NewCustomButton(img Imager) (*Button, error) { + b, _ := NewEmptyButton() + b.SetImage(img) + + if connector, ok := img.(Connector); ok { + connector.ConnectHandlers(b) + } + + return b, nil +} + +func (b *Button) SetImage(img Imager) { b.Image = img b.Button.SetImage(img) } @@ -56,13 +70,34 @@ type RadiusSetter interface { SetRadius(float64) } +type Connector interface { + ConnectHandlers(connector primitives.Connector) +} + +type Imager interface { + gtk.IWidget + RadiusSetter + + // Embed setters. + httputil.ImageContainerSizer + + GetPixbuf() *gdk.Pixbuf + GetAnimation() *gdk.PixbufAnimation + + GetImage() *gtk.Image +} + // StaticImage is an image that only plays a GIF if it's hovered on top of. type StaticImage struct { *Image animation *gdk.PixbufAnimation } -var _ httputil.ImageContainer = (*StaticImage)(nil) +var ( + _ Imager = (*StaticImage)(nil) + _ Connector = (*StaticImage)(nil) + _ httputil.ImageContainer = (*StaticImage)(nil) +) func NewStaticImage(parent primitives.Connector, radius float64) (*StaticImage, error) { i, err := NewImage(radius) @@ -110,6 +145,8 @@ type Image struct { Radius float64 } +var _ Imager = (*Image)(nil) + // NewImage creates a new round image. If radius is 0, then it will be half the // dimensions. If the radius is less than 0, then nothing is rounded. func NewImage(radius float64) (*Image, error) { @@ -126,6 +163,10 @@ func NewImage(radius float64) (*Image, error) { return image, nil } +func (i *Image) GetImage() *gtk.Image { + return i.Image +} + func (i *Image) SetRadius(r float64) { i.Radius = r } diff --git a/internal/ui/rich/labeluri/labeluri.go b/internal/ui/rich/labeluri/labeluri.go index ec3324d..86a96ed 100644 --- a/internal/ui/rich/labeluri/labeluri.go +++ b/internal/ui/rich/labeluri/labeluri.go @@ -187,24 +187,27 @@ func largeText(text string) string { // popoverImg creates a new button with an image for it, which is used for the // avatar in the user popover. func popoverImg(url string, round bool) gtk.IWidget { - var img *gtk.Image var btn *gtk.Button + var img *gtk.Image + var idl httputil.ImageContainerSizer if round { b, _ := roundimage.NewButton() - img = b.Image.Image + img = b.Image.GetImage() + idl = b.Image btn = b.Button } else { img, _ = gtk.ImageNew() btn, _ = gtk.ButtonNew() btn.Add(img) + idl = img } img.SetSizeRequest(AvatarSize, AvatarSize) img.SetHAlign(gtk.ALIGN_CENTER) img.Show() - httputil.AsyncImageSized(img, url, AvatarSize, AvatarSize) + httputil.AsyncImageSized(idl, url, AvatarSize, AvatarSize) btn.SetHAlign(gtk.ALIGN_CENTER) btn.SetRelief(gtk.RELIEF_NONE) diff --git a/internal/ui/service/session/server/children.go b/internal/ui/service/session/server/children.go index 6f9a519..ecd0ee2 100644 --- a/internal/ui/service/session/server/children.go +++ b/internal/ui/service/session/server/children.go @@ -89,6 +89,9 @@ func (c *Children) Reset() { if c.Box != nil { // Remove old servers from the list. for _, row := range c.Rows { + if row.IsHollow() { + continue + } c.Box.Remove(row) } } diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index 2c612d6..d14cbf1 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -316,9 +316,11 @@ func (r *Row) SetSelected(selected bool) { } func (r *Row) GetActive() bool { - AssertUnhollow(r) + if !r.IsHollow() { + return r.Button.GetActive() + } - return r.Button.GetActive() + return false } // SetRevealChild reveals the list of servers. It does nothing if there are no diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 4f5742a..6d5281d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -133,15 +133,19 @@ func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat. app.header.SetBreadcrumber(srv) + // Assert that server is also a list, then join the server. + app.window.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage)) +} + +func (app *App) OnMessageBusy() { // Disable the server list because we don't want the user to switch around // while we're loading. - app.window.Services.SetSensitive(false) + gts.App.Window.SetSensitive(false) +} - // Assert that server is also a list, then join the server. - app.window.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage), func() { - // Re-enable the server list. - app.window.Services.SetSensitive(true) - }) +func (app *App) OnMessageDone() { + // Re-enable the server list. + gts.App.Window.SetSensitive(true) } func (app *App) AuthenticateSession(list *service.List, ssvc *service.Service) { diff --git a/internal/ui/window.go b/internal/ui/window.go index 35513ed..aabc5ef 100644 --- a/internal/ui/window.go +++ b/internal/ui/window.go @@ -12,12 +12,17 @@ type window struct { MessageView *messages.View } -func newWindow(mainctl service.Controller) *window { +type Controller interface { + service.Controller + messages.Controller +} + +func newWindow(mainctl Controller) *window { services := service.NewView(mainctl) services.SetSizeRequest(leftMinWidth, -1) services.Show() - mesgview := messages.NewView() + mesgview := messages.NewView(mainctl) mesgview.Show() pane, _ := gtk.PanedNew(gtk.ORIENTATION_HORIZONTAL) diff --git a/profile.go b/profile.go index 2fbbb45..ce57450 100644 --- a/profile.go +++ b/profile.go @@ -3,23 +3,29 @@ package main import ( - "os" - "runtime/pprof" - _ "net/http/pprof" _ "github.com/ianlancetaylor/cgosymbolizer" ) +import "C" + +const ProfileAddr = "localhost:49583" + func init() { + C.HeapProfilerStart() + destructor = func() { C.HeapProfilerStop() } + + // runtime.SetBlockProfileRate(1) + // go func() { - // if err := http.ListenAndServe("localhost:42069", nil); err != nil { + // log.Println("Listening to profiler at", ProfileAddr) + + // if err := http.ListenAndServe(ProfileAddr, nil); err != nil { // log.Error(errors.Wrap(err, "Failed to start profiling HTTP server")) // } // }() - // runtime.SetBlockProfileRate(1) - // f, _ := os.Create("/tmp/cchat.pprof") // p := pprof.Lookup("block") @@ -33,13 +39,13 @@ func init() { // f.Close() // } - f, _ := os.Create("/tmp/cchat.pprof") - if err := pprof.StartCPUProfile(f); err != nil { - panic(err) - } + // f, _ := os.Create("/tmp/cchat.pprof") + // if err := pprof.StartCPUProfile(f); err != nil { + // panic(err) + // } - destructor = func() { - pprof.StopCPUProfile() - f.Close() - } + // destructor = func() { + // pprof.StopCPUProfile() + // f.Close() + // } }