diff options
author | Tulir Asokan <tulir@maunium.net> | 2018-04-14 18:09:02 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-04-14 18:09:02 +0300 |
commit | 53cdfb64c1773b63fb9432a1525b4ac4acb154fc (patch) | |
tree | 1c1aa180313abe8179d0b07591348e222b60483e /ui/messages | |
parent | 14a84295d72a24a8bce8a71c240ab2b155ed5a1f (diff) | |
parent | d060d10615434c557373ee00ba009cc8b583e881 (diff) |
Merge pull request #18 from tulir/ui-refactor
Refactor UI to use interfaces and add advanced message rendering
Diffstat (limited to 'ui/messages')
-rw-r--r-- | ui/messages/base.go | 234 | ||||
-rw-r--r-- | ui/messages/doc.go | 2 | ||||
-rw-r--r-- | ui/messages/expandedtextmessage.go | 71 | ||||
-rw-r--r-- | ui/messages/imagemessage.go | 123 | ||||
-rw-r--r-- | ui/messages/message.go | 38 | ||||
-rw-r--r-- | ui/messages/meta.go | 77 | ||||
-rw-r--r-- | ui/messages/parser/htmlparser.go | 186 | ||||
-rw-r--r-- | ui/messages/parser/htmltagarray.go | 118 | ||||
-rw-r--r-- | ui/messages/parser/parser.go | 128 | ||||
-rw-r--r-- | ui/messages/textbase.go | 84 | ||||
-rw-r--r-- | ui/messages/textmessage.go | 102 | ||||
-rw-r--r-- | ui/messages/tstring/cell.go | 51 | ||||
-rw-r--r-- | ui/messages/tstring/string.go | 173 |
13 files changed, 1387 insertions, 0 deletions
diff --git a/ui/messages/base.go b/ui/messages/base.go new file mode 100644 index 0000000..aed7903 --- /dev/null +++ b/ui/messages/base.go @@ -0,0 +1,234 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package messages + +import ( + "encoding/gob" + "time" + + "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/ui/messages/tstring" + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/tcell" +) + +func init() { + gob.Register(&BaseMessage{}) +} + +type BaseMessage struct { + MsgID string + MsgType string + MsgSender string + MsgSenderColor tcell.Color + MsgTimestamp time.Time + MsgState ifc.MessageState + MsgIsHighlight bool + MsgIsService bool + buffer []tstring.TString + prevBufferWidth int +} + +func newBaseMessage(id, sender, msgtype string, timestamp time.Time) BaseMessage { + return BaseMessage{ + MsgSender: sender, + MsgTimestamp: timestamp, + MsgSenderColor: widget.GetHashColor(sender), + MsgType: msgtype, + MsgID: id, + prevBufferWidth: 0, + MsgState: ifc.MessageStateDefault, + MsgIsHighlight: false, + MsgIsService: false, + } +} + +func (msg *BaseMessage) RegisterGomuks(gmx ifc.Gomuks) {} + +// CopyFrom replaces the content of this message object with the content of the given object. +func (msg *BaseMessage) CopyFrom(from ifc.MessageMeta) { + msg.MsgSender = from.Sender() + msg.MsgSenderColor = from.SenderColor() + + fromMsg, ok := from.(UIMessage) + if ok { + msg.MsgSender = fromMsg.RealSender() + msg.MsgID = fromMsg.ID() + msg.MsgType = fromMsg.Type() + msg.MsgTimestamp = fromMsg.Timestamp() + msg.MsgState = fromMsg.State() + msg.MsgIsService = fromMsg.IsService() + msg.MsgIsHighlight = fromMsg.IsHighlight() + msg.buffer = nil + } +} + +// Sender gets the string that should be displayed as the sender of this message. +// +// If the message is being sent, the sender is "Sending...". +// If sending has failed, the sender is "Error". +// If the message is an emote, the sender is blank. +// In any other case, the sender is the display name of the user who sent the message. +func (msg *BaseMessage) Sender() string { + switch msg.MsgState { + case ifc.MessageStateSending: + return "Sending..." + case ifc.MessageStateFailed: + return "Error" + } + switch msg.MsgType { + case "m.emote": + // Emotes don't show a separate sender, it's included in the buffer. + return "" + default: + return msg.MsgSender + } +} + +func (msg *BaseMessage) RealSender() string { + return msg.MsgSender +} + +func (msg *BaseMessage) getStateSpecificColor() tcell.Color { + switch msg.MsgState { + case ifc.MessageStateSending: + return tcell.ColorGray + case ifc.MessageStateFailed: + return tcell.ColorRed + case ifc.MessageStateDefault: + fallthrough + default: + return tcell.ColorDefault + } +} + +// SenderColor returns the color the name of the sender should be shown in. +// +// If the message is being sent, the color is gray. +// If sending has failed, the color is red. +// +// In any other case, the color is whatever is specified in the Message struct. +// Usually that means it is the hash-based color of the sender (see ui/widget/color.go) +func (msg *BaseMessage) SenderColor() tcell.Color { + stateColor := msg.getStateSpecificColor() + switch { + case stateColor != tcell.ColorDefault: + return stateColor + case msg.MsgIsService: + return tcell.ColorGray + default: + return msg.MsgSenderColor + } +} + +// TextColor returns the color the actual content of the message should be shown in. +func (msg *BaseMessage) TextColor() tcell.Color { + stateColor := msg.getStateSpecificColor() + switch { + case stateColor != tcell.ColorDefault: + return stateColor + case msg.MsgIsService, msg.MsgType == "m.notice": + return tcell.ColorGray + case msg.MsgIsHighlight: + return tcell.ColorYellow + case msg.MsgType == "m.room.member": + return tcell.ColorGreen + default: + return tcell.ColorDefault + } +} + +// TimestampColor returns the color the timestamp should be shown in. +// +// As with SenderColor(), messages being sent and messages that failed to be sent are +// gray and red respectively. +// +// However, other messages are the default color instead of a color stored in the struct. +func (msg *BaseMessage) TimestampColor() tcell.Color { + return msg.getStateSpecificColor() +} + +// Buffer returns the computed text buffer. +// +// The buffer contains the text of the message split into lines with a maximum +// width of whatever was provided to CalculateBuffer(). +// +// N.B. This will NOT automatically calculate the buffer if it hasn't been +// calculated already, as that requires the target width. +func (msg *BaseMessage) Buffer() []tstring.TString { + return msg.buffer +} + +// Height returns the number of rows in the computed buffer (see Buffer()). +func (msg *BaseMessage) Height() int { + return len(msg.buffer) +} + +// Timestamp returns the full timestamp when the message was sent. +func (msg *BaseMessage) Timestamp() time.Time { + return msg.MsgTimestamp +} + +// FormatTime returns the formatted time when the message was sent. +func (msg *BaseMessage) FormatTime() string { + return msg.MsgTimestamp.Format(TimeFormat) +} + +// FormatDate returns the formatted date when the message was sent. +func (msg *BaseMessage) FormatDate() string { + return msg.MsgTimestamp.Format(DateFormat) +} + +func (msg *BaseMessage) ID() string { + return msg.MsgID +} + +func (msg *BaseMessage) SetID(id string) { + msg.MsgID = id +} + +func (msg *BaseMessage) Type() string { + return msg.MsgType +} + +func (msg *BaseMessage) SetType(msgtype string) { + msg.MsgType = msgtype +} + +func (msg *BaseMessage) State() ifc.MessageState { + return msg.MsgState +} + +func (msg *BaseMessage) SetState(state ifc.MessageState) { + msg.MsgState = state +} + +func (msg *BaseMessage) IsHighlight() bool { + return msg.MsgIsHighlight +} + +func (msg *BaseMessage) SetIsHighlight(isHighlight bool) { + msg.MsgIsHighlight = isHighlight +} + +func (msg *BaseMessage) IsService() bool { + return msg.MsgIsService +} + +func (msg *BaseMessage) SetIsService(isService bool) { + msg.MsgIsService = isService +} diff --git a/ui/messages/doc.go b/ui/messages/doc.go new file mode 100644 index 0000000..289c308 --- /dev/null +++ b/ui/messages/doc.go @@ -0,0 +1,2 @@ +// Package messages contains different message types and code to generate and render them. +package messages diff --git a/ui/messages/expandedtextmessage.go b/ui/messages/expandedtextmessage.go new file mode 100644 index 0000000..3ee15ad --- /dev/null +++ b/ui/messages/expandedtextmessage.go @@ -0,0 +1,71 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package messages + +import ( + "encoding/gob" + "time" + + "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/ui/messages/tstring" +) + +func init() { + gob.Register(&ExpandedTextMessage{}) +} + +type ExpandedTextMessage struct { + BaseTextMessage + MsgText tstring.TString +} + +// NewExpandedTextMessage creates a new ExpandedTextMessage object with the provided values and the default state. +func NewExpandedTextMessage(id, sender, msgtype string, text tstring.TString, timestamp time.Time) UIMessage { + return &ExpandedTextMessage{ + BaseTextMessage: newBaseTextMessage(id, sender, msgtype, timestamp), + MsgText: text, + } +} + +func (msg *ExpandedTextMessage) GenerateText() tstring.TString { + return msg.MsgText +} + +// CopyFrom replaces the content of this message object with the content of the given object. +func (msg *ExpandedTextMessage) CopyFrom(from ifc.MessageMeta) { + msg.BaseTextMessage.CopyFrom(from) + + fromExpandedMsg, ok := from.(*ExpandedTextMessage) + if ok { + msg.MsgText = fromExpandedMsg.MsgText + } + + msg.RecalculateBuffer() +} + +func (msg *ExpandedTextMessage) NotificationContent() string { + return msg.MsgText.String() +} + +func (msg *ExpandedTextMessage) CalculateBuffer(width int) { + msg.BaseTextMessage.calculateBufferWithText(msg.MsgText, width) +} + +// RecalculateBuffer calculates the buffer again with the previously provided width. +func (msg *ExpandedTextMessage) RecalculateBuffer() { + msg.CalculateBuffer(msg.prevBufferWidth) +} diff --git a/ui/messages/imagemessage.go b/ui/messages/imagemessage.go new file mode 100644 index 0000000..2fbf6ae --- /dev/null +++ b/ui/messages/imagemessage.go @@ -0,0 +1,123 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package messages + +import ( + "bytes" + "encoding/gob" + "fmt" + "time" + + "image/color" + + "maunium.net/go/gomuks/debug" + "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/lib/ansimage" + "maunium.net/go/gomuks/ui/messages/tstring" + "maunium.net/go/tcell" +) + +func init() { + gob.Register(&ImageMessage{}) +} + +type ImageMessage struct { + BaseMessage + Homeserver string + FileID string + data []byte + + gmx ifc.Gomuks +} + +// NewImageMessage creates a new ImageMessage object with the provided values and the default state. +func NewImageMessage(gmx ifc.Gomuks, id, sender, msgtype, homeserver, fileID string, data []byte, timestamp time.Time) UIMessage { + return &ImageMessage{ + newBaseMessage(id, sender, msgtype, timestamp), + homeserver, + fileID, + data, + gmx, + } +} + +func (msg *ImageMessage) RegisterGomuks(gmx ifc.Gomuks) { + msg.gmx = gmx + + debug.Print(len(msg.data), msg.data) + if len(msg.data) == 0 { + go func() { + defer gmx.Recover() + msg.updateData() + }() + } +} + +func (msg *ImageMessage) NotificationContent() string { + return "Sent an image" +} + +func (msg *ImageMessage) updateData() { + debug.Print("Loading image:", msg.Homeserver, msg.FileID) + data, _, _, err := msg.gmx.Matrix().Download(fmt.Sprintf("mxc://%s/%s", msg.Homeserver, msg.FileID)) + if err != nil { + debug.Print("Failed to download image %s/%s: %v", msg.Homeserver, msg.FileID, err) + return + } + msg.data = data +} + +func (msg *ImageMessage) Path() string { + return msg.gmx.Matrix().GetCachePath(msg.Homeserver, msg.FileID) +} + +// CopyFrom replaces the content of this message object with the content of the given object. +func (msg *ImageMessage) CopyFrom(from ifc.MessageMeta) { + msg.BaseMessage.CopyFrom(from) + + fromImgMsg, ok := from.(*ImageMessage) + if ok { + msg.data = fromImgMsg.data + } + + msg.RecalculateBuffer() +} + +// CalculateBuffer generates the internal buffer for this message that consists +// of the text of this message split into lines at most as wide as the width +// parameter. +func (msg *ImageMessage) CalculateBuffer(width int) { + if width < 2 { + return + } + + image, err := ansimage.NewScaledFromReader(bytes.NewReader(msg.data), 0, width, color.Black) + if err != nil { + msg.buffer = []tstring.TString{tstring.NewColorTString("Failed to display image", tcell.ColorRed)} + debug.Print("Failed to display image:", err) + return + } + + msg.buffer = image.Render() + msg.prevBufferWidth = width +} + +// RecalculateBuffer calculates the buffer again with the previously provided width. +func (msg *ImageMessage) RecalculateBuffer() { + msg.CalculateBuffer(msg.prevBufferWidth) +} + diff --git a/ui/messages/message.go b/ui/messages/message.go new file mode 100644 index 0000000..6ebfb6d --- /dev/null +++ b/ui/messages/message.go @@ -0,0 +1,38 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package messages + +import ( + "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/ui/messages/tstring" +) + +// Message is a wrapper for the content and metadata of a Matrix message intended to be displayed. +type UIMessage interface { + ifc.Message + + CalculateBuffer(width int) + RecalculateBuffer() + Buffer() []tstring.TString + Height() int + + RealSender() string + RegisterGomuks(gmx ifc.Gomuks) +} + +const DateFormat = "January _2, 2006" +const TimeFormat = "15:04:05" diff --git a/ui/messages/meta.go b/ui/messages/meta.go new file mode 100644 index 0000000..7e2f29f --- /dev/null +++ b/ui/messages/meta.go @@ -0,0 +1,77 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package messages + +import ( + "time" + + "maunium.net/go/tcell" + "maunium.net/go/gomuks/interface" +) + +// BasicMeta is a simple variable store implementation of MessageMeta. +type BasicMeta struct { + BSender string + BTimestamp time.Time + BSenderColor, BTextColor, BTimestampColor tcell.Color +} + +// Sender gets the string that should be displayed as the sender of this message. +func (meta *BasicMeta) Sender() string { + return meta.BSender +} + +// SenderColor returns the color the name of the sender should be shown in. +func (meta *BasicMeta) SenderColor() tcell.Color { + return meta.BSenderColor +} + +// Timestamp returns the full time when the message was sent. +func (meta *BasicMeta) Timestamp() time.Time { + return meta.BTimestamp +} + +// FormatTime returns the formatted time when the message was sent. +func (meta *BasicMeta) FormatTime() string { + return meta.BTimestamp.Format(TimeFormat) +} + +// FormatDate returns the formatted date when the message was sent. +func (meta *BasicMeta) FormatDate() string { + return meta.BTimestamp.Format(DateFormat) +} + +// TextColor returns the color the actual content of the message should be shown in. +func (meta *BasicMeta) TextColor() tcell.Color { + return meta.BTextColor +} + +// TimestampColor returns the color the timestamp should be shown in. +// +// This usually does not apply to the date, as it is rendered separately from the message. +func (meta *BasicMeta) TimestampColor() tcell.Color { + return meta.BTimestampColor +} + +// CopyFrom replaces the content of this meta object with the content of the given object. +func (meta *BasicMeta) CopyFrom(from ifc.MessageMeta) { + meta.BSender = from.Sender() + meta.BTimestamp = from.Timestamp() + meta.BSenderColor = from.SenderColor() + meta.BTextColor = from.TextColor() + meta.BTimestampColor = from.TimestampColor() +} diff --git a/ui/messages/parser/htmlparser.go b/ui/messages/parser/htmlparser.go new file mode 100644 index 0000000..9ca707f --- /dev/null +++ b/ui/messages/parser/htmlparser.go @@ -0,0 +1,186 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package parser + +import ( + "fmt" + "io" + "math" + "regexp" + "strings" + + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/debug" + "maunium.net/go/gomuks/lib/htmlparser" + "maunium.net/go/gomuks/matrix/rooms" + "maunium.net/go/gomuks/ui/messages/tstring" + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/tcell" +) + +var matrixToURL = regexp.MustCompile("^(?:https?://)?(?:www\\.)?matrix\\.to/#/([#@!].*)") + +type MatrixHTMLProcessor struct { + text tstring.TString + + indent string + listType string + lineIsNew bool + openTags *TagArray + + room *rooms.Room +} + +func (parser *MatrixHTMLProcessor) newline() { + if !parser.lineIsNew { + parser.text = parser.text.Append("\n" + parser.indent) + parser.lineIsNew = true + } +} + +func (parser *MatrixHTMLProcessor) Preprocess() {} + +func (parser *MatrixHTMLProcessor) HandleText(text string) { + style := tcell.StyleDefault + for _, tag := range *parser.openTags { + switch tag.Tag { + case "b", "strong": + style = style.Bold(true) + case "i", "em": + style = style.Italic(true) + case "s", "del": + style = style.Strikethrough(true) + case "u", "ins": + style = style.Underline(true) + case "a": + tag.Text += text + return + } + } + + if !parser.openTags.Has("pre", "code") { + text = strings.Replace(text, "\n", "", -1) + } + parser.text = parser.text.AppendStyle(text, style) + parser.lineIsNew = false +} + +func (parser *MatrixHTMLProcessor) HandleStartTag(tagName string, attrs map[string]string) { + tag := &TagWithMeta{Tag: tagName} + switch tag.Tag { + case "h1", "h2", "h3", "h4", "h5", "h6": + length := int(tag.Tag[1] - '0') + parser.text = parser.text.Append(strings.Repeat("#", length) + " ") + parser.lineIsNew = false + case "a": + tag.Meta, _ = attrs["href"] + case "ol", "ul": + parser.listType = tag.Tag + case "li": + indentSize := 2 + if parser.listType == "ol" { + list := parser.openTags.Get(parser.listType) + list.Counter++ + parser.text = parser.text.Append(fmt.Sprintf("%d. ", list.Counter)) + indentSize = int(math.Log10(float64(list.Counter))+1) + len(". ") + } else { + parser.text = parser.text.Append("* ") + } + parser.indent += strings.Repeat(" ", indentSize) + parser.lineIsNew = false + case "blockquote": + parser.indent += "> " + parser.text = parser.text.Append("> ") + parser.lineIsNew = false + } + parser.openTags.PushMeta(tag) +} + +func (parser *MatrixHTMLProcessor) HandleSelfClosingTag(tagName string, attrs map[string]string) { + if tagName == "br" { + parser.newline() + } +} + +func (parser *MatrixHTMLProcessor) HandleEndTag(tagName string) { + tag := parser.openTags.Pop(tagName) + + switch tag.Tag { + case "li", "blockquote": + indentSize := 2 + if tag.Tag == "li" && parser.listType == "ol" { + list := parser.openTags.Get(parser.listType) + indentSize = int(math.Log10(float64(list.Counter))+1) + len(". ") + } + if len(parser.indent) >= indentSize { + parser.indent = parser.indent[0 : len(parser.indent)-indentSize] + } + // TODO this newline is sometimes not good + parser.newline() + case "a": + match := matrixToURL.FindStringSubmatch(tag.Meta) + if len(match) == 2 { + pillTarget := match[1] + if pillTarget[0] == '@' { + if member := parser.room.GetMember(pillTarget); member != nil { + parser.text = parser.text.AppendColor(member.DisplayName, widget.GetHashColor(member.DisplayName)) + } else { + parser.text = parser.text.Append(pillTarget) + } + } else { + parser.text = parser.text.Append(pillTarget) + } + } else { + // TODO make text clickable rather than printing URL + parser.text = parser.text.Append(fmt.Sprintf("%s (%s)", tag.Text, tag.Meta)) + } + parser.lineIsNew = false + case "p", "pre", "ol", "ul", "h1", "h2", "h3", "h4", "h5", "h6", "div": + // parser.newline() + } +} + +func (parser *MatrixHTMLProcessor) ReceiveError(err error) { + if err != io.EOF { + debug.Print("Unexpected error parsing HTML:", err) + } +} + +func (parser *MatrixHTMLProcessor) Postprocess() { + if len(parser.text) > 0 && parser.text[len(parser.text)-1].Char == '\n' { + parser.text = parser.text[:len(parser.text)-1] + } +} + +// ParseHTMLMessage parses a HTML-formatted Matrix event into a UIMessage. +func ParseHTMLMessage(room *rooms.Room, evt *gomatrix.Event) tstring.TString { + htmlData, _ := evt.Content["formatted_body"].(string) + + processor := &MatrixHTMLProcessor{ + room: room, + text: tstring.NewBlankTString(), + indent: "", + listType: "", + lineIsNew: true, + openTags: &TagArray{}, + } + + parser := htmlparser.NewHTMLParserFromString(htmlData, processor) + parser.Process() + + return processor.text +} diff --git a/ui/messages/parser/htmltagarray.go b/ui/messages/parser/htmltagarray.go new file mode 100644 index 0000000..4cd4245 --- /dev/null +++ b/ui/messages/parser/htmltagarray.go @@ -0,0 +1,118 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package parser + +// TagWithMeta is an open HTML tag with some metadata (e.g. list index, a href value). +type TagWithMeta struct { + Tag string + Counter int + Meta string + Text string +} + +// BlankTag is a blank TagWithMeta object. +var BlankTag = &TagWithMeta{} + +// TagArray is a reversed queue for remembering what HTML tags are open. +type TagArray []*TagWithMeta + +// Pushb converts the given byte array into a string and calls Push(). +func (ta *TagArray) Pushb(tag []byte) { + ta.Push(string(tag)) +} + +// Popb converts the given byte array into a string and calls Pop(). +func (ta *TagArray) Popb(tag []byte) *TagWithMeta { + return ta.Pop(string(tag)) +} + +// Indexb converts the given byte array into a string and calls Index(). +func (ta *TagArray) Indexb(tag []byte) { + ta.Index(string(tag)) +} + +// IndexAfterb converts the given byte array into a string and calls IndexAfter(). +func (ta *TagArray) IndexAfterb(tag []byte, after int) { + ta.IndexAfter(string(tag), after) +} + +// Push adds the given tag to the array. +func (ta *TagArray) Push(tag string) { + ta.PushMeta(&TagWithMeta{Tag: tag}) +} + +// Push adds the given tag to the array. +func (ta *TagArray) PushMeta(tag *TagWithMeta) { + *ta = append(*ta, BlankTag) + copy((*ta)[1:], *ta) + (*ta)[0] = tag +} + +// Pop removes the given tag from the array. +func (ta *TagArray) Pop(tag string) (removed *TagWithMeta) { + if (*ta)[0].Tag == tag { + // This is the default case and is lighter than append(), so we handle it separately. + removed = (*ta)[0] + *ta = (*ta)[1:] + } else if index := ta.Index(tag); index != -1 { + removed = (*ta)[index] + *ta = append((*ta)[:index], (*ta)[index+1:]...) + } + return +} + +// Index returns the first index where the given tag is, or -1 if it's not in the list. +func (ta *TagArray) Index(tag string) int { + return ta.IndexAfter(tag, -1) +} + +// IndexAfter returns the first index after the given index where the given tag is, +// or -1 if the given tag is not on the list after the given index. +func (ta *TagArray) IndexAfter(tag string, after int) int { + for i := after + 1; i < len(*ta); i++ { + if (*ta)[i].Tag == tag { + return i + } + } + return -1 +} + +// Get returns the first occurrence of the given tag, or nil if it's not in the list. +func (ta *TagArray) Get(tag string) *TagWithMeta { + return ta.GetAfter(tag, -1) +} + +// IndexAfter returns the first occurrence of the given tag, or nil if the given +// tag is not on the list after the given index. +func (ta *TagArray) GetAfter(tag string, after int) *TagWithMeta { + for i := after + 1; i < len(*ta); i++ { + if (*ta)[i].Tag == tag { + return (*ta)[i] + } + } + return nil +} + +// Has returns whether or not the list has at least one of the given tags. +func (ta *TagArray) Has(tags ...string) bool { + for _, tag := range tags { + if index := ta.Index(tag); index != -1 { + return true + } + } + return false +} diff --git a/ui/messages/parser/parser.go b/ui/messages/parser/parser.go new file mode 100644 index 0000000..939dd10 --- /dev/null +++ b/ui/messages/parser/parser.go @@ -0,0 +1,128 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package parser + +import ( + "fmt" + "time" + + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/debug" + "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/matrix/rooms" + "maunium.net/go/gomuks/ui/messages" + "maunium.net/go/gomuks/ui/messages/tstring" + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/tcell" +) + +func ParseEvent(gmx ifc.Gomuks, room *rooms.Room, evt *gomatrix.Event) messages.UIMessage { + member := room.GetMember(evt.Sender) + if member != nil { + evt.Sender = member.DisplayName + } + switch evt.Type { + case "m.room.message": + return ParseMessage(gmx, room, evt) + case "m.room.member": + return ParseMembershipEvent(evt) + } + return nil +} + +func unixToTime(unix int64) time.Time { + timestamp := time.Now() + if unix != 0 { + timestamp = time.Unix(unix/1000, unix%1000*1000) + } + return timestamp +} + +func ParseMessage(gmx ifc.Gomuks, room *rooms.Room, evt *gomatrix.Event) messages.UIMessage { + msgtype, _ := evt.Content["msgtype"].(string) + ts := unixToTime(evt.Timestamp) + switch msgtype { + case "m.text", "m.notice", "m.emote": + format, hasFormat := evt.Content["format"].(string) + if hasFormat && format == "org.matrix.custom.html" { + text := ParseHTMLMessage(room, evt) + return messages.NewExpandedTextMessage(evt.ID, evt.Sender, msgtype, text, ts) + } else { + text, _ := evt.Content["body"].(string) + return messages.NewTextMessage(evt.ID, evt.Sender, msgtype, text, ts) + } + case "m.image": + url, _ := evt.Content["url"].(string) + data, hs, id, err := gmx.Matrix().Download(url) + if err != nil { + debug.Printf("Failed to download %s: %v", url, err) + } + return messages.NewImageMessage(gmx, evt.ID, evt.Sender, msgtype, hs, id, data, ts) + } + return nil +} + +func getMembershipEventContent(evt *gomatrix.Event) (sender string, text tstring.TString) { + membership, _ := evt.Content["membership"].(string) + displayname, _ := evt.Content["displayname"].(string) + if len(displayname) == 0 { + displayname = *evt.StateKey + } + prevMembership := "leave" + prevDisplayname := "" + if evt.Unsigned.PrevContent != nil { + prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string) + prevDisplayname, _ = evt.Unsigned.PrevContent["displayname"].(string) + } + + if membership != prevMembership { + switch membership { + case "invite": + sender = "---" + text = tstring.NewColorTString(fmt.Sprintf("%s invited %s.", evt.Sender, displayname), tcell.ColorGreen) + text.Colorize(0, len(evt.Sender), widget.GetHashColor(evt.Sender)) + text.Colorize(len(evt.Sender)+len(" invited "), len(displayname), widget.GetHashColor(displayname)) + case "join": + sender = "-->" + text = tstring.NewColorTString(fmt.Sprintf("%s joined the room.", displayname), tcell.ColorGreen) + text.Colorize(0, len(displayname), widget.GetHashColor(displayname)) + case "leave": + sender = "<--" + if evt.Sender != *evt.StateKey { + reason, _ := evt.Content["reason"].(string) + text = tstring.NewColorTString(fmt.Sprintf("%s kicked %s: %s", evt.Sender, displayname, reason), tcell.ColorRed) + text.Colorize(0, len(evt.Sender), widget.GetHashColor(evt.Sender)) + text.Colorize(len(evt.Sender)+len(" kicked "), len(displayname), widget.GetHashColor(displayname)) + } else { + text = tstring.NewColorTString(fmt.Sprintf("%s left the room.", displayname), tcell.ColorRed) + text.Colorize(0, len(displayname), widget.GetHashColor(displayname)) + } + } + } else if displayname != prevDisplayname { + sender = "---" + text = tstring.NewColorTString(fmt.Sprintf("%s changed their display name to %s.", prevDisplayname, displayname), tcell.ColorYellow) + text.Colorize(0, len(prevDisplayname), widget.GetHashColor(prevDisplayname)) + text.Colorize(len(prevDisplayname)+len(" changed their display name to "), len(displayname), widget.GetHashColor(displayname)) + } + return +} + +func ParseMembershipEvent(evt *gomatrix.Event) messages.UIMessage { + sender, text := getMembershipEventContent(evt) + ts := unixToTime(evt.Timestamp) + return messages.NewExpandedTextMessage(evt.ID, sender, "m.room.membership", text, ts) +} diff --git a/ui/messages/textbase.go b/ui/messages/textbase.go new file mode 100644 index 0000000..d7eb16c --- /dev/null +++ b/ui/messages/textbase.go @@ -0,0 +1,84 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package messages + +import ( + "encoding/gob" + "regexp" + "time" + + "maunium.net/go/gomuks/ui/messages/tstring" +) + +func init() { + gob.Register(BaseTextMessage{}) +} + +type BaseTextMessage struct { + BaseMessage +} + +func newBaseTextMessage(id, sender, msgtype string, timestamp time.Time) BaseTextMessage { + return BaseTextMessage{newBaseMessage(id, sender, msgtype, timestamp)} +} + +// Regular expressions used to split lines when calculating the buffer. +// +// From tview/textview.go +var ( + boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)") + spacePattern = regexp.MustCompile(`\s+`) +) + +// CalculateBuffer generates the internal buffer for this message that consists +// of the text of this message split into lines at most as wide as the width +// parameter. +func (msg *BaseTextMessage) calculateBufferWithText(text tstring.TString, width int) { + if width < 2 { + return + } + + msg.buffer = []tstring.TString{} + + forcedLinebreaks := text.Split('\n') + newlines := 0 + for _, str := range forcedLinebreaks { + if len(str) == 0 && newlines < 1 { + msg.buffer = append(msg.buffer, tstring.TString{}) + newlines++ + } else { + newlines = 0 + } + // Mostly from tview/textview.go#reindexBuffer() + for len(str) > 0 { + extract := str.Truncate(width) + if len(extract) < len(str) { + if spaces := spacePattern.FindStringIndex(str[len(extract):].String()); spaces != nil && spaces[0] == 0 { + extract = str[:len(extract)+spaces[1]] + } + + matches := boundaryPattern.FindAllStringIndex(extract.String(), -1) + if len(matches) > 0 { + extract = extract[:matches[len(matches)-1][1]] + } + } + msg.buffer = append(msg.buffer, extract) + str = str[len(extract):] + } + } + msg.prevBufferWidth = width +} diff --git a/ui/messages/textmessage.go b/ui/messages/textmessage.go new file mode 100644 index 0000000..4c99e5b --- /dev/null +++ b/ui/messages/textmessage.go @@ -0,0 +1,102 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package messages + +import ( + "encoding/gob" + "fmt" + "time" + + "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/ui/messages/tstring" +) + +func init() { + gob.Register(&TextMessage{}) +} + +type TextMessage struct { + BaseTextMessage + cache tstring.TString + MsgText string +} + +// NewTextMessage creates a new UITextMessage object with the provided values and the default state. +func NewTextMessage(id, sender, msgtype, text string, timestamp time.Time) UIMessage { + return &TextMessage{ + BaseTextMessage: newBaseTextMessage(id, sender, msgtype, timestamp), + MsgText: text, + } +} + +func (msg *TextMessage) getCache() tstring.TString { + if msg.cache == nil { + switch msg.MsgType { + case "m.emote": + msg.cache = tstring.NewColorTString(fmt.Sprintf("* %s %s", msg.MsgSender, msg.MsgText), msg.TextColor()) + msg.cache.Colorize(0, len(msg.MsgSender)+2, msg.SenderColor()) + default: + msg.cache = tstring.NewColorTString(msg.MsgText, msg.TextColor()) + } + } + return msg.cache +} + +// CopyFrom replaces the content of this message object with the content of the given object. +func (msg *TextMessage) CopyFrom(from ifc.MessageMeta) { + msg.BaseTextMessage.CopyFrom(from) + + fromTextMsg, ok := from.(*TextMessage) + if ok { + msg.MsgText = fromTextMsg.MsgText + } + + msg.cache = nil + msg.RecalculateBuffer() +} +func (msg *TextMessage) SetType(msgtype string) { + msg.BaseTextMessage.SetType(msgtype) + msg.cache = nil +} + +func (msg *TextMessage) SetState(state ifc.MessageState) { + msg.BaseTextMessage.SetState(state) + msg.cache = nil +} + +func (msg *TextMessage) SetIsHighlight(isHighlight bool) { + msg.BaseTextMessage.SetIsHighlight(isHighlight) + msg.cache = nil +} + +func (msg *TextMessage) SetIsService(isService bool) { + msg.BaseTextMessage.SetIsService(isService) + msg.cache = nil +} + +func (msg *TextMessage) NotificationContent() string { + return msg.MsgText +} + +func (msg *TextMessage) CalculateBuffer(width int) { + msg.BaseTextMessage.calculateBufferWithText(msg.getCache(), width) +} + +// RecalculateBuffer calculates the buffer again with the previously provided width. +func (msg *TextMessage) RecalculateBuffer() { + msg.CalculateBuffer(msg.prevBufferWidth) +} diff --git a/ui/messages/tstring/cell.go b/ui/messages/tstring/cell.go new file mode 100644 index 0000000..8a400ee --- /dev/null +++ b/ui/messages/tstring/cell.go @@ -0,0 +1,51 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tstring + +import ( + "maunium.net/go/tcell" + "github.com/mattn/go-runewidth" +) + +type Cell struct { + Char rune + Style tcell.Style +} + +func NewStyleCell(char rune, style tcell.Style) Cell { + return Cell{char, style} +} + +func NewColorCell(char rune, color tcell.Color) Cell { + return Cell{char, tcell.StyleDefault.Foreground(color)} +} + +func NewCell(char rune) Cell { + return Cell{char, tcell.StyleDefault} +} + +func (cell Cell) RuneWidth() int { + return runewidth.RuneWidth(cell.Char) +} + +func (cell Cell) Draw(screen tcell.Screen, x, y int) (chWidth int) { + chWidth = cell.RuneWidth() + for runeWidthOffset := 0; runeWidthOffset < chWidth; runeWidthOffset++ { + screen.SetContent(x+runeWidthOffset, y, cell.Char, nil, cell.Style) + } + return +} diff --git a/ui/messages/tstring/string.go b/ui/messages/tstring/string.go new file mode 100644 index 0000000..a87d16a --- /dev/null +++ b/ui/messages/tstring/string.go @@ -0,0 +1,173 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tstring + +import ( + "strings" + + "github.com/mattn/go-runewidth" + "maunium.net/go/tcell" +) + +type TString []Cell + +func NewBlankTString() TString { + return make([]Cell, 0) +} + +func NewTString(str string) TString { + newStr := make([]Cell, len(str)) + for i, char := range str { + newStr[i] = NewCell(char) + } + return newStr +} + +func NewColorTString(str string, color tcell.Color) TString { + newStr := make([]Cell, len(str)) + for i, char := range str { + newStr[i] = NewColorCell(char, color) + } + return newStr +} + +func NewStyleTString(str string, style tcell.Style) TString { + newStr := make([]Cell, len(str)) + for i, char := range str { + newStr[i] = NewStyleCell(char, style) + } + return newStr +} + +func (str TString) AppendTString(data TString) TString { + return append(str, data...) +} + +func (str TString) Append(data string) TString { + newStr := make(TString, len(str)+len(data)) + copy(newStr, str) + for i, char := range data { + newStr[i+len(str)] = NewCell(char) + } + return newStr +} + +func (str TString) AppendColor(data string, color tcell.Color) TString { + newStr := make(TString, len(str)+len(data)) + copy(newStr, str) + for i, char := range data { + newStr[i+len(str)] = NewColorCell(char, color) + } + return newStr +} + +func (str TString) AppendStyle(data string, style tcell.Style) TString { + newStr := make(TString, len(str)+len(data)) + copy(newStr, str) + for i, char := range data { + newStr[i+len(str)] = NewStyleCell(char, style) + } + return newStr +} + +func (str TString) Colorize(from, length int, color tcell.Color) { + for i := from; i < from+length; i++ { + str[i].Style = str[i].Style.Foreground(color) + } +} + +func (str TString) Draw(screen tcell.Screen, x, y int) { + offsetX := 0 + for _, cell := range str { + offsetX += cell.Draw(screen, x+offsetX, y) + } +} + +func (str TString) RuneWidth() (width int) { + for _, cell := range str { + width += runewidth.RuneWidth(cell.Char) + } + return width +} + +func (str TString) String() string { + var buf strings.Builder + for _, cell := range str { + buf.WriteRune(cell.Char) + } + return buf.String() +} + +// Truncate return string truncated with w cells +func (str TString) Truncate(w int) TString { + if str.RuneWidth() <= w { + return str[:] + } + width := 0 + i := 0 + for ; i < len(str); i++ { + cw := runewidth.RuneWidth(str[i].Char) + if width+cw > w { + break + } + width += cw + } + return str[0:i] +} + +func (str TString) IndexFrom(r rune, from int) int { + for i := from; i < len(str); i++ { + if str[i].Char == r { + return i + } + } + return -1 +} + +func (str TString) Index(r rune) int { + return str.IndexFrom(r, 0) +} + +func (str TString) Count(r rune) (counter int) { + index := 0 + for { + index = str.IndexFrom(r, index) + if index < 0 { + break + } + index++ + counter++ + } + return +} + +func (str TString) Split(sep rune) []TString { + a := make([]TString, str.Count(sep)+1) + i := 0 + orig := str + for { + m := orig.Index(sep) + if m < 0 { + break + } + a[i] = orig[:m] + orig = orig[m+1:] + i++ + } + a[i] = orig + return a[:i+1] +} |