diff --git a/internal/gts/gts.go b/internal/gts/gts.go index f8e4daf..e1fbb8d 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -22,6 +22,13 @@ var App struct { *gtk.Application Window *handy.ApplicationWindow Throttler *throttler.State + + closing bool +} + +// IsClosing returns true if the window is destroyed. +func IsClosing() bool { + return App.closing } // Windower is the interface for a window. @@ -121,6 +128,7 @@ func Main(wfn func() MainApplication) { App.Window.Window.Connect("destroy", func(window *handy.ApplicationWindow) { // Hide the application window. window.Hide() + App.closing = true // Let the main loop run once by queueing the stop loop afterwards. // This is to allow the main loop to properly hide the Gtk window diff --git a/internal/gts/httputil/httputil.go b/internal/gts/httputil/httputil.go index b721d31..eb4dd11 100644 --- a/internal/gts/httputil/httputil.go +++ b/internal/gts/httputil/httputil.go @@ -13,22 +13,31 @@ import ( "github.com/pkg/errors" ) -var basePath = filepath.Join(os.TempDir(), "cchat-gtk-totally-not-node-modules") +var basePath = filepath.Join(os.TempDir(), "cchat-gtk-caching-is-hard") var dskcached = http.Client{ Timeout: 15 * time.Second, - Transport: httpcache.NewTransport( - diskcache.NewWithDiskv(diskv.New(diskv.Options{ + Transport: &httpcache.Transport{ + Transport: &http.Transport{ + // Be generous: use a 128KB buffer instead of 4KB to hopefully + // reduce cgo calls. + WriteBufferSize: 128 * 1024, + ReadBufferSize: 128 * 1024, + }, + Cache: diskcache.NewWithDiskv(diskv.New(diskv.Options{ BasePath: basePath, TempDir: filepath.Join(basePath, "tmp"), PathPerm: 0750, FilePerm: 0750, - Compression: diskv.NewZlibCompressionLevel(5), - CacheSizeMax: 0, // 25 MiB in memory + Compression: diskv.NewZlibCompressionLevel(4), + CacheSizeMax: 25 * 1024 * 1024, // 25 MiB in memory })), - ), + MarkCachedResponses: true, + }, } +// TODO: log cache misses with httpcache.XFromCache + func get(ctx context.Context, url string, cached bool) (r *http.Response, err error) { q, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { diff --git a/internal/gts/httputil/image.go b/internal/gts/httputil/image.go index 0ded0e2..a90f2e3 100644 --- a/internal/gts/httputil/image.go +++ b/internal/gts/httputil/image.go @@ -1,6 +1,7 @@ package httputil import ( + "bufio" "context" "io" "mime" @@ -8,6 +9,7 @@ import ( "net/url" "path" "strings" + "sync" "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/log" @@ -19,7 +21,31 @@ import ( "github.com/pkg/errors" ) -// TODO: +// bufferPool provides a sync.Pool of *bufio.Writers. This is used to reduce the +// amount of cgo calls, by writing bytes in larger chunks. +// +// Technically, httpcache already wraps its cached reader around a bufio.Reader, +// but we have no control over the buffer size. +var bufferPool = sync.Pool{ + New: func() interface{} { + // Allocate a 512KB buffer by default. + const defaultBufSz = 512 * 1024 + + return bufio.NewWriterSize(nil, defaultBufSz) + }, +} + +func bufferedWriter(w io.Writer) *bufio.Writer { + buf := bufferPool.Get().(*bufio.Writer) + buf.Reset(w) + return buf +} + +func returnBufferedWriter(buf *bufio.Writer) { + // Unreference the internal reader. + buf.Reset(nil) + bufferPool.Put(buf) +} type ImageContainer interface { primitives.Connector @@ -67,9 +93,9 @@ func AsyncImage(ctx context.Context, scale = surfaceContainer.GetScaleFactor() } - go func() { - ctx := primitives.HandleDestroyCtx(ctx, img) + ctx = primitives.HandleDestroyCtx(ctx, img) + go func() { // Try and guess the MIME type from the URL. mimeType := mime.TypeByExtension(urlExt(imageURL)) @@ -102,7 +128,7 @@ func AsyncImage(ctx context.Context, l, err := gdk.PixbufLoaderNewWithType(fileType) if err != nil { - log.Error(errors.Wrap(err, "failed to make pixbuf loader")) + log.Error(errors.Wrapf(err, "failed to make PixbufLoader type %q", fileType)) return } @@ -117,11 +143,20 @@ func AsyncImage(ctx context.Context, l.Connect("area-prepared", load) l.Connect("area-updated", load) - if err := downloadImage(r.Body, l, procs, isGIF); err != nil { + // Borrow a buffered writer and return it at the end. + bufWriter := bufferedWriter(l) + defer returnBufferedWriter(bufWriter) + + if err := downloadImage(r.Body, bufWriter, procs, isGIF); err != nil { log.Error(errors.Wrapf(err, "failed to download %q", imageURL)) // Force close after downloading. } + if err := bufWriter.Flush(); err != nil { + log.Error(errors.Wrapf(err, "failed to flush writer for %q", imageURL)) + // Force close after downloading. + } + if err := l.Close(); err != nil { log.Error(errors.Wrapf(err, "failed to close pixbuf loader for %q", imageURL)) } diff --git a/internal/ui/messages/container/compact/compact.go b/internal/ui/messages/container/compact/compact.go index ecdd173..91354bd 100644 --- a/internal/ui/messages/container/compact/compact.go +++ b/internal/ui/messages/container/compact/compact.go @@ -13,8 +13,8 @@ type Container struct { } func NewContainer(ctrl container.Controller) *Container { - c := container.NewListContainer(constructor{}, ctrl) - primitives.AddClass(c, "compact-conatainer") + c := container.NewListContainer(ctrl, constructors) + primitives.AddClass(c, "compact-container") return &Container{c} } @@ -33,12 +33,19 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) { gts.ExecAsync(func() { c.ListContainer.DeleteMessageUnsafe(msg) }) } -type constructor struct{} +var constructors = container.Constructor{ + NewMessage: newMessage, + NewPresendMessage: newPresendMessage, +} + +func newMessage( + msg cchat.MessageCreate, _ container.MessageRow) container.MessageRow { -func (constructor) NewMessage(msg cchat.MessageCreate) container.MessageRow { return NewMessage(msg) } -func (constructor) NewPresendMessage(msg input.PresendMessage) container.PresendMessageRow { +func newPresendMessage( + msg input.PresendMessage, _ container.MessageRow) container.PresendMessageRow { + return NewPresendMessage(msg) } diff --git a/internal/ui/messages/container/compact/message.go b/internal/ui/messages/container/compact/message.go index 1d8442f..e070650 100644 --- a/internal/ui/messages/container/compact/message.go +++ b/internal/ui/messages/container/compact/message.go @@ -1,15 +1,33 @@ package compact import ( + "time" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/humanize" "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/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" "github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/pango" ) +var messageTimeCSS = primitives.PrepareClassCSS("", ` + .message-time { + margin-left: 1em; + margin-right: 1em; + } +`) + +var messageAuthorCSS = primitives.PrepareClassCSS("", ` + .message-author { + margin-right: 0.5em; + } +`) + type PresendMessage struct { message.PresendContainer Message @@ -17,60 +35,76 @@ type PresendMessage struct { func NewPresendMessage(msg input.PresendMessage) PresendMessage { msgc := message.NewPresendContainer(msg) - attachCompact(msgc.GenericContainer) return PresendMessage{ PresendContainer: msgc, - Message: Message{msgc.GenericContainer}, + Message: wrapMessage(msgc.GenericContainer), } } type Message struct { *message.GenericContainer + Timestamp *gtk.Label + Username *labeluri.Label } var _ container.MessageRow = (*Message)(nil) func NewMessage(msg cchat.MessageCreate) Message { - msgc := message.NewContainer(msg) - attachCompact(msgc) + msgc := wrapMessage(message.NewContainer(msg)) message.FillContainer(msgc, msg) - - return Message{msgc} + return msgc } func NewEmptyMessage() Message { ct := message.NewEmptyContainer() - attachCompact(ct) - - return Message{ct} + return wrapMessage(ct) } -var messageTimeCSS = primitives.PrepareClassCSS("message-time", ` - .message-time { - margin-left: 1em; - margin-right: 1em; +func wrapMessage(ct *message.GenericContainer) Message { + ts := message.NewTimestamp() + ts.SetVAlign(gtk.ALIGN_START) + ts.Show() + messageTimeCSS(ts) + + user := message.NewUsername() + user.SetMaxWidthChars(25) + user.SetEllipsize(pango.ELLIPSIZE_NONE) + user.SetLineWrap(true) + user.SetLineWrapMode(pango.WRAP_WORD_CHAR) + user.Show() + messageAuthorCSS(user) + + ct.PackStart(ts, false, false, 0) + ct.PackStart(user, false, false, 0) + ct.PackStart(ct.Content, true, true, 0) + ct.SetClass("compact") + + return Message{ + GenericContainer: ct, + Timestamp: ts, + Username: user, } -`) - -var messageAuthorCSS = primitives.PrepareClassCSS("message-author", ` - .message-author { - margin-right: 0.5em; - } -`) - -func attachCompact(container *message.GenericContainer) { - container.Timestamp.SetVAlign(gtk.ALIGN_START) - container.Username.SetMaxWidthChars(25) - container.Username.SetEllipsize(pango.ELLIPSIZE_NONE) - container.Username.SetLineWrap(true) - container.Username.SetLineWrapMode(pango.WRAP_WORD_CHAR) - - messageTimeCSS(container.Timestamp) - messageAuthorCSS(container.Username) - - container.PackStart(container.Timestamp, false, false, 0) - container.PackStart(container.Username, false, false, 0) - container.PackStart(container.Content, true, true, 0) - container.SetClass("compact") +} + +// SetReferenceHighlighter sets the reference highlighter into the message. +func (m Message) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) { + m.GenericContainer.SetReferenceHighlighter(r) + m.Username.SetReferenceHighlighter(r) +} + +func (m Message) UpdateTimestamp(t time.Time) { + m.GenericContainer.UpdateTimestamp(t) + m.Timestamp.SetText(humanize.TimeAgo(t)) + m.Timestamp.SetTooltipText(t.Format(time.Stamp)) +} + +func (m Message) UpdateAuthor(author cchat.Author) { + m.GenericContainer.UpdateAuthor(author) + + cfg := markup.RenderConfig{} + cfg.NoReferencing = true + cfg.SetForegroundAnchor(m.ContentBodyStyle) + + m.Username.SetOutput(markup.RenderCmplxWithConfig(author.Name(), cfg)) } diff --git a/internal/ui/messages/container/container.go b/internal/ui/messages/container/container.go index 0251a8a..5079637 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -42,7 +42,7 @@ type Container interface { // CreateMessageUnsafe creates a new message and returns the index that is // the location the message is added to. - CreateMessageUnsafe(cchat.MessageCreate) + CreateMessageUnsafe(cchat.MessageCreate) MessageRow UpdateMessageUnsafe(cchat.MessageUpdate) DeleteMessageUnsafe(cchat.MessageDelete) @@ -84,9 +84,9 @@ type Controller interface { // Constructor is an interface for making custom message implementations which // allows ListContainer to generically work with. -type Constructor interface { - NewMessage(cchat.MessageCreate) MessageRow - NewPresendMessage(input.PresendMessage) PresendMessageRow +type Constructor struct { + NewMessage func(msg cchat.MessageCreate, before MessageRow) MessageRow + NewPresendMessage func(msg input.PresendMessage, before MessageRow) PresendMessageRow } const ColumnSpacing = 8 @@ -107,10 +107,19 @@ type messageRow struct { presend message.PresendContainer // this shouldn't be here but i'm lazy } +// unwrapRow is a helper that unwraps a messageRow if it's not nil. If it's nil, +// then a nil interface is returned. +func unwrapRow(msg *messageRow) MessageRow { + if msg == nil || msg.MessageRow == nil { + return nil + } + return msg.MessageRow +} + var _ Container = (*ListContainer)(nil) -func NewListContainer(constr Constructor, ctrl Controller) *ListContainer { - listStore := NewListStore(constr, ctrl) +func NewListContainer(ctrl Controller, constr Constructor) *ListContainer { + listStore := NewListStore(ctrl, constr) listStore.ListBox.Show() clamp := handy.ClampNew() @@ -128,11 +137,12 @@ func NewListContainer(constr Constructor, ctrl Controller) *ListContainer { } } -// CreateMessageUnsafe inserts a message. It does not clean up old messages. -func (c *ListContainer) CreateMessageUnsafe(msg cchat.MessageCreate) { - // Insert the message first. - c.ListStore.CreateMessageUnsafe(msg) -} +// TODO: remove useless abstraction (this file). + +// // CreateMessageUnsafe inserts a message. It does not clean up old messages. +// func (c *ListContainer) CreateMessageUnsafe(msg cchat.MessageCreate) MessageRow { +// return c.ListStore.CreateMessageUnsafe(msg) +// } // CleanMessages cleans up the oldest messages if the user is scrolled to the // bottom. True is returned if there were changes. diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index e4aa6d0..92c1f6a 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -41,61 +41,45 @@ const ( AvatarMargin = 10 ) +var messageConstructors = container.Constructor{ + NewMessage: NewMessage, + NewPresendMessage: NewPresendMessage, +} + +func NewMessage( + msg cchat.MessageCreate, before container.MessageRow) container.MessageRow { + + if gridMessageIsAuthor(before, msg.Author()) { + return NewCollapsedMessage(msg) + } + + return NewFullMessage(msg) +} + +func NewPresendMessage( + msg input.PresendMessage, before container.MessageRow) container.PresendMessageRow { + + if gridMessageIsAuthor(before, msg.Author()) { + return NewCollapsedSendingMessage(msg) + } + + return NewFullSendingMessage(msg) +} + type Container struct { *container.ListContainer } func NewContainer(ctrl container.Controller) *Container { - c := &Container{} - c.ListContainer = container.NewListContainer(c, ctrl) - + c := container.NewListContainer(ctrl, messageConstructors) primitives.AddClass(c, "cozy-container") - return c -} - -func (c *Container) NewMessage(msg cchat.MessageCreate) container.MessageRow { - // 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() - - // Try and reuse an existing avatar if the author has one. - if avatarURL := author.Avatar(); avatarURL != "" { - // Try reusing the avatar, but fetch it from the interndet if we can't - // reuse. The reuse function does this for us. - c.reuseAvatar(author.ID(), author.Avatar(), full) - } - - return full -} - -func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendMessageRow { - // We can do the check here since we're never using NewPresendMessage for - // backlog messages. - if c.lastMessageIsAuthor(msg.AuthorID(), msg.Author().String(), 0) { - return NewCollapsedSendingMessage(msg) - } - - full := NewFullSendingMessage(msg) - - // Try and see if we can reuse the avatar, and fallback if possible. The - // avatar URL passed in here will always yield an equal. - c.reuseAvatar(msg.AuthorID(), msg.AuthorAvatarURL(), &full.FullMessage) - - return full + return &Container{ListContainer: c} } func (c *Container) findAuthorID(authorID string) container.MessageRow { // Search the old author if we have any. return c.ListStore.FindMessage(func(msgc container.MessageRow) bool { - return msgc.AuthorID() == authorID + return msgc.Author().ID() == authorID }) } @@ -108,32 +92,48 @@ func (c *Container) reuseAvatar(authorID, avatarURL string, full *FullMessage) { // Borrow the avatar pixbuf, but only if the avatar URL is the same. p, ok := lastAuthorMsg.(AvatarPixbufCopier) - if ok && lastAuthorMsg.AvatarURL() == avatarURL { - p.CopyAvatarPixbuf(full.Avatar.Image) - full.Avatar.ManuallySetURL(avatarURL) - } else { - // We can't borrow, so we need to fetch it anew. - full.Avatar.SetURL(avatarURL) + if ok && lastAuthorMsg.Author().Avatar() == avatarURL { + if p.CopyAvatarPixbuf(full.Avatar.Image) { + full.Avatar.ManuallySetURL(avatarURL) + return + } } + + // We can't borrow, so we need to fetch it anew. + full.Avatar.SetURL(avatarURL) } -func (c *Container) lastMessageIsAuthor(id cchat.ID, name string, offset int) bool { - // Get the offfsetth message from last. - var last = c.ListStore.NthMessage((c.ListStore.MessagesLen() - 1) + offset) - return gridMessageIsAuthor(last, id, name) -} +// lastMessageIsAuthor removed - assuming index before insertion is harmful. -func gridMessageIsAuthor(gridMsg container.MessageRow, id cchat.ID, name string) bool { - return gridMsg != nil && - gridMsg.AuthorID() == id && - gridMsg.AuthorName() == name +func gridMessageIsAuthor(gridMsg container.MessageRow, author cchat.Author) bool { + if gridMsg == nil { + return false + } + leftAuthor := gridMsg.Author() + return true && + leftAuthor.ID() == author.ID() && + leftAuthor.Name().String() == author.Name().String() } 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.ListContainer.CreateMessageUnsafe(msg) + row := c.ListContainer.CreateMessageUnsafe(msg) + + // Is this a full message? If so, then we should fetch the avatar when + // we can. + if full, ok := row.(*FullMessage); ok { + author := msg.Author() + avatarURL := author.Avatar() + + // Try and reuse an existing avatar if the author has one. + if avatarURL != "" { + // Try reusing the avatar, but fetch it from the internet if we can't + // reuse. The reuse function does this for us. + c.reuseAvatar(author.ID(), avatarURL, full) + } + } // Did the handler wipe old messages? It will only do so if the user is // scrolled to the bottom. @@ -143,24 +143,12 @@ func (c *Container) CreateMessage(msg cchat.MessageCreate) { c.uncompact(c.FirstMessage()) } - switch msg.ID() { - // Should we collapse this message? Yes, if the current message is - // inserted at the end and its author is the same as the last author. - case c.ListContainer.LastMessage().ID(): - author := msg.Author() - if c.lastMessageIsAuthor(author.ID(), author.Name().String(), -1) { - c.compact(c.ListContainer.LastMessage()) - } - // If we've prepended the message, then see if we need to collapse the // second message. - case c.ListContainer.FirstMessage().ID(): - if sec := c.NthMessage(1); sec != nil { - // The author is the same; collapse. - author := msg.Author() - if gridMessageIsAuthor(sec, author.ID(), author.Name().String()) { - c.compact(sec) - } + if first := c.ListContainer.FirstMessage(); first != nil && first.ID() == msg.ID() { + // If the author is the same, then collapse. + if sec := c.NthMessage(1); sec != nil && gridMessageIsAuthor(sec, msg.Author()) { + c.compact(sec) } } }) @@ -174,15 +162,17 @@ func (c *Container) UpdateMessage(msg cchat.MessageUpdate) { func (c *Container) DeleteMessage(msg cchat.MessageDelete) { gts.ExecAsync(func() { + msgID := msg.ID() + // Get the previous and next message before deleting. We'll need them to // evaluate whether we need to change anything. - prev, next := c.ListStore.Around(msg.ID()) + prev, next := c.ListStore.Around(msgID) // The function doesn't actually try and re-collapse the bottom message // when a sandwiched message is deleted. This is fine. // Delete the message off of the parent's container. - msg := c.ListStore.PopMessage(msg.ID()) + msg := c.ListStore.PopMessage(msgID) // Don't calculate if we don't have any messages, or no messages before // and after. @@ -190,8 +180,10 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) { return } + msgAuthorID := msg.Author().ID() + // Check if the last message is the author's (relative to i): - if prev.AuthorID() == msg.AuthorID() { + if prev.Author().ID() == msgAuthorID { // If the author is the same, then we don't need to uncollapse the // message. return @@ -199,7 +191,7 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) { // If the next message (relative to i) is not the deleted message's // author, then we don't need to uncollapse it. - if next.AuthorID() != msg.AuthorID() { + if next.Author().ID() != msgAuthorID { return } @@ -211,39 +203,30 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) { func (c *Container) uncompact(msg container.MessageRow) { // We should only uncompact the message if it's compacted in the first // place. - if collapse, ok := msg.(Collapsible); !ok || !collapse.Collapsed() { - return - } - - // We can't unwrap if the message doesn't implement Unwrapper. - uw, ok := msg.(Unwrapper) + compact, ok := msg.(*CollapsedMessage) if !ok { return } // Start the "lengthy" uncollapse process. - full := WrapFullMessage(uw.Unwrap()) + full := WrapFullMessage(compact.Unwrap()) // Update the container to reformat everything including the timestamps. message.RefreshContainer(full, full.GenericContainer) // Update the avatar if needed be, since we're now showing it. - c.reuseAvatar(msg.AuthorID(), msg.AvatarURL(), full) + author := msg.Author() + c.reuseAvatar(author.ID(), author.Avatar(), full) // Swap the old next message out for a new one. c.ListStore.SwapMessage(full) } func (c *Container) compact(msg container.MessageRow) { - // Exit if the message is already collapsed. - if collapse, ok := msg.(Collapsible); !ok || collapse.Collapsed() { - return - } - - uw, ok := msg.(Unwrapper) + full, ok := msg.(*FullMessage) if !ok { return } - compact := WrapCollapsedMessage(uw.Unwrap()) + compact := WrapCollapsedMessage(full.Unwrap()) message.RefreshContainer(compact, compact.GenericContainer) c.ListStore.SwapMessage(compact) diff --git a/internal/ui/messages/container/cozy/message_collapsed.go b/internal/ui/messages/container/cozy/message_collapsed.go index 2a4902f..7d97f66 100644 --- a/internal/ui/messages/container/cozy/message_collapsed.go +++ b/internal/ui/messages/container/cozy/message_collapsed.go @@ -16,6 +16,7 @@ import ( type CollapsedMessage struct { // Author is still updated normally. *message.GenericContainer + Timestamp *gtk.Label } func NewCollapsedMessage(msg cchat.MessageCreate) *CollapsedMessage { @@ -26,21 +27,23 @@ func NewCollapsedMessage(msg cchat.MessageCreate) *CollapsedMessage { func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage { // Set Timestamp's padding accordingly to Avatar's. - gc.Timestamp.SetSizeRequest(AvatarSize, -1) - gc.Timestamp.SetVAlign(gtk.ALIGN_START) - gc.Timestamp.SetXAlign(0.5) // middle align - gc.Timestamp.SetMarginEnd(container.ColumnSpacing) - gc.Timestamp.SetMarginStart(container.ColumnSpacing * 2) + ts := message.NewTimestamp() + ts.SetSizeRequest(AvatarSize, -1) + ts.SetVAlign(gtk.ALIGN_START) + ts.SetXAlign(0.5) // middle align + ts.SetMarginEnd(container.ColumnSpacing) + ts.SetMarginStart(container.ColumnSpacing * 2) // Set Content's padding accordingly to FullMessage's main box. gc.Content.ToWidget().SetMarginEnd(container.ColumnSpacing * 2) - gc.PackStart(gc.Timestamp, false, false, 0) + gc.PackStart(ts, false, false, 0) gc.PackStart(gc.Content, true, true, 0) gc.SetClass("cozy-collapsed") return &CollapsedMessage{ GenericContainer: gc, + Timestamp: ts, } } diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index b529931..94de10f 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -13,6 +13,8 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage" "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" + "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/cairo" "github.com/gotk3/gotk3/gtk" ) @@ -27,12 +29,12 @@ type FullMessage struct { Avatar *Avatar MainBox *gtk.Box // wraps header and content - // Header wraps author and timestamp. - HeaderBox *gtk.Box + Header *labeluri.Label + timestamp string // markup } type AvatarPixbufCopier interface { - CopyAvatarPixbuf(img httputil.SurfaceContainer) + CopyAvatarPixbuf(img httputil.SurfaceContainer) bool } var ( @@ -41,10 +43,6 @@ var ( _ container.MessageRow = (*FullMessage)(nil) ) -var boldCSS = primitives.PrepareCSS(` - * { font-weight: 600; } -`) - var avatarCSS = primitives.PrepareClassCSS("cozy-avatar", ` /* Slightly dip down on click */ .cozy-avatar:active { @@ -63,33 +61,26 @@ func NewFullMessage(msg cchat.MessageCreate) *FullMessage { } func WrapFullMessage(gc *message.GenericContainer) *FullMessage { + header := labeluri.NewLabel(text.Rich{}) + header.SetHAlign(gtk.ALIGN_START) // left-align + header.SetMaxWidthChars(100) + header.Show() + avatar := NewAvatar() avatar.SetMarginTop(TopFullMargin / 2) avatar.SetMarginStart(container.ColumnSpacing * 2) avatar.Connect("clicked", func(w gtk.IWidget) { - if output := gc.Username.Output(); len(output.Mentions) > 0 { + if output := header.Output(); len(output.Mentions) > 0 { labeluri.PopoverMentioner(w, output.Input, output.Mentions[0]) } }) avatar.Show() - // Style the timestamp accordingly. - gc.Timestamp.SetXAlign(0.0) // left-align - gc.Timestamp.SetVAlign(gtk.ALIGN_END) // bottom-align - gc.Timestamp.SetMarginStart(0) // clear margins - - gc.Username.SetMaxWidthChars(75) - // Attach the class and CSS for the left avatar. avatarCSS(avatar) // Attach the username style provider. - primitives.AttachCSS(gc.Username, boldCSS) - - header, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) - header.PackStart(gc.Username, false, false, 0) - header.PackStart(gc.Timestamp, false, false, 7) // padding - header.Show() + // primitives.AttachCSS(gc.Username, boldCSS) main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) main.PackStart(header, false, false, 0) @@ -108,9 +99,10 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage { return &FullMessage{ GenericContainer: gc, - Avatar: avatar, - MainBox: main, - HeaderBox: header, + + Avatar: avatar, + MainBox: main, + Header: header, } } @@ -118,17 +110,12 @@ func (m *FullMessage) Collapsed() bool { return false } func (m *FullMessage) Unwrap() *message.GenericContainer { // Remove GenericContainer's widgets from the containers. - m.HeaderBox.Remove(m.Username) - m.HeaderBox.Remove(m.Timestamp) - m.MainBox.Remove(m.HeaderBox) - m.MainBox.Remove(m.Content) - - // Hide the avatar. - m.Avatar.Hide() + m.Header.Destroy() + m.MainBox.Remove(m.Content) // not ours, so don't destroy. // Remove the message from the grid. - m.Remove(m.Avatar) - m.Remove(m.MainBox) + m.Avatar.Destroy() + m.MainBox.Destroy() // Return after removing. return m.GenericContainer @@ -136,18 +123,36 @@ func (m *FullMessage) Unwrap() *message.GenericContainer { func (m *FullMessage) UpdateTimestamp(t time.Time) { m.GenericContainer.UpdateTimestamp(t) - m.Timestamp.SetText(humanize.TimeAgoLong(t)) + + m.timestamp = " " + + `` + humanize.TimeAgoLong(t) + `` + + // Update the timestamp. + m.Header.SetMarkup(m.Header.Output().Markup + m.timestamp) } func (m *FullMessage) UpdateAuthor(author cchat.Author) { - // Call the parent's method to update the labels. + // Call the parent's method to update the state. m.GenericContainer.UpdateAuthor(author) + m.UpdateAuthorName(author.Name()) m.Avatar.SetURL(author.Avatar()) } +func (m *FullMessage) UpdateAuthorName(name text.Rich) { + cfg := markup.RenderConfig{} + cfg.NoReferencing = true + cfg.SetForegroundAnchor(m.ContentBodyStyle) + + output := markup.RenderCmplxWithConfig(name, cfg) + output.Markup = `` + output.Markup + "" + + m.Header.SetMarkup(output.Markup + m.timestamp) + m.Header.SetUnderlyingOutput(output) +} + // 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.SurfaceContainer) { +func (m *FullMessage) CopyAvatarPixbuf(dst httputil.SurfaceContainer) bool { switch img := m.Avatar.Image.GetImage(); img.GetStorageType() { case gtk.IMAGE_PIXBUF: dst.SetFromPixbuf(img.GetPixbuf()) @@ -156,7 +161,10 @@ func (m *FullMessage) CopyAvatarPixbuf(dst httputil.SurfaceContainer) { case gtk.IMAGE_SURFACE: v, _ := img.GetProperty("surface") dst.SetFromSurface(v.(*cairo.Surface)) + default: + return false } + return true } func (m *FullMessage) AttachMenu(items []menu.Item) { diff --git a/internal/ui/messages/container/list.go b/internal/ui/messages/container/list.go index fa80474..9746fef 100644 --- a/internal/ui/messages/container/list.go +++ b/internal/ui/messages/container/list.go @@ -1,7 +1,6 @@ package container import ( - "log" "time" "github.com/diamondburned/cchat" @@ -35,7 +34,7 @@ type ListStore struct { messages map[messageKey]*messageRow } -func NewListStore(constr Constructor, ctrl Controller) *ListStore { +func NewListStore(ctrl Controller, constr Constructor) *ListStore { listBox, _ := gtk.ListBoxNew() listBox.SetSelectionMode(gtk.SELECTION_SINGLE) listBox.Show() @@ -154,12 +153,9 @@ func (c *ListStore) around(aroundID cchat.ID) (before, after *messageRow) { // LatestMessageFrom returns the latest message with the given user ID. This is // used for the input prompt. func (c *ListStore) LatestMessageFrom(userID string) (msgID string, ok bool) { - log.Println("LatestMessageFrom called") - // FindMessage already looks from the latest messages. var msg = c.FindMessage(func(msg MessageRow) bool { - log.Println("Author:", msg.AuthorName()) - return msg.AuthorID() == userID + return msg.Author().ID() == userID }) if msg == nil { @@ -229,25 +225,22 @@ func (c *ListStore) FindMessage(isMessage func(MessageRow) bool) MessageRow { msg, _ := c.findMessage(false, func(row *messageRow) bool { return isMessage(row.MessageRow) }) - if msg != nil { - return msg.MessageRow - } - return nil + return unwrapRow(msg) } func (c *ListStore) nthMessage(n int) *messageRow { v := primitives.NthChild(c.ListBox, n) + if v == nil { + return nil + } + id := primitives.GetName(v.(primitives.Namer)) return c.message(id, "") } // NthMessage returns the nth message. func (c *ListStore) NthMessage(n int) MessageRow { - msg := c.nthMessage(n) - if msg != nil { - return msg.MessageRow - } - return nil + return unwrapRow(c.nthMessage(n)) } // FirstMessage returns the first message. @@ -263,10 +256,7 @@ func (c *ListStore) LastMessage() MessageRow { // Message finds the message state in the container. It is not thread-safe. This // exists for backwards compatibility. func (c *ListStore) Message(msgID cchat.ID, nonce string) MessageRow { - if m := c.message(msgID, nonce); m != nil { - return m.MessageRow - } - return nil + return unwrapRow(c.message(msgID, nonce)) } func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow { @@ -300,7 +290,8 @@ func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow { // AddPresendMessage inserts an input.PresendMessage into the container and // returning a wrapped widget interface. func (c *ListStore) AddPresendMessage(msg input.PresendMessage) PresendMessageRow { - presend := c.Construct.NewPresendMessage(msg) + before := c.LastMessage() + presend := c.Construct.NewPresendMessage(msg, before) msgc := &messageRow{ MessageRow: presend, @@ -326,7 +317,7 @@ func (c *ListStore) bindMessage(msgc *messageRow) { // unreliable. The index might be off if the message buffer is cleaned up. Don't // rely on it. -func (c *ListStore) CreateMessageUnsafe(msg cchat.MessageCreate) { +func (c *ListStore) CreateMessageUnsafe(msg cchat.MessageCreate) MessageRow { // Call the event handler last. defer c.Controller.AuthorEvent(msg.Author()) @@ -337,33 +328,45 @@ func (c *ListStore) CreateMessageUnsafe(msg cchat.MessageCreate) { msgc.UpdateTimestamp(msg.Time()) c.bindMessage(msgc) - return + return msgc.MessageRow } - msgc := &messageRow{ - MessageRow: c.Construct.NewMessage(msg), - } msgTime := msg.Time() - // Iterate and compare timestamp to find where to insert a message. - after, index := c.findMessage(true, func(after *messageRow) bool { - return msgTime.After(after.Time()) + // Iterate and compare timestamp to find where to insert a message. Note + // that "before" is the message that will go before the to-be-inserted + // method. + before, index := c.findMessage(true, func(before *messageRow) bool { + return msgTime.After(before.Time()) }) - // Append the message. If after is nil, then that means the message is the - // oldest, so we add it to the front of the list. - if after != nil { - index++ // insert right after - c.ListBox.Insert(msgc.Row(), index) - } else { + msgc := &messageRow{ + MessageRow: c.Construct.NewMessage(msg, unwrapRow(before)), + } + + // Add the message. If before is nil, then the to-be-inserted message is the + // earliest message, therefore we prepend it. + if before == nil { index = 0 - c.ListBox.Add(msgc.Row()) + c.ListBox.Prepend(msgc.Row()) + } else { + index++ // insert right after + + // Fast path: Insert did appear a lot on profiles, so we can try and use + // Add over Insert when we know. + if c.MessagesLen() == index { + c.ListBox.Add(msgc.Row()) + } else { + c.ListBox.Insert(msgc.Row(), index) + } } // Set the ID into the message map. c.messages[idKey(msgc.ID())] = msgc c.bindMessage(msgc) + + return msgc.MessageRow } func (c *ListStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { @@ -416,8 +419,6 @@ func (c *ListStore) DeleteEarliest(n int) { id := primitives.GetName(v.(primitives.Namer)) gridMsg := c.message(id, "") - log.Println("Deleting overflowed message ID from", gridMsg.AuthorName()) - if id := gridMsg.ID(); id != "" { delete(c.messages, idKey(id)) } diff --git a/internal/ui/messages/input/input.go b/internal/ui/messages/input/input.go index 3753e40..084a016 100644 --- a/internal/ui/messages/input/input.go +++ b/internal/ui/messages/input/input.go @@ -11,6 +11,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" "github.com/diamondburned/handy" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" @@ -20,7 +21,7 @@ import ( type Controller interface { AddPresendMessage(msg PresendMessage) (onErr func(error)) LatestMessageFrom(userID cchat.ID) (messageID cchat.ID, ok bool) - MessageAuthorMarkup(msgID cchat.ID) (markup string, ok bool) + MessageAuthor(msgID cchat.ID) cchat.Author } // LabelBorrower is an interface that allows the caller to borrow a label. @@ -330,12 +331,22 @@ func (f *Field) StartReplyingTo(msgID cchat.ID) { f.replyingID = msgID f.sendIcon.SetFromIconName(replyButtonIcon, gtk.ICON_SIZE_BUTTON) - name, ok := f.ctrl.MessageAuthorMarkup(msgID) - if !ok { - name = "message" + if author := f.ctrl.MessageAuthor(msgID); author != nil { + // Extract the name from the author's rich text and only render the area + // with the MessageReference. + name := author.Name() + + for _, seg := range name.Segments { + if seg.AsMessageReferencer() != nil || seg.AsMentioner() != nil { + mention := markup.Render(markup.SubstringSegment(name, seg)) + f.indicator.BorrowLabel("Replying to " + mention) + return + } + } } - f.indicator.BorrowLabel("Replying to " + name) + f.indicator.BorrowLabel("Replying to message.") + return } // Editable returns whether or not the input field can be edited. diff --git a/internal/ui/messages/input/sendable.go b/internal/ui/messages/input/sendable.go index d0cb93c..469e8eb 100644 --- a/internal/ui/messages/input/sendable.go +++ b/internal/ui/messages/input/sendable.go @@ -64,14 +64,12 @@ func (f *Field) sendInput() { } f.SendMessage(SendMessageData{ - time: time.Now().UTC(), - content: text, - author: f.Username.GetLabel(), - authorID: f.UserID, - authorURL: f.Username.GetIconURL(), - nonce: f.generateNonce(), - replyID: f.replyingID, - files: attachments, + time: time.Now().UTC(), + content: text, + author: newAuthor(f), + nonce: f.generateNonce(), + replyID: f.replyingID, + files: attachments, }) // Clear the input field after sending. @@ -110,14 +108,12 @@ func (files Files) Attachments() []cchat.MessageAttachment { // SendMessageData contains what is to be sent in a message. It behaves // similarly to a regular CreateMessage. type SendMessageData struct { - time time.Time - content string - author text.Rich - authorID cchat.ID - authorURL string // avatar - nonce string - replyID cchat.ID - files Files + time time.Time + content string + author cchat.Author + nonce string + replyID cchat.ID + files Files } var _ cchat.SendableMessage = (*SendMessageData)(nil) @@ -130,9 +126,7 @@ type PresendMessage interface { // These methods are reserved for internal use. - Author() text.Rich - AuthorID() string - AuthorAvatarURL() string // may be empty + Author() cchat.Author Files() []attachment.File } @@ -142,12 +136,30 @@ var _ PresendMessage = (*SendMessageData)(nil) func (s SendMessageData) ID() string { return s.nonce } func (s SendMessageData) Time() time.Time { return s.time } func (s SendMessageData) Content() string { return s.content } -func (s SendMessageData) Author() text.Rich { return s.author } -func (s SendMessageData) AuthorID() string { return s.authorID } -func (s SendMessageData) AuthorAvatarURL() string { return s.authorURL } +func (s SendMessageData) Author() cchat.Author { return s.author } func (s SendMessageData) AsNoncer() cchat.Noncer { return s } func (s SendMessageData) Nonce() string { return s.nonce } func (s SendMessageData) Files() []attachment.File { return s.files } func (s SendMessageData) AsAttacher() cchat.Attacher { return s.files } func (s SendMessageData) AsReplier() cchat.Replier { return s } func (s SendMessageData) ReplyingTo() cchat.ID { return s.replyID } + +type sendableAuthor struct { + id cchat.ID + name text.Rich + avatarURL string +} + +func newAuthor(f *Field) sendableAuthor { + return sendableAuthor{ + f.UserID, + f.Username.GetLabel(), + f.Username.GetIconURL(), + } +} + +var _ cchat.Author = (*sendableAuthor)(nil) + +func (a sendableAuthor) ID() string { return a.id } +func (a sendableAuthor) Name() text.Rich { return a.name } +func (a sendableAuthor) Avatar() string { return a.avatarURL } diff --git a/internal/ui/messages/memberlist/eventqueue.go b/internal/ui/messages/memberlist/eventqueue.go index 649d30f..61aa48e 100644 --- a/internal/ui/messages/memberlist/eventqueue.go +++ b/internal/ui/messages/memberlist/eventqueue.go @@ -29,7 +29,7 @@ func (evq *eventQueue) Add(fn func()) { if evq.activated { evq.idleQueue = append(evq.idleQueue, fn) } else { - gts.ExecAsync(fn) + gts.ExecLater(fn) } } @@ -54,18 +54,25 @@ func (evq *eventQueue) pop() []func() { func (evq *eventQueue) Deactivate() { var popped = evq.pop() + const chunkSz = 25 + // We shouldn't try and run more than a certain amount of callbacks within a // single loop, as it will freeze up the UI. - if len(popped) > 25 { - for _, fn := range popped { - gts.ExecAsync(fn) - } - return - } + for i := 0; i < len(popped); i += chunkSz { + // Calculate the bounds in chunks. + start, end := i, min(i+chunkSz, len(popped)) - gts.ExecAsync(func() { - for _, fn := range popped { - fn() - } - }) + gts.ExecLater(func() { + for _, fn := range popped[start:end] { + fn() + } + }) + } +} + +func min(i, j int) int { + if i < j { + return i + } + return j } diff --git a/internal/ui/messages/memberlist/memberlist.go b/internal/ui/messages/memberlist/memberlist.go index d0e9188..363c0b4 100644 --- a/internal/ui/messages/memberlist/memberlist.go +++ b/internal/ui/messages/memberlist/memberlist.go @@ -366,6 +366,7 @@ func NewMember(member cchat.ListMember) *Member { var noMentionLinks = markup.RenderConfig{ NoMentionLinks: true, + NoReferencing: true, } func (m *Member) Update(member cchat.ListMember) { @@ -376,9 +377,10 @@ func (m *Member) Update(member cchat.ListMember) { } m.output = markup.RenderCmplxWithConfig(member.Name(), noMentionLinks) + txt := strings.Builder{} txt.WriteString(fmt.Sprintf( - ` %s`, + ` %s`, statusColors(member.Status()), m.output.Markup, )) @@ -395,20 +397,22 @@ func (m *Member) Update(member cchat.ListMember) { // Popup pops up the mention popover if any. func (m *Member) Popup(evq EventQueuer) { - if len(m.output.Mentions) > 0 { - p := labeluri.NewPopoverMentioner(m, m.output.Input, m.output.Mentions[0]) - if p == nil { - return - } - - // Unbounded concurrency is kind of bad. We should deal with - // this in the future. - evq.Activate() - p.Connect("closed", func(interface{}) { evq.Deactivate() }) - - p.SetPosition(gtk.POS_LEFT) - p.Popup() + if len(m.output.Mentions) == 0 { + return } + + p := labeluri.NewPopoverMentioner(m, m.output.Input, m.output.Mentions[0]) + if p == nil { + return + } + + // Unbounded concurrency is kind of bad. We should deal with + // this in the future. + evq.Activate() + p.Connect("closed", func(interface{}) { evq.Deactivate() }) + + p.SetPosition(gtk.POS_LEFT) + p.Popup() } func statusColors(status cchat.Status) uint32 { diff --git a/internal/ui/messages/message/author.go b/internal/ui/messages/message/author.go new file mode 100644 index 0000000..88e0daa --- /dev/null +++ b/internal/ui/messages/message/author.go @@ -0,0 +1,50 @@ +package message + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat/text" +) + +// Author implements cchat.Author. It effectively contains a copy of +// cchat.Author. +type Author struct { + id cchat.ID + name text.Rich + avatarURL string +} + +var _ cchat.Author = (*Author)(nil) + +// NewAuthor creates a new Author that is a copy of the given author. +func NewAuthor(author cchat.Author) Author { + a := Author{} + a.Update(author) + return a +} + +// NewCustomAuthor creates a new Author from the given parameters. +func NewCustomAuthor(id cchat.ID, name text.Rich, avatar string) Author { + return Author{ + id, + name, + avatar, + } +} + +func (a *Author) Update(author cchat.Author) { + a.id = author.ID() + a.name = author.Name() + a.avatarURL = author.Avatar() +} + +func (a Author) ID() string { + return a.id +} + +func (a Author) Name() text.Rich { + return a.name +} + +func (a Author) Avatar() string { + return a.avatarURL +} diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index 1b2ce8d..5318ce0 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -4,28 +4,22 @@ import ( "time" "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/humanize" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" - "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/pango" ) type Container interface { - ID() string + ID() cchat.ID Time() time.Time - AuthorID() string - AuthorName() string - AuthorMarkup() string - AvatarURL() string // avatar + Author() cchat.Author Nonce() string UpdateAuthor(cchat.Author) - UpdateAuthorName(text.Rich) UpdateContent(c text.Rich, edited bool) UpdateTimestamp(time.Time) } @@ -40,8 +34,6 @@ func FillContainer(c Container, msg cchat.MessageCreate) { // RefreshContainer sets the container's contents to the one from // GenericContainer. This is mainly used for transferring between different // containers. -// -// Right now, this only works with Timestamp, as that's the only state tracked. func RefreshContainer(c Container, gc *GenericContainer) { c.UpdateTimestamp(gc.time) } @@ -53,40 +45,20 @@ type GenericContainer struct { row *gtk.ListBoxRow // contains Box class string - id string - time time.Time - authorID string - authorName string - avatarURL string // avatar - nonce string + id string + time time.Time + author Author + nonce string - Timestamp *gtk.Label - Username *labeluri.Label - Content gtk.IWidget // conceal widget implementation - - contentBox *gtk.Box // basically what is in Content - ContentBody *labeluri.Label + Content *gtk.Box + ContentBody *labeluri.Label + ContentBodyStyle *gtk.StyleContext menuItems []menu.Item } var _ Container = (*GenericContainer)(nil) -var timestampCSS = primitives.PrepareClassCSS("message-time", ` - .message-time { - opacity: 0.3; - font-size: 0.8em; - margin-top: 0.2em; - margin-bottom: 0.2em; - } -`) - -var authorCSS = primitives.PrepareClassCSS("message-author", ` - .message-author { - color: mix(@theme_bg_color, @theme_fg_color, 0.8); - } -`) - // 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 { @@ -94,24 +66,12 @@ func NewContainer(msg cchat.MessageCreate) *GenericContainer { c.id = msg.ID() c.time = msg.Time() c.nonce = msg.Nonce() - c.authorID = msg.Author().ID() + c.author.Update(msg.Author()) return c } func NewEmptyContainer() *GenericContainer { - ts, _ := gtk.LabelNew("") - ts.SetEllipsize(pango.ELLIPSIZE_MIDDLE) - ts.SetXAlign(0.5) // centre align - ts.SetVAlign(gtk.ALIGN_END) - ts.Show() - - user := labeluri.NewLabel(text.Rich{}) - user.SetXAlign(0) // left align - user.SetVAlign(gtk.ALIGN_START) - user.SetTrackVisitedLinks(false) - user.Show() - ctbody := labeluri.NewLabel(text.Rich{}) ctbody.SetVExpand(true) ctbody.SetHAlign(gtk.ALIGN_START) @@ -123,6 +83,9 @@ func NewEmptyContainer() *GenericContainer { ctbody.SetTrackVisitedLinks(false) ctbody.Show() + ctbodyStyle, _ := ctbody.GetStyleContext() + ctbodyStyle.AddClass("message-content") + // Wrap the content label inside a content box. ctbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) ctbox.SetHExpand(true) @@ -135,24 +98,15 @@ func NewEmptyContainer() *GenericContainer { row, _ := gtk.ListBoxRowNew() row.Add(box) row.Show() - - // Add CSS classes. - primitives.AddClass(ts, "message-time") primitives.AddClass(row, "message-row") - primitives.AddClass(user, "message-author") - primitives.AddClass(ctbody, "message-content") - timestampCSS(ts) - authorCSS(ts) gc := &GenericContainer{ Box: box, row: row, - Timestamp: ts, - Username: user, - Content: ctbox, - contentBox: ctbox, - ContentBody: ctbody, + Content: ctbox, + ContentBody: ctbody, + ContentBodyStyle: ctbodyStyle, // Time is important, as it is used to sort messages, so we have to be // careful with this. @@ -183,7 +137,6 @@ func (m *GenericContainer) SetClass(class string) { // SetReferenceHighlighter sets the reference highlighter into the message. func (m *GenericContainer) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) { - m.Username.SetReferenceHighlighter(r) m.ContentBody.SetReferenceHighlighter(r) } @@ -195,20 +148,8 @@ func (m *GenericContainer) Time() time.Time { return m.time } -func (m *GenericContainer) AuthorID() string { - return m.authorID -} - -func (m *GenericContainer) AuthorName() string { - return m.authorName -} - -func (m *GenericContainer) AuthorMarkup() string { - return m.Username.Label.Label.GetLabel() -} - -func (m *GenericContainer) AvatarURL() string { - return m.avatarURL +func (m *GenericContainer) Author() cchat.Author { + return m.author } func (m *GenericContainer) Nonce() string { @@ -217,23 +158,10 @@ func (m *GenericContainer) Nonce() string { func (m *GenericContainer) UpdateTimestamp(t time.Time) { m.time = t - m.Timestamp.SetText(humanize.TimeAgo(t)) - m.Timestamp.SetTooltipText(t.Format(time.Stamp)) } func (m *GenericContainer) UpdateAuthor(author cchat.Author) { - m.authorID = author.ID() - m.avatarURL = author.Avatar() - m.UpdateAuthorName(author.Name()) -} - -func (m *GenericContainer) UpdateAuthorName(name text.Rich) { - cfg := markup.RenderConfig{} - cfg.NoReferencing = true - cfg.SetForegroundAnchor(m.ContentBody) - - m.authorName = name.String() - m.Username.SetOutput(markup.RenderCmplxWithConfig(name, cfg)) + m.author.Update(author) } func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) { diff --git a/internal/ui/messages/message/sending.go b/internal/ui/messages/message/sending.go index af7c448..c79b550 100644 --- a/internal/ui/messages/message/sending.go +++ b/internal/ui/messages/message/sending.go @@ -35,14 +35,10 @@ type GenericPresendContainer struct { var _ PresendContainer = (*GenericPresendContainer)(nil) func NewPresendContainer(msg input.PresendMessage) *GenericPresendContainer { - return WrapPresendContainer(NewEmptyContainer(), msg) -} - -func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *GenericPresendContainer { + c := NewEmptyContainer() c.nonce = msg.Nonce() - c.authorID = msg.AuthorID() + c.UpdateAuthor(msg.Author()) c.UpdateTimestamp(msg.Time()) - c.UpdateAuthorName(msg.Author()) p := &GenericPresendContainer{ GenericContainer: c, @@ -56,7 +52,7 @@ func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *Generi } func (m *GenericPresendContainer) SetSensitive(sensitive bool) { - m.contentBox.SetSensitive(sensitive) + m.Content.SetSensitive(sensitive) } func (m *GenericPresendContainer) SetDone(id string) { @@ -68,13 +64,13 @@ func (m *GenericPresendContainer) SetDone(id string) { // free it from memory. m.presend = nil m.uploads = nil - m.contentBox.SetTooltipText("") + m.Content.SetTooltipText("") // Remove everything in the content box. m.clearBox() // Re-add the content label. - m.contentBox.Add(m.ContentBody) + m.Content.Add(m.ContentBody) // Set the sensitivity from false in SetLoading back to true. m.SetSensitive(true) @@ -82,18 +78,18 @@ func (m *GenericPresendContainer) SetDone(id string) { func (m *GenericPresendContainer) SetLoading() { m.SetSensitive(false) - m.contentBox.SetTooltipText("") + m.Content.SetTooltipText("") // Clear everything inside the content container. m.clearBox() // Add the content label. - m.contentBox.Add(m.ContentBody) + m.Content.Add(m.ContentBody) // Add the attachment progress box back in, if any. if m.uploads != nil { m.uploads.Show() // show the bars - m.contentBox.Add(m.uploads) + m.Content.Add(m.uploads) } if content := m.presend.Content(); content != "" { @@ -106,13 +102,13 @@ func (m *GenericPresendContainer) SetLoading() { func (m *GenericPresendContainer) SetSentError(err error) { m.SetSensitive(true) // allow events incl right clicks - m.contentBox.SetTooltipText(err.Error()) + m.Content.SetTooltipText(err.Error()) // Remove everything again. m.clearBox() // Re-add the label. - m.contentBox.Add(m.ContentBody) + m.Content.Add(m.ContentBody) // Style the label appropriately by making it red. var content = EmptyContentPlaceholder @@ -132,13 +128,10 @@ func (m *GenericPresendContainer) SetSentError(err error) { )) errl.Show() - m.contentBox.Add(errl) + m.Content.Add(errl) } // clearBox clears everything inside the content container. func (m *GenericPresendContainer) clearBox() { - primitives.ForeachChild(m.contentBox, func(v interface{}) (stop bool) { - m.contentBox.Remove(v.(gtk.IWidget)) - return false - }) + primitives.RemoveChildren(m.Content) } diff --git a/internal/ui/messages/message/timestamp.go b/internal/ui/messages/message/timestamp.go new file mode 100644 index 0000000..d70be98 --- /dev/null +++ b/internal/ui/messages/message/timestamp.go @@ -0,0 +1,27 @@ +package message + +import ( + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/gotk3/gotk3/gtk" + "github.com/gotk3/gotk3/pango" +) + +var timestampCSS = primitives.PrepareClassCSS("message-time", ` + .message-time { + opacity: 0.3; + font-size: 0.8em; + margin-top: 0.2em; + margin-bottom: 0.2em; + } +`) + +func NewTimestamp() *gtk.Label { + ts, _ := gtk.LabelNew("") + ts.SetEllipsize(pango.ELLIPSIZE_MIDDLE) + ts.SetXAlign(0.5) // centre align + ts.SetVAlign(gtk.ALIGN_END) + ts.Show() + + timestampCSS(ts) + return ts +} diff --git a/internal/ui/messages/message/username.go b/internal/ui/messages/message/username.go new file mode 100644 index 0000000..a28e154 --- /dev/null +++ b/internal/ui/messages/message/username.go @@ -0,0 +1,25 @@ +package message + +import ( + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" + "github.com/diamondburned/cchat/text" + "github.com/gotk3/gotk3/gtk" +) + +var authorCSS = primitives.PrepareClassCSS("message-author", ` + .message-author { + color: mix(@theme_bg_color, @theme_fg_color, 0.8); + } +`) + +func NewUsername() *labeluri.Label { + user := labeluri.NewLabel(text.Rich{}) + user.SetXAlign(0) // left align + user.SetVAlign(gtk.ALIGN_START) + user.SetTrackVisitedLinks(false) + user.Show() + + authorCSS(user) + return user +} diff --git a/internal/ui/messages/sadface/sadface.go b/internal/ui/messages/sadface/sadface.go index 450f000..1e251a1 100644 --- a/internal/ui/messages/sadface/sadface.go +++ b/internal/ui/messages/sadface/sadface.go @@ -14,8 +14,10 @@ type FaceView struct { gtk.Stack placeholder gtk.IWidget - Face *Container - Loading *Spinner + face *Container + loading *Spinner + parent gtk.IWidget + empty gtk.IWidget } func New(parent gtk.IWidget, placeholder gtk.IWidget) *FaceView { @@ -31,42 +33,50 @@ func New(parent gtk.IWidget, placeholder gtk.IWidget) *FaceView { stack, _ := gtk.StackNew() stack.SetTransitionDuration(55) stack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE) - stack.AddNamed(parent, "main") - stack.AddNamed(placeholder, "placeholder") - stack.AddNamed(c, "face") - stack.AddNamed(s, "loading") - stack.AddNamed(b, "empty") + stack.Add(parent) + stack.Add(c) + stack.Add(s) + stack.Add(b) // Show placeholder by default. - stack.SetVisibleChildName("placeholder") + stack.AddNamed(placeholder, "placeholder") + stack.SetVisibleChild(placeholder) - return &FaceView{*stack, placeholder, c, s} + return &FaceView{ + Stack: *stack, + placeholder: placeholder, + + face: c, + loading: s, + parent: parent, + empty: b, + } } // Reset brings the view to an empty box. func (v *FaceView) Reset() { - v.Loading.Spinner.Stop() - v.Stack.SetVisibleChildName("empty") + v.loading.Spinner.Stop() + v.Stack.SetVisibleChild(v.empty) v.ensurePlaceholderDestroyed() } func (v *FaceView) SetMain() { - v.Loading.Spinner.Stop() - v.Stack.SetVisibleChildName("main") + v.loading.Spinner.Stop() + v.Stack.SetVisibleChild(v.parent) v.ensurePlaceholderDestroyed() } func (v *FaceView) SetLoading() { - v.Loading.Spinner.Start() - v.Stack.SetVisibleChildName("loading") + v.loading.Spinner.Start() + v.Stack.SetVisibleChild(v.loading) v.ensurePlaceholderDestroyed() } func (v *FaceView) SetError(err error) { - v.Face.SetError(err) - v.Stack.SetVisibleChildName("face") + v.face.SetError(err) + v.Stack.SetVisibleChild(v.face) v.ensurePlaceholderDestroyed() - v.Loading.Spinner.Stop() + v.loading.Spinner.Stop() } func (v *FaceView) ensurePlaceholderDestroyed() { @@ -74,7 +84,7 @@ func (v *FaceView) ensurePlaceholderDestroyed() { if v.placeholder != nil { // Safely remove the placeholder from the stack. if v.Stack.GetVisibleChildName() == "placeholder" { - v.Stack.SetVisibleChildName("empty") + v.Stack.SetVisibleChild(v.empty) } // Remove the placeholder widget. diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index dbe6bc6..6ae5f86 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -232,8 +232,8 @@ func (v *View) Reset() { // reset resets the message view, but does not change visible containers. func (v *View) reset() { - v.Header.Reset() // Reset the header. v.state.Reset() // Reset the state variables. + v.Header.Reset() // Reset the header. v.Typing.Reset() // Reset the typing state. v.InputView.Reset() // Reset the input. v.MemberList.Reset() // Reset the member list. @@ -397,13 +397,13 @@ func (v *View) AuthorEvent(author cchat.Author) { } } -func (v *View) MessageAuthorMarkup(msgID cchat.ID) (string, bool) { +func (v *View) MessageAuthor(msgID cchat.ID) cchat.Author { msg := v.Container.Message(msgID, "") if msg == nil { - return "", false + return nil } - return msg.AuthorMarkup(), true + return msg.Author() } // LatestMessageFrom returns the last message ID with that author. diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go index fba9e9d..b687d60 100644 --- a/internal/ui/primitives/primitives.go +++ b/internal/ui/primitives/primitives.go @@ -26,13 +26,13 @@ type Container interface { var _ Container = (*gtk.Container)(nil) func RemoveChildren(w Container) { - type destroyer interface { - Destroy() - } + // type destroyer interface { + // Destroy() + // } - children := w.GetChildren() - children.Foreach(func(child interface{}) { w.Remove(child.(gtk.IWidget)) }) - children.Free() + w.GetChildren().FreeFull(func(child interface{}) { + w.Remove(child.(gtk.IWidget)) + }) } // ChildrenLen gets the total count of children for the given container. @@ -47,9 +47,15 @@ func NthChild(w Container, n int) interface{} { children := w.GetChildren() defer children.Free() + // Bound check! + if n < 0 || int(children.Length()) >= n { + return nil + } + if n == 0 { return children.Data() } + return children.NthData(uint(n)) } @@ -324,7 +330,9 @@ func PrepareClassCSS(class, css string) (attach func(StyleContexter)) { return func(ctx StyleContexter) { s, _ := ctx.GetStyleContext() s.AddProvider(prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) - s.AddClass(class) + if class != "" { + s.AddClass(class) + } } } diff --git a/internal/ui/rich/labeluri/labeluri.go b/internal/ui/rich/labeluri/labeluri.go index 0cd9130..7365655 100644 --- a/internal/ui/rich/labeluri/labeluri.go +++ b/internal/ui/rich/labeluri/labeluri.go @@ -82,12 +82,20 @@ func (l *Label) Output() markup.RenderOutput { return l.output } -// SetOutput sets the internal output and label. +// SetOutput sets the internal output and label. It preserves the tail if +// any. func (l *Label) SetOutput(o markup.RenderOutput) { l.output = o l.SetMarkup(o.Markup) } +// SetUnderlyingOutput sets the output state without changing the label's +// markup. This is useful for internal use cases where the label is updated +// separately. +func (l *Label) SetUnderlyingOutput(o markup.RenderOutput) { + l.output = o +} + type ReferenceHighlighter interface { HighlightReference(ref markup.ReferenceSegment) } diff --git a/internal/ui/rich/parser/markup/markup.go b/internal/ui/rich/parser/markup/markup.go index 872ae86..0b88a65 100644 --- a/internal/ui/rich/parser/markup/markup.go +++ b/internal/ui/rich/parser/markup/markup.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/attrmap" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/hl" "github.com/diamondburned/cchat/text" @@ -21,7 +20,30 @@ import ( var Hyphenate = false func hyphenate(text string) string { - return fmt.Sprintf(`%s`, Hyphenate, text) + if !Hyphenate { + return text + } + return `` + text + `` +} + +// SubstringSegment slices the given rich text. +func SubstringSegment(rich text.Rich, seg text.Segment) text.Rich { + start, end := seg.Bounds() + substring := text.Rich{ + Content: rich.Content[start:end], + Segments: make([]text.Segment, 0, len(rich.Segments)), + } + + for _, seg := range rich.Segments { + i, j := seg.Bounds() + + // Bound-check. + if start <= i && j <= end { + substring.Segments = append(substring.Segments, seg) + } + } + + return substring } // RenderOutput is the output of a render. @@ -79,8 +101,13 @@ func (r RenderOutput) URISegment(uri string) text.Segment { } } +var simpleConfig = RenderConfig{ + NoMentionLinks: true, + NoReferencing: true, +} + func Render(content text.Rich) string { - return RenderCmplx(content).Markup + return RenderCmplxWithConfig(content, simpleConfig).Markup } // RenderCmplx renders content into a complete output. @@ -107,18 +134,19 @@ type RenderConfig struct { // SetForegroundAnchor sets the AnchorColor of the render config to be that of // the regular text foreground color. -func (c *RenderConfig) SetForegroundAnchor(styler primitives.StyleContexter) { - styleCtx, _ := styler.GetStyleContext() - - if rgba := styleCtx.GetColor(gtk.STATE_FLAG_NORMAL); rgba != nil { - var color uint32 - for _, v := range rgba.Floats() { // [0.0, 1.0] - color = (color << 8) + uint32(v*0xFF) - } - - c.AnchorColor.bool = true - c.AnchorColor.uint32 = color +func (c *RenderConfig) SetForegroundAnchor(ctx *gtk.StyleContext) { + rgba := ctx.GetColor(gtk.STATE_FLAG_NORMAL) + if rgba == nil { + return } + + var color uint32 + for _, v := range rgba.Floats() { // [0.0, 1.0] + color = (color << 8) + uint32(v*0xFF) + } + + c.AnchorColor.bool = true + c.AnchorColor.uint32 = color } func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { @@ -183,35 +211,19 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { // Mentioner needs to be before colorer, as we'd want the below color // segment to also highlight the full mention as well as make the // padding part of the hyperlink. - if mentioner := segment.AsMentioner(); mentioner != nil && !cfg.NoMentionLinks { + if mentioner := segment.AsMentioner(); mentioner != nil { // Render the mention into "cchat://mention:0" or such. Other // components will take care of showing the information. - appended.AnchorNU(start, end, fmtSegmentURI(MentionType, len(mentions))) - hasAnchor = true + if !cfg.NoMentionLinks { + appended.AnchorNU(start, end, fmtSegmentURI(MentionType, len(mentions))) + hasAnchor = true + } // Add the mention segment into the list regardless of hyperlinks. mentions = append(mentions, MentionSegment{ Segment: segment, Mentioner: mentioner, }) - - // TODO: figure out a way to readd Pad. Right now, backend - // implementations can arbitrarily add multiple mentions onto the - // author for overloading, which we don't want to break. - - // // Determine if the mention segment covers the entire label. - // // Only pad the name and add a dimmed background if the bounds do - // // not cover the whole segment. - // var cover = (start == 0) && (end == len(content.Content)) - // if !cover { - // appended.Pad(start, end) - // } - - // // If we don't have a mention color for this segment, then try to - // // use our own AnchorColor. - // if !hasColor && cfg.AnchorColor.bool { - // appended.Span(start, end, colorAttrs(cfg.AnchorColor.uint32, false)...) - // } } if colorer := segment.AsColorer(); colorer != nil { @@ -223,18 +235,18 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { // Don't use AnchorColor for the link, as we're technically just // borrowing the anchor tag for its use. We should also prefer the // username popover (Mention) over this. - if !cfg.NoReferencing && !hasAnchor { - if reference := segment.AsMessageReferencer(); reference != nil { + if reference := segment.AsMessageReferencer(); reference != nil { + if !cfg.NoReferencing && !hasAnchor { // Render the mention into "cchat://reference:0" or such. Other // components will take care of showing the information. appended.AnchorNU(start, end, fmtSegmentURI(ReferenceType, len(references))) - - // Add the mention segment into the list regardless of hyperlinks. - references = append(references, ReferenceSegment{ - Segment: segment, - MessageReferencer: reference, - }) } + + // Add the mention segment into the list regardless of hyperlinks. + references = append(references, ReferenceSegment{ + Segment: segment, + MessageReferencer: reference, + }) } if attributor := segment.AsAttributor(); attributor != nil { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 8092cd6..dbce92e 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -159,7 +159,9 @@ func (app *App) SessionSelected(svc *service.Service, ses *session.Row) { } func (app *App) ClearMessenger(ses *session.Row) { - if app.MessageView.SessionID() == ses.Session.ID() { + // No need to try if the window is destroyed already, since its children + // will also be destroyed. + if !gts.IsClosing() && app.MessageView.SessionID() == ses.Session.ID() { app.MessageView.Reset() } }