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()
+ // }
}