Refactor server list, minor appearance tweaks

pull/19/head
diamondburned 2 years ago
parent e73f9a099b
commit 095107180d

@ -2,13 +2,11 @@ module github.com/diamondburned/cchat-gtk
go 1.16
replace github.com/diamondburned/cchat-discord => ../cchat-discord
require (
github.com/Xuanwo/go-locale v1.0.0
github.com/alecthomas/chroma v0.7.3
github.com/diamondburned/cchat v0.6.4
github.com/diamondburned/cchat-discord v0.0.0-20210326063953-deb4ccb32bff
github.com/diamondburned/cchat-discord v0.0.0-20210501072434-cc2b2ee4c799
github.com/diamondburned/gspell v0.0.0-20201229064336-e43698fd5828
github.com/diamondburned/handy v0.0.0-20210329054445-387ad28eb2c2
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972

@ -145,6 +145,8 @@ github.com/diamondburned/cchat-discord v0.0.0-20210326063215-9eb392a95413 h1:r6P
github.com/diamondburned/cchat-discord v0.0.0-20210326063215-9eb392a95413/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs=
github.com/diamondburned/cchat-discord v0.0.0-20210326063953-deb4ccb32bff h1:p5XYPavnJ89wrJAf4ns6f1OfHQz5NMU9uXlX3EiKdfU=
github.com/diamondburned/cchat-discord v0.0.0-20210326063953-deb4ccb32bff/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs=
github.com/diamondburned/cchat-discord v0.0.0-20210501072434-cc2b2ee4c799 h1:xxqeuAx0T9SsS8DYKe4jxzL2saEpLyQeAttD0sX/g1E=
github.com/diamondburned/cchat-discord v0.0.0-20210501072434-cc2b2ee4c799/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs=
github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db h1:VQI2PdbsdsRJ7d669kp35GbCUO44KZ0Xfqdu4o/oqVg=
github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db/go.mod h1:M87kjNzWVPlkZycFNzpGPKQXzkHNnZphuwMf3E9ckgc=
github.com/diamondburned/gotk3 v0.0.0-20201209182406-e7291341a091 h1:lQpSWzbi3rQf66aMSip/rIypasIFwqCqF0Wfn5og6gw=

@ -96,8 +96,6 @@ func Restore() {
log.Error(errors.Wrap(err, "Failed to unmarshal main config.json"))
}
log.Printlnf("To restore: %#v", toRestore)
for path, v := range toRestore {
if err := UnmarshalFromFile(path, v); err != nil {
log.Error(errors.Wrapf(err, "Failed to unmarshal %s", path))

@ -22,17 +22,23 @@ func NewContainer(ctrl container.Controller) *Container {
func (c *Container) NewPresendMessage(state *message.PresendState) container.PresendMessageRow {
msg := WrapPresendMessage(state)
c.AddMessage(msg)
c.addMessage(msg)
return msg
}
func (c *Container) CreateMessage(msg cchat.MessageCreate) {
gts.ExecAsync(func() {
msg := WrapMessage(message.NewState(msg))
c.ListContainer.AddMessage(msg)
c.addMessage(msg)
c.CleanMessages()
})
}
func (c *Container) addMessage(msg container.MessageRow) {
_, at := container.InsertPosition(c, msg.Unwrap().Time)
c.AddMessageAt(msg, at)
}
func (c *Container) UpdateMessage(msg cchat.MessageUpdate) {
gts.ExecAsync(func() { container.UpdateMessage(c, msg) })
}

@ -94,13 +94,9 @@ func (m Message) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) {
m.Username.SetReferenceHighlighter(r)
}
func (m Message) Unwrap(revert bool) *message.State {
if revert {
m.unwrap()
func (m Message) Revert() *message.State {
m.unwrap()
m.ClearBox()
primitives.RemoveChildren(m)
m.SetClass("")
}
return m.State
return m.Unwrap()
}

@ -1,6 +1,8 @@
package container
import (
"time"
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
@ -37,21 +39,21 @@ type Container interface {
// NewPresendMessage creates and adds a presend message state into the list.
NewPresendMessage(state *message.PresendState) PresendMessageRow
// AddMessage adds a new message into the list.
AddMessage(row MessageRow)
// AddMessageAt adds a new message into the list at the given index.
AddMessageAt(row MessageRow, ix int)
// FirstMessage returns the first message in the buffer. Nil is returned if
// there's nothing.
FirstMessage() MessageRow
// LastMessage returns the last message in the buffer or nil if there's
// MessagesLen returns the current number of messages.
MessagesLen() int
// NthMessage returns the nth message in the buffer or nil if there's
// nothing.
LastMessage() MessageRow
NthMessage(ix int) MessageRow
// Message finds and returns the message, if any. It performs maximum 2
// constant-time lookups.
Message(id cchat.ID, nonce string) MessageRow
// FindMessage finds a message that satisfies the given callback. It
// iterates the message buffer from latest to earliest.
FindMessage(isMessage func(MessageRow) bool) MessageRow
FindMessage(isMessage func(MessageRow) bool) (MessageRow, int)
// Highlight temporarily highlights the given message for a short while.
Highlight(msg MessageRow)
@ -71,10 +73,56 @@ func UpdateMessage(ct Container, update cchat.MessageUpdate) {
}
// LatestMessageFrom returns the latest message from the given author ID.
func LatestMessageFrom(ct Container, authorID cchat.ID) MessageRow {
return ct.FindMessage(func(msg MessageRow) bool {
return msg.Unwrap(false).Author.ID == authorID
func LatestMessageFrom(ct Container, authorID cchat.ID) (MessageRow, int) {
finder, ok := ct.(messageFinder)
if !ok {
return ct.FindMessage(func(msg MessageRow) bool {
return msg.Unwrap().Author.ID == authorID
})
}
msg, ix := finder.findMessage(true, func(msg *messageRow) bool {
return msg.state.Author.ID == authorID
})
return unwrapRow(msg), ix
}
// FirstMessage returns the first message in the buffer. Nil is returned if
// there's nothing.
func FirstMessage(ct Container) MessageRow {
return ct.NthMessage(0)
}
// LastMessage returns the last message in the buffer or nil if there's nothing.
func LastMessage(ct Container) MessageRow {
return ct.NthMessage(ct.MessagesLen() - 1)
}
// InsertPosition returns the message that is before the given time (or nil) and
// the new index of the message with the given timestamp. If -1 is returned,
// then there is no message prior, and the message should be prepended on top.
func InsertPosition(ct Container, t time.Time) (MessageRow, int) {
var row MessageRow
var mIx int
finder, ok := ct.(messageFinder)
if !ok {
row, mIx = ct.FindMessage(func(msg MessageRow) bool {
return t.After(msg.Unwrap().Time)
})
} else {
// 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.
msg, ix := finder.findMessage(true, func(msg *messageRow) bool {
return t.After(msg.state.Time)
})
row = unwrapRow(msg)
mIx = ix
}
return row, mIx
}
// Controller is for menu actions.
@ -109,6 +157,7 @@ type ListContainer struct {
// messageRow w/ required internals
type messageRow struct {
MessageRow
state *message.State
presend message.Presender // this shouldn't be here but i'm lazy
}
@ -140,11 +189,6 @@ func NewListContainer(ctrl Controller) *ListContainer {
}
}
func (c *ListContainer) AddMessage(row MessageRow) {
c.ListStore.AddMessage(row)
c.CleanMessages()
}
// CleanMessages cleans up the oldest messages if the user is scrolled to the
// bottom. True is returned if there were changes.
func (c *ListContainer) CleanMessages() bool {

@ -14,7 +14,7 @@ type Avatar struct {
}
func NewAvatar(parent primitives.Connector) *Avatar {
img := roundimage.NewStillImage(nil, 0)
img := roundimage.NewStillImage(parent, 0)
img.SetSizeRequest(AvatarSize, AvatarSize)
img.Show()

@ -10,20 +10,6 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
)
// Collapsible is an interface for cozy messages to return whether or not
// they're full or collapsed.
type Collapsible interface {
// Compact returns true if the message is a compact one and not full.
Collapsed() bool
}
var (
_ Collapsible = (*CollapsedMessage)(nil)
_ Collapsible = (*CollapsedSendingMessage)(nil)
_ Collapsible = (*FullMessage)(nil)
_ Collapsible = (*FullSendingMessage)(nil)
)
const (
AvatarSize = 40
AvatarMargin = 10
@ -61,69 +47,58 @@ func NewContainer(ctrl container.Controller) *Container {
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.Unwrap(false).Author.ID == authorID
})
}
const splitDuration = 10 * time.Minute
// isCollapsible returns true if the given lastMsg has matching conditions with
// the given msg.
func isCollapsible(last container.MessageRow, msg *message.State) bool {
if last == nil || msg.ID == "" {
if last == nil || msg == nil {
return false
}
lastMsg := last.Unwrap(false)
lastMsg := last.Unwrap()
return true &&
lastMsg.Author.ID == msg.ID &&
lastMsg.Author.ID == msg.Author.ID &&
lastMsg.Time.Add(splitDuration).After(msg.Time)
}
func (c *Container) NewPresendMessage(state *message.PresendState) container.PresendMessageRow {
msgr := NewPresendMessage(state, c.LastMessage())
c.AddMessage(msgr)
before, at := container.InsertPosition(c, state.Time)
msgr := NewPresendMessage(state, before)
c.AddMessageAt(msgr, at)
return msgr
}
func (c *Container) CreateMessage(msg cchat.MessageCreate) {
gts.ExecAsync(func() {
before, at := container.InsertPosition(c, msg.Time())
state := message.NewState(msg)
msgr := NewMessage(state, c.LastMessage())
c.AddMessage(msgr)
msgr := NewMessage(state, before)
c.AddMessageAt(msgr, at)
})
}
// AddMessage adds the given message.
func (c *Container) AddMessage(msgr container.MessageRow) {
func (c *Container) AddMessageAt(msgr container.MessageRow, ix int) {
// Create the message in the parent's handler. This handler will also
// wipe old messages.
c.ListContainer.AddMessage(msgr)
c.ListContainer.AddMessageAt(msgr, ix)
// Did the handler wipe old messages? It will only do so if the user is
// scrolled to the bottom.
if c.ListContainer.CleanMessages() {
// We need to uncollapse the first (top) message. No length check is
// needed here, as we just inserted a message.
c.uncompact(c.FirstMessage())
c.uncompact(container.FirstMessage(c))
}
// If we've prepended the message, then see if we need to collapse the
// second message.
if first := c.ListContainer.FirstMessage(); first != nil {
firstState := first.Unwrap(false)
msgState := msgr.Unwrap(false)
if firstState.ID == msgState.ID {
// If the author is the same, then collapse.
if sec := c.NthMessage(1); isCollapsible(sec, firstState) {
c.compact(sec)
}
if ix == -1 {
// If the author is the same, then collapse.
if sec := c.NthMessage(1); isCollapsible(sec, msgr.Unwrap()) {
c.compact(sec)
}
}
}
@ -152,10 +127,10 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
return
}
msgHeader := msg.Unwrap(false)
msgHeader := msg.Unwrap()
prevHeader := prev.Unwrap(false)
nextHeader := next.Unwrap(false)
prevHeader := prev.Unwrap()
nextHeader := next.Unwrap()
// Check if the last message is the author's (relative to i):
if prevHeader.Author.ID == msgHeader.Author.ID {
@ -176,11 +151,21 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) {
}
func (c *Container) uncompact(msg container.MessageRow) {
full := WrapFullMessage(msg.Unwrap(true))
_, isFull := msg.(full)
if isFull {
return
}
full := WrapFullMessage(msg.Revert())
c.ListStore.SwapMessage(full)
}
func (c *Container) compact(msg container.MessageRow) {
compact := WrapCollapsedMessage(msg.Unwrap(true))
_, isCollapsed := msg.(collapsed)
if isCollapsed {
return
}
compact := WrapCollapsedMessage(msg.Revert())
c.ListStore.SwapMessage(compact)
}

@ -25,7 +25,7 @@ func WrapCollapsedMessage(gc *message.State) *CollapsedMessage {
ts.SetMarginStart(container.ColumnSpacing * 2)
// Set Content's padding accordingly to FullMessage's main box.
gc.Content.ToWidget().SetMarginEnd(container.ColumnSpacing * 2)
gc.Content.SetMarginEnd(container.ColumnSpacing * 2)
gc.PackStart(ts, false, false, 0)
gc.PackStart(gc.Content, true, true, 0)
@ -37,18 +37,19 @@ func WrapCollapsedMessage(gc *message.State) *CollapsedMessage {
}
}
func (c *CollapsedMessage) Collapsed() bool { return true }
func (c *CollapsedMessage) Unwrap(revert bool) *message.State {
if revert {
// Remove State's widgets from the containers.
c.Remove(c.Timestamp)
c.Remove(c.Content)
}
func (c *CollapsedMessage) Revert() *message.State {
c.ClearBox()
c.Content.SetMarginEnd(0)
c.Timestamp.Destroy()
return c.Unwrap()
}
return c.State
type collapsed interface {
collapsed()
}
func (c *CollapsedMessage) collapsed() {}
type CollapsedSendingMessage struct {
*CollapsedMessage
message.Presender

@ -14,9 +14,6 @@ import (
"github.com/gotk3/gotk3/gtk"
)
// TopFullMargin is the margin on top of every full message.
const TopFullMargin = 4
type FullMessage struct {
*message.State
@ -37,12 +34,22 @@ var (
)
var avatarCSS = primitives.PrepareClassCSS("cozy-avatar", `
.cozy-avatar {
margin-top: 2px;
}
/* Slightly dip down on click */
.cozy-avatar:active {
margin-top: 1px;
}
`)
var mainCSS = primitives.PrepareClassCSS("cozy-main", `
.cozy-main {
margin-top: 4px;
}
`)
func NewFullMessage(msg cchat.MessageCreate) *FullMessage {
return WrapFullMessage(message.NewState(msg))
}
@ -54,7 +61,6 @@ func WrapFullMessage(gc *message.State) *FullMessage {
header.Show()
avatar := NewAvatar(gc.Row)
avatar.SetMarginTop(TopFullMargin / 2)
avatar.SetMarginStart(container.ColumnSpacing * 2)
avatar.Connect("clicked", func(w gtk.IWidget) {
if output := header.Output(); len(output.Mentions) > 0 {
@ -72,7 +78,6 @@ func WrapFullMessage(gc *message.State) *FullMessage {
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
main.PackStart(header, false, false, 0)
main.PackStart(gc.Content, false, false, 0)
main.SetMarginTop(TopFullMargin)
main.SetMarginEnd(container.ColumnSpacing * 2)
main.SetMarginStart(container.ColumnSpacing)
main.Show()
@ -84,6 +89,11 @@ func WrapFullMessage(gc *message.State) *FullMessage {
gc.PackStart(main, true, true, 0)
gc.SetClass("cozy-full")
removeUpdate := gc.Author.Name.OnUpdate(func() {
avatar.SetImage(gc.Author.Name.Image())
header.SetLabel(gc.Author.Name.Label())
})
msg := &FullMessage{
State: gc,
timestamp: formatLongTime(gc.Time),
@ -92,17 +102,14 @@ func WrapFullMessage(gc *message.State) *FullMessage {
MainBox: main,
HeaderLabel: header,
unwrap: gc.Author.Name.OnUpdate(func() {
avatar.SetImage(gc.Author.Name.Image())
header.SetLabel(gc.Author.Name.Label())
}),
unwrap: func() { removeUpdate() },
}
header.SetRenderer(func(rich text.Rich) markup.RenderOutput {
cfg := markup.RenderConfig{}
cfg.NoReferencing = true
cfg.SetForegroundAnchor(gc.ContentBodyStyle)
cfg := markup.RenderConfig{}
cfg.NoReferencing = true
cfg.SetForegroundAnchor(gc.ContentBodyStyle)
header.SetRenderer(func(rich text.Rich) markup.RenderOutput {
output := markup.RenderCmplxWithConfig(rich, cfg)
output.Markup = `<span font_weight="600">` + output.Markup + "</span>"
output.Markup += msg.timestamp
@ -113,27 +120,30 @@ func WrapFullMessage(gc *message.State) *FullMessage {
return msg
}
func (m *FullMessage) Collapsed() bool { return false }
func (m *FullMessage) Revert() *message.State {
// Remove the handlers.
m.unwrap()
func (m *FullMessage) Unwrap(revert bool) *message.State {
if revert {
// Remove the handlers.
m.unwrap()
// Destroy the bottom leaf widgets first.
m.Avatar.Destroy()
m.HeaderLabel.Destroy()
// Remove State's widgets from the containers.
m.HeaderLabel.Destroy()
m.MainBox.Remove(m.Content) // not ours, so don't destroy.
// Remove the content label from main then destroy it, in case destroying it
// ruins the label.
m.MainBox.Remove(m.Content)
m.MainBox.Destroy()
// Remove the message from the grid.
m.Avatar.Destroy()
m.MainBox.Destroy()
}
m.ClearBox()
return m.State
return m.Unwrap()
}
type full interface{ full() }
func (m *FullMessage) full() {}
func formatLongTime(t time.Time) string {
return `<span alpha="70%" size="small">` + humanize.TimeAgoLong(t) + `</span>`
return ` <span alpha="70%" size="small">` + humanize.TimeAgoLong(t) + `</span>`
}
type FullSendingMessage struct {

@ -1,6 +1,7 @@
package container
import (
"log"
"strings"
"time"
@ -21,6 +22,19 @@ type messageKey struct {
func nonceKey(nonce string) messageKey { return messageKey{nonce, true} }
func idKey(id cchat.ID) messageKey { return messageKey{id, false} }
// newKey creates a new message key.
func newKey(state *message.State) messageKey {
if state.ID != "" {
return messageKey{state.ID, false}
}
if state.Nonce != "" {
return messageKey{state.Nonce, true}
}
log.Printf("state is missing both ID and Nonce: \n%#v\n", state)
return messageKey{}
}
func parseKeyFromNamer(n primitives.Namer) messageKey {
name, err := n.GetName()
if err != nil {
@ -38,7 +52,7 @@ func parseKeyFromNamer(n primitives.Namer) messageKey {
case "nonce":
return messageKey{id: parts[1], nonce: true}
default:
panic("Unknown prefix in row name " + parts[0])
panic("unknown prefix in message row name " + parts[0])
}
}
@ -123,9 +137,7 @@ func (c *ListStore) Reset() {
// Delegate removing children to the constructor.
c.messages = make(map[messageKey]*messageRow, BacklogLimit+1)
if c.self.ID != "" {
c.self.Name.Stop()
}
c.self.Name.Stop()
}
// SetSelf sets the current author to presend. If ID is empty or Namer is nil,
@ -149,32 +161,38 @@ func (c *ListStore) MessagesLen() int {
// TODO: combine compact and full so they share the same attach method.
func (c *ListStore) SwapMessage(msg MessageRow) bool {
// Unwrap msg from a *messageRow if it's not already.
m, ok := msg.(*messageRow)
if ok {
msg = m.MessageRow
if mrow, ok := msg.(*messageRow); ok {
msg = mrow.MessageRow
}
msgState := msg.Unwrap(false)
state := msg.Unwrap()
// Get the current message's index.
oldMsg, ix := c.findIndex(msgState.ID)
oldMsg, ix := c.findIndex(state.ID)
if ix == -1 {
return false
}
oldState := oldMsg.Unwrap(false)
// Remove the previous message off the message map using the key from its
// state.
delete(c.messages, newKey(oldMsg.state))
// Remove the to-be-replaced message box. We should probably reuse the row.
c.ListBox.Remove(oldState.Row)
// Remove the to-be-replaced message box.
// TODO: We should probably reuse the row.
c.ListBox.Remove(oldMsg.state.Row)
// Add a row at index. The actual row we want to delete will be shifted
// downwards.
c.ListBox.Insert(msgState.Row, ix)
c.ListBox.Insert(state.Row, ix)
row := messageRow{
MessageRow: msg,
state: state,
}
// Set the message into the map.
row := c.messages[idKey(msgState.ID)]
row.MessageRow = msg
c.bindMessage(row)
c.messages[newKey(state)] = &row
c.bindMessage(&row)
return true
}
@ -241,6 +259,15 @@ func (c *ListStore) findIndex(findID cchat.ID) (found *messageRow, index int) {
return
}
// Fast path interface.
type messageFinder interface {
findMessage(presend bool, fn func(*messageRow) bool) (*messageRow, int)
}
var _ messageFinder = (*ListStore)(nil)
// findMessage finds a message with the given callback as the filter. If presend
// is false, then presend messages are ignored.
func (c *ListStore) findMessage(presend bool, fn func(*messageRow) bool) (*messageRow, int) {
var r *messageRow
var i = c.MessagesLen() - 1
@ -251,7 +278,7 @@ func (c *ListStore) findMessage(presend bool, fn func(*messageRow) bool) (*messa
// If gridMsg is actually nil, then we have bigger issues.
if gridMsg != nil {
// Ignore sending messages.
// Ignore sending messages if presend is false.
if (presend || gridMsg.presend == nil) && fn(gridMsg) {
r = gridMsg
return true
@ -271,12 +298,12 @@ func (c *ListStore) findMessage(presend bool, fn func(*messageRow) bool) (*messa
}
// FindMessage iterates backwards and returns the message if isMessage() returns
// true on that message. It does not search presend messages.
func (c *ListStore) FindMessage(isMessage func(MessageRow) bool) MessageRow {
msg, _ := c.findMessage(false, func(row *messageRow) bool {
// true on that message.
func (c *ListStore) FindMessage(isMessage func(MessageRow) bool) (MessageRow, int) {
msg, ix := c.findMessage(true, func(row *messageRow) bool {
return isMessage(row.MessageRow)
})
return unwrapRow(msg)
return unwrapRow(msg), ix
}
func (c *ListStore) nthMessage(n int) *messageRow {
@ -294,16 +321,6 @@ func (c *ListStore) NthMessage(n int) MessageRow {
return unwrapRow(c.nthMessage(n))
}
// FirstMessage returns the first message.
func (c *ListStore) FirstMessage() MessageRow {
return c.NthMessage(0)
}
// LastMessage returns the latest message.
func (c *ListStore) LastMessage() MessageRow {
return c.NthMessage(c.MessagesLen() - 1)
}
// 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 {
@ -343,26 +360,24 @@ func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow {
}
func (c *ListStore) bindMessage(msgc *messageRow) {
state := msgc.Unwrap(false)
// Bind the message ID to the row so we can easily do a lookup.
key := messageKey{
id: state.ID,
id: msgc.state.ID,
}
if state.Nonce != "" {
key.id = state.Nonce
if msgc.state.Nonce != "" {
key.id = msgc.state.Nonce
key.nonce = true
}
state.Row.SetName(key.name())
msgc.state.Row.SetName(key.name())
msgc.MessageRow.SetReferenceHighlighter(c)
c.Controller.BindMenu(msgc.MessageRow)
}
func (c *ListStore) AddMessage(msg MessageRow) {
state := msg.Unwrap(false)
func (c *ListStore) AddMessageAt(msg MessageRow, ix int) {
state := msg.Unwrap()
defer c.Controller.AuthorEvent(state.Author.ID)
@ -373,37 +388,34 @@ func (c *ListStore) AddMessage(msg MessageRow) {
return
}
// 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 before.Unwrap(false).Time.After(state.Time)
})
// Attempt to guess if this is a presend message or not. This should be
// unwrapped once it's finalized.
presend, _ := msg.(message.Presender)
msgc := &messageRow{
MessageRow: msg,
presend: presend,
state: state,
}
// 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
if ix < 0 {
ix = 0
c.ListBox.Prepend(state.Row)
} else {
index++ // insert right after
ix++ // 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 {
if c.MessagesLen() == ix {
c.ListBox.Add(state.Row)
} else {
c.ListBox.Insert(state.Row, index)
c.ListBox.Insert(state.Row, ix)
}
}
// Set the ID into the message map.
c.messages[idKey(state.ID)] = msgc
c.messages[newKey(state)] = msgc
c.bindMessage(msgc)
}
@ -436,19 +448,14 @@ func (c *ListStore) DeleteEarliest(n int) {
// after deleting, so we have to call Next manually before Removing.
primitives.ForeachChild(c.ListBox, func(v interface{}) (stop bool) {
id := parseKeyFromNamer(v.(primitives.Namer))
gridMsg := c.message(id.expand())
state := gridMsg.Unwrap(false)
if state.ID != "" {
delete(c.messages, idKey(state.ID))
}
if state.Nonce != "" {
delete(c.messages, nonceKey(state.Nonce))
mr, ok := c.messages[id]
if !ok {
log.Panicln("message with ID", id, "not found in map")
}
destroyMsg(gridMsg)
delete(c.messages, id)
destroyMsg(mr)
n--
return n == 0
@ -464,7 +471,7 @@ func (c *ListStore) HighlightReference(ref markup.ReferenceSegment) {
func (c *ListStore) Highlight(msg MessageRow) {
gts.ExecAsync(func() {
state := msg.Unwrap(false)
state := msg.Unwrap()
state.Row.GrabFocus()
c.ListBox.DragHighlightRow(state.Row)
gts.DoAfter(2*time.Second, c.ListBox.DragUnhighlightRow)
@ -472,7 +479,6 @@ func (c *ListStore) Highlight(msg MessageRow) {
}
func destroyMsg(row *messageRow) {
state := row.Unwrap(true)
state.Author.Name.Stop()
state.Row.Destroy()
row.state.Author.Name.Stop()
row.state.Row.Destroy()
}

@ -54,7 +54,7 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
return false
}
id := msgr.Unwrap(false).ID
id := msgr.Unwrap().ID
// If we don't support message editing, then passthrough events.
if !f.Editable(id) {

@ -103,19 +103,19 @@ func (u *Container) shouldReveal() bool {
func (u *Container) Reset() {
u.SetRevealChild(false)
u.State.ID = ""
u.State.Name.Stop()
}
// Update is not thread-safe.
func (u *Container) Update(session cchat.Session, messenger cchat.Messenger) {
// Set the fallback username.
u.State.Name.BindNamer(u.main, "destroy", session)
// Reveal the name if it's not empty.
u.State.ID = session.ID()
u.SetRevealChild(true)
// Does messenger implement Nicknamer? If yes, use it.
if nicknamer := messenger.AsNicknamer(); nicknamer != nil {
u.State.Name.BindNamer(u.main, "destroy", nicknamer)
} else {
u.State.Name.BindNamer(u.main, "destroy", session)
}
}

@ -238,11 +238,12 @@ func NewSection(sect cchat.MemberSection, evq EventQueuer) *Section {
section.Box.PackStart(section.Body, false, false, 0)
section.Box.Show()
var members = map[string]*Member{}
members := map[string]*Member{}
// On row click, show the mention popup if any.
section.Body.Connect("row-activated", func(_ *gtk.ListBox, r *gtk.ListBoxRow) {
var i = r.GetIndex()
i := r.GetIndex()
// Cold path; we can afford searching in the map.
for _, member := range members {
if member.ListBoxRow.GetIndex() == i {
@ -253,6 +254,7 @@ func NewSection(sect cchat.MemberSection, evq EventQueuer) *Section {
section.name.QueueNamer(context.Background(), sect)
section.Header.Connect("destroy", section.name.Stop)
section.Members = members
return section
}
@ -328,12 +330,30 @@ var memberBoxCSS = primitives.PrepareClassCSS("member-box", `
}
`)
var avatarMemberCSS = primitives.PrepareClassCSS("avatar-member", `
.avatar-member {
padding-right: 10px;
var avatarBoxMemberCSS = primitives.PrepareClassCSS("avatar-box-member", `
.avatar-box-member {
margin-right: 10px;
padding: 2px;
border: 1.5px solid;
border-color: #747F8D; /* Offline Grey */
border-radius: 99px;
}
.avatar-box-member.online {
border-color: #43B581;
}
.avatar-box-member.busy {
border-color: #F04747;
}
.avatar-box-member.idle {
border-color: #FAA61A;
}
`)
var labelMemberCSS = primitives.PrepareClassCSS("label-member", ``)
func NewMember(member cchat.ListMember) *Member {
m := Member{}
@ -341,33 +361,44 @@ func NewMember(member cchat.ListMember) *Member {
evb.AddEvents(int(gdk.EVENT_ENTER_NOTIFY) | int(gdk.EVENT_LEAVE_NOTIFY))
evb.Show()
m.Avatar = roundimage.NewStillImage(evb, 9999)
m.Avatar = roundimage.NewStillImage(evb, 0)
m.Avatar.SetSize(AvatarSize)
m.Avatar.SetPlaceholderIcon("user-info-symbolic", AvatarSize)
m.Avatar.Show()
avatarMemberCSS(m.Avatar)
rich.BindRoundImage(m.Avatar, &m.name, true)
avaBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
avaBox.SetVAlign(gtk.ALIGN_CENTER)
avaBox.PackStart(m.Avatar, false, false, 0)
avaBox.Show()
avatarBoxMemberCSS(avaBox)
m.Name = rich.NewLabel(&m.name)
m.Name.SetUseMarkup(true)
m.Name.SetXAlign(0)
m.Name.SetEllipsize(pango.ELLIPSIZE_END)
m.Name.Show()
labelMemberCSS(m.Name)
// Keep track of the current status class to replace.
var statusClass string
styler, _ := avaBox.GetStyleContext()
m.Name.SetRenderer(func(rich text.Rich) markup.RenderOutput {
out := markup.RenderCmplx(rich)
out := markup.RenderCmplxWithConfig(rich, markup.RenderConfig{
NoMentionLinks: true,
})
if m.status != cchat.StatusUnknown {
out.Markup = fmt.Sprintf(
`<span color="#%06X" size="large">●</span> %s`,
statusColors(member.Status()), out.Markup,
)
if statusClass != "" {
styler.RemoveClass(statusClass)
}
statusClass = statusClassName(m.status)
styler.AddClass(statusClass)
if !m.second.IsEmpty() {
out.Markup += fmt.Sprintf(
"\n"+`<span alpha="85%%"><sup>%s</sup></span>`,
`<span alpha="85%%" size="small">`+"\n"+`%s</span>`,
markup.Render(m.second),
)
}
@ -376,7 +407,7 @@ func NewMember(member cchat.ListMember) *Member {
})
m.Main, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
m.Main.PackStart(m.Avatar, false, false, 0)
m.Main.PackStart(avaBox, false, false, 0)
m.Main.PackStart(m.Name, true, true, 0)
m.Main.Show()
memberBoxCSS(m.Main)
@ -392,6 +423,25 @@ func NewMember(member cchat.ListMember) *Member {
return &m
}
func statusClassName(status cchat.Status) string {
switch status {
case cchat.StatusOnline:
return "online"
case cchat.StatusBusy:
return "busy"
case cchat.StatusAway:
fallthrough
case cchat.StatusIdle:
return "idle"
case cchat.StatusInvisible:
fallthrough
case cchat.StatusOffline:
fallthrough
default:
return ""
}
}
var noMentionLinks = markup.RenderConfig{
NoMentionLinks: true,
NoReferencing: true,

@ -19,10 +19,11 @@ import (
// made for containers to override; methods not meant to be override are not
// exposed and will be done directly on the State.
type Container interface {
// Unwrap unwraps the message container and, if revert is true, revert the
// state to a clean version. Containers must implement this method by
// itself.
Unwrap(revert bool) *State
// Unwrap returns the internal message state.
Unwrap() *State
// Revert unwraps and reverts all widget changes to the internal state then
// returns that state.
Revert() *State
// UpdateContent updates the underlying content widget.
UpdateContent(content text.Rich, edited bool)
@ -50,8 +51,7 @@ type State struct {
MenuItems []menu.Item
}
// NewState creates a new message state with the given ID and nonce. It does not
// update the widgets, so FillContainer should be called afterwards.
// NewState creates a new message state with the given MessageCreate.
func NewState(msg cchat.MessageCreate) *State {
author := msg.Author()
@ -61,6 +61,7 @@ func NewState(msg cchat.MessageCreate) *State {
c.ID = msg.ID()
c.Time = msg.Time()
c.Nonce = msg.Nonce()
c.UpdateContent(msg.Content(), false)
return c
}
@ -69,8 +70,7 @@ func NewState(msg cchat.MessageCreate) *State {
// immediately afterwards; it is invalid once the state is used.
func NewEmptyState() *State {
ctbody := labeluri.NewLabel(text.Rich{})
ctbody.SetVExpand(true)
ctbody.SetHAlign(gtk.ALIGN_START)
ctbody.SetHAlign(gtk.ALIGN_FILL)
ctbody.SetEllipsize(pango.ELLIPSIZE_NONE)
ctbody.SetLineWrap(true)
ctbody.SetLineWrapMode(pango.WRAP_WORD_CHAR)
@ -84,10 +84,11 @@ func NewEmptyState() *State {
// Wrap the content label inside a content box.
ctbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
ctbox.SetHExpand(true)
ctbox.PackStart(ctbody, false, false, 0)
ctbox.SetHAlign(gtk.ALIGN_FILL)
ctbox.Show()
// Box that belongs to the implementations of messages.
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
box.Show()
@ -122,13 +123,38 @@ func NewEmptyState() *State {
return gc
}
// ClearBox clears the state's widget container.
func (m *State) ClearBox() {
primitives.RemoveChildren(m)
m.SetClass("")
}
// // For debugging use only.
// func (m *State) PackStart(child gtk.IWidget, expand bool, fill bool, padding uint) {
// paths := make([]string, 0, 5)
// for i := 1; i < 5; i++ {
// _, file, line, ok := runtime.Caller(i)
// if !ok {
// break
// }
//
// paths = append(paths, fmt.Sprintf("%s:%d", filepath.Base(file), line))
// }
//
// log.Println("child packstart", m.ID, "at", strings.Join(paths, " < "))
// m.Box.PackStart(child, expand, fill, padding)
// }
// SetClass sets the internal row's class.
func (m *State) SetClass(class string) {
if m.class != "" {
primitives.RemoveClass(m.Row, m.class)
}
primitives.AddClass(m.Row, class)
if class != "" {
primitives.AddClass(m.Row, class)
}
m.class = class
}
@ -153,3 +179,6 @@ func (m *State) UpdateContent(content text.Rich, edited bool) {
func (m *State) Focusable() gtk.IWidget {
return m.Content
}
// Unwrap returns itself.
func (m *State) Unwrap() *State { return m }

@ -45,14 +45,10 @@ type PresendState struct {
uploads *attachment.MessageUploader
}
var (
_ Presender = (*PresendState)(nil)
)
type SendMessageData struct {
}
var _ Presender = (*PresendState)(nil)
// NewPresendState creates a new presend state.
// NewPresendState creates a new presend state. The caller must call one of the
// state setters, usually SetLoading.
func NewPresendState(self *Author, msg PresendMessage) *PresendState {
c := NewEmptyState()
c.Author = self
@ -64,7 +60,7 @@ func NewPresendState(self *Author, msg PresendMessage) *PresendState {
presend: msg,
uploads: attachment.NewMessageUploader(msg.Files()),
}
p.SetLoading()
// p.SetLoading()
return p
}

@ -84,7 +84,7 @@ func (mc *MessageControl) Enable(msg container.MessageRow, names MessageItemName
mc.SetSensitive(true)
mc.SetRevealChild(true && !mc.hide)
unwrap := msg.Unwrap(false)
unwrap := msg.Unwrap()
mc.Reply.bind(menu.FindItemFunc(unwrap.MenuItems, names.Reply))
mc.Edit.bind(menu.FindItemFunc(unwrap.MenuItems, names.Edit))

@ -114,9 +114,10 @@ func NewView(c Controller) *View {
view.MsgBox.Show()
view.Scroller = autoscroll.NewScrolledWindow()
view.Scroller.Add(view.MsgBox)
view.Scroller.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
view.Scroller.SetVExpand(true)
view.Scroller.SetHExpand(true)
view.Scroller.Add(view.MsgBox)
view.Scroller.Show()
messageScroller(view.Scroller)
@ -352,12 +353,12 @@ func (v *View) JoinServer(ses *session.Row, srv *server.ServerRow, bc traverse.B
}
func (v *View) FetchBacklog() {
var backlogger = v.state.Backlogger()
backlogger := v.state.Backlogger()
if backlogger == nil {
return
}
var firstMsg = v.Container.FirstMessage()
firstMsg := container.FirstMessage(v.Container)
if firstMsg == nil {
return
}
@ -365,12 +366,12 @@ func (v *View) FetchBacklog() {
// Set the window as busy. TODO: loading circles.
v.ctrl.OnMessageBusy()
var done = func() {
done := func() {
v.ctrl.OnMessageDone()
v.Container.Highlight(firstMsg)
}
firstID := firstMsg.Unwrap(false).ID
firstID := firstMsg.Unwrap().ID
gts.Async(func() (func(), error) {
ctx, cancel := context.WithTimeout(context.TODO(), 3*time.Second)
@ -396,59 +397,62 @@ func (v *View) MessageAuthor(msgID cchat.ID) *message.Author {
return nil
}
return msg.Unwrap(false).Author
return msg.Unwrap().Author
}
// Author returns the author from the message list with the given author ID.
func (v *View) Author(authorID cchat.ID) rich.LabelStateStorer {
msg := v.Container.FindMessage(func(msg container.MessageRow) bool {
return msg.Unwrap(false).Author.ID == authorID
msg, _ := v.Container.FindMessage(func(msg container.MessageRow) bool {
return msg.Unwrap().Author.ID == authorID
})
if msg == nil {
return nil
}
state := msg.Unwrap(false)
state := msg.Unwrap()
return &state.Author.Name
}
// LatestMessageFrom returns the last message ID with that author.
func (v *View) LatestMessageFrom(userID cchat.ID) container.MessageRow {
return container.LatestMessageFrom(v.Container, userID)
msg, _ := container.LatestMessageFrom(v.Container, userID)
return msg
}
func (v *View) SendMessage(msg message.PresendMessage) {
state := message.NewPresendState(v.InputView.Username.State, msg)
msgr := v.Container.NewPresendMessage(state)
v.retryMessage(msgr)
v.retryMessage(state, msgr)
}
// retryMessage sends the message.
func (v *View) retryMessage(presend container.PresendMessageRow) {
func (v *View) retryMessage(state *message.PresendState, presend container.PresendMessageRow) {
var sender = v.InputView.Sender
if sender == nil {
return
}
// Ensure the message is set to loading.
presend.SetLoading()
go func() {
if err := sender.Send(presend.SendingMessage()); err != nil {
// Set the message's state to errored again, but we don't need to
// rebind the menu.
gts.ExecAsync(func() {
// Set the retry message.
presend.SetSentError(err)
// Only attach the menu once. Further retries do not need to be
// reattached.
state := presend.Unwrap(false)
state.MenuItems = []menu.Item{
menu.SimpleItem("Retry", func() {
presend.SetLoading()
v.retryMessage(presend)
}),
}
})
err := sender.Send(presend.SendingMessage())
if err == nil {
return
}
// Set the message's state to errored again, but we don't need to rebind
// the menu.
gts.ExecAsync(func() {
presend.SetSentError(err)
state.MenuItems = []menu.Item{
menu.SimpleItem("Retry", func() {
presend.SetLoading()
v.retryMessage(state, presend)
}),
}
})
}()
}
@ -461,7 +465,7 @@ var messageItemNames = MessageItemNames{
// BindMenu attaches the menu constructor into the message with the needed
// states and callbacks.
func (v *View) BindMenu(msg container.MessageRow) {
state := msg.Unwrap(false)
state := msg.Unwrap()
// Add 1 for the edit menu item.
var mitems = []menu.Item{

@ -173,17 +173,15 @@ func (i *Image) SetImageURLInto(url string, otherImage httputil.ImageContainer)
return
}
if i.icon.name != "" {
primitives.SetImageIcon(i, i.icon.name, i.icon.size)
goto noImage
}
if i.ifNone != nil {
i.ifNone(ctx)
return
}
noImage:
if i.icon.name != "" {
primitives.SetImageIcon(i, i.icon.name, i.icon.size)
}
i.Image.SetFromPixbuf(nil)
i.cancel()
}
@ -216,9 +214,14 @@ func (i *Image) drawer(image *gtk.Image, cc *cairo.Context) bool {
return false
}
a := image.GetAllocation()
w := float64(a.GetWidth())
h := float64(a.GetHeight())
var w, h float64
if reqW, reqH := image.GetSizeRequest(); reqW > 0 && reqH > 0 {
w = float64(reqW)
h = float64(reqH)
} els