aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTulir Asokan <tulir@maunium.net>2018-04-14 18:09:02 +0300
committerGitHub <noreply@github.com>2018-04-14 18:09:02 +0300
commit53cdfb64c1773b63fb9432a1525b4ac4acb154fc (patch)
tree1c1aa180313abe8179d0b07591348e222b60483e
parent14a84295d72a24a8bce8a71c240ab2b155ed5a1f (diff)
parentd060d10615434c557373ee00ba009cc8b583e881 (diff)
Merge pull request #18 from tulir/ui-refactor
Refactor UI to use interfaces and add advanced message rendering
-rw-r--r--config/config.go4
-rw-r--r--config/session.go2
-rw-r--r--debug/debug.go (renamed from ui/debug/debug.go)111
-rw-r--r--debug/doc.go2
-rw-r--r--gomuks.go39
-rw-r--r--interface/matrix.go2
-rw-r--r--interface/ui.go80
-rw-r--r--lib/ansimage/LICENSE373
-rw-r--r--lib/ansimage/ansimage.go287
-rw-r--r--lib/ansimage/doc.go12
-rw-r--r--lib/htmlparser/doc.go3
-rw-r--r--lib/htmlparser/htmlparser.go142
-rw-r--r--lib/notification/doc.go (renamed from notification/doc.go)0
-rw-r--r--lib/notification/notify_darwin.go (renamed from notification/notify_darwin.go)0
-rw-r--r--lib/notification/notify_linux.go (renamed from notification/notify_linux.go)0
-rw-r--r--lib/notification/notify_unsupported.go (renamed from notification/notify_unsupported.go)0
-rw-r--r--lib/notification/notify_windows.go (renamed from notification/notify_windows.go)0
-rw-r--r--lib/open/doc.go4
-rw-r--r--lib/open/open.go27
-rw-r--r--lib/open/open_darwin.go25
-rw-r--r--lib/open/open_windows.go (renamed from ui/debug/external.go)28
-rw-r--r--matrix/matrix.go85
-rw-r--r--matrix/rooms/room.go6
-rw-r--r--ui/debug/doc.go2
-rw-r--r--ui/message-view.go464
-rw-r--r--ui/messages/base.go234
-rw-r--r--ui/messages/doc.go2
-rw-r--r--ui/messages/expandedtextmessage.go71
-rw-r--r--ui/messages/imagemessage.go123
-rw-r--r--ui/messages/message.go38
-rw-r--r--ui/messages/meta.go77
-rw-r--r--ui/messages/parser/htmlparser.go186
-rw-r--r--ui/messages/parser/htmltagarray.go118
-rw-r--r--ui/messages/parser/parser.go128
-rw-r--r--ui/messages/textbase.go84
-rw-r--r--ui/messages/textmessage.go102
-rw-r--r--ui/messages/tstring/cell.go51
-rw-r--r--ui/messages/tstring/string.go173
-rw-r--r--ui/room-list.go (renamed from ui/widget/room-list.go)9
-rw-r--r--ui/room-view.go (renamed from ui/widget/room-view.go)56
-rw-r--r--ui/types/doc.go2
-rw-r--r--ui/types/message.go234
-rw-r--r--ui/types/meta.go71
-rw-r--r--ui/ui.go2
-rw-r--r--ui/view-login.go2
-rw-r--r--ui/view-main.go163
-rw-r--r--ui/widget/advanced-inputfield.go2
-rw-r--r--ui/widget/border.go2
-rw-r--r--ui/widget/color.go2
-rw-r--r--ui/widget/message-view.go354
-rw-r--r--ui/widget/util.go16
51 files changed, 3026 insertions, 974 deletions
diff --git a/config/config.go b/config/config.go
index 4ad6793..85160c6 100644
--- a/config/config.go
+++ b/config/config.go
@@ -23,7 +23,7 @@ import (
"path/filepath"
"gopkg.in/yaml.v2"
- "maunium.net/go/gomuks/ui/debug"
+ "maunium.net/go/gomuks/debug"
)
// Config contains the main config of gomuks.
@@ -33,6 +33,7 @@ type Config struct {
Dir string `yaml:"-"`
HistoryDir string `yaml:"history_dir"`
+ MediaDir string `yaml:"media_dir"`
Session *Session `yaml:"-"`
}
@@ -41,6 +42,7 @@ func NewConfig(dir string) *Config {
return &Config{
Dir: dir,
HistoryDir: filepath.Join(dir, "history"),
+ MediaDir: filepath.Join(dir, "media"),
}
}
diff --git a/config/session.go b/config/session.go
index 912fed0..26d0daa 100644
--- a/config/session.go
+++ b/config/session.go
@@ -24,7 +24,7 @@ import (
"maunium.net/go/gomatrix"
"maunium.net/go/gomuks/matrix/pushrules"
"maunium.net/go/gomuks/matrix/rooms"
- "maunium.net/go/gomuks/ui/debug"
+ "maunium.net/go/gomuks/debug"
)
type Session struct {
diff --git a/ui/debug/debug.go b/debug/debug.go
index 3f47980..c6f367e 100644
--- a/ui/debug/debug.go
+++ b/debug/debug.go
@@ -17,105 +17,45 @@
package debug
import (
+ "bytes"
"fmt"
+ "io"
"io/ioutil"
"os"
"time"
"runtime/debug"
- "maunium.net/go/tview"
)
-type Printer interface {
- Printf(text string, args ...interface{})
- Print(text ...interface{})
-}
-
-type Pane struct {
- *tview.TextView
- Height int
- Width int
- num int
-}
+var writer io.Writer
-var Default Printer
-var RedirectAllExt bool
-
-func NewPane() *Pane {
- pane := tview.NewTextView()
- pane.
- SetScrollable(true).
- SetWrap(true).
- SetBorder(true).
- SetTitle("Debug output")
- fmt.Fprintln(pane, "[0] Debug pane initialized")
-
- return &Pane{
- TextView: pane,
- Height: 35,
- Width: 80,
- num: 0,
+func init() {
+ var err error
+ writer, err = os.OpenFile("/tmp/gomuks-debug.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
+ if err != nil {
+ writer = nil
}
}
-func (db *Pane) Printf(text string, args ...interface{}) {
- db.WriteString(fmt.Sprintf(text, args...) + "\n")
-}
-
-func (db *Pane) Print(text ...interface{}) {
- db.WriteString(fmt.Sprintln(text...))
-}
-
-func (db *Pane) WriteString(text string) {
- db.num++
- fmt.Fprintf(db, "[%d] %s", db.num, text)
-}
-
-type PaneSide int
-
-const (
- Top PaneSide = iota
- Bottom
- Left
- Right
-)
-
-func (db *Pane) Wrap(main tview.Primitive, side PaneSide) tview.Primitive {
- rows, columns := []int{0}, []int{0}
- mainRow, mainColumn, paneRow, paneColumn := 0, 0, 0, 0
- switch side {
- case Top:
- rows = []int{db.Height, 0}
- mainRow = 1
- case Bottom:
- rows = []int{0, db.Height}
- paneRow = 1
- case Left:
- columns = []int{db.Width, 0}
- mainColumn = 1
- case Right:
- columns = []int{0, db.Width}
- paneColumn = 1
+func Printf(text string, args ...interface{}) {
+ if writer != nil {
+ fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
+ fmt.Fprintf(writer, text+"\n", args...)
}
- return tview.NewGrid().SetRows(rows...).SetColumns(columns...).
- AddItem(main, mainRow, mainColumn, 1, 1, 1, 1, true).
- AddItem(db, paneRow, paneColumn, 1, 1, 1, 1, false)
}
-func Printf(text string, args ...interface{}) {
- if RedirectAllExt {
- ExtPrintf(text, args...)
- } else if Default != nil {
- Default.Printf(text, args...)
+func Print(text ...interface{}) {
+ if writer != nil {
+ fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
+ fmt.Fprintln(writer, text...)
}
}
-func Print(text ...interface{}) {
- if RedirectAllExt {
- ExtPrint(text...)
- } else if Default != nil {
- Default.Print(text...)
+func PrintStack() {
+ if writer != nil {
+ data := debug.Stack()
+ writer.Write(data)
}
}
@@ -128,14 +68,18 @@ const Oops = ` __________
U ||----W |
|| ||`
-func PrettyPanic() {
+func PrettyPanic(panic interface{}) {
fmt.Println(Oops)
fmt.Println("")
fmt.Println("A fatal error has occurred.")
fmt.Println("")
traceFile := fmt.Sprintf("/tmp/gomuks-panic-%s.txt", time.Now().Format("2006-01-02--15-04-05"))
- data := debug.Stack()
- err := ioutil.WriteFile(traceFile, data, 0644)
+
+ var buf bytes.Buffer
+ fmt.Fprintln(&buf, panic)
+ buf.Write(debug.Stack())
+ err := ioutil.WriteFile(traceFile, buf.Bytes(), 0644)
+
if err != nil {
fmt.Println("Saving the stack trace to", traceFile, "failed:")
fmt.Println("--------------------------------------------------------------------------------")
@@ -146,6 +90,7 @@ func PrettyPanic() {
fmt.Println("Please provide the file save error (above) and the stack trace of the original error (below) when filing an issue.")
fmt.Println("")
fmt.Println("--------------------------------------------------------------------------------")
+ fmt.Println(panic)
debug.PrintStack()
fmt.Println("--------------------------------------------------------------------------------")
} else {
diff --git a/debug/doc.go b/debug/doc.go
new file mode 100644
index 0000000..253441c
--- /dev/null
+++ b/debug/doc.go
@@ -0,0 +1,2 @@
+// Package debug contains utilities to log debug messages and display panics nicely.
+package debug
diff --git a/gomuks.go b/gomuks.go
index 60a4d2b..494f182 100644
--- a/gomuks.go
+++ b/gomuks.go
@@ -23,10 +23,10 @@ import (
"time"
"maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/matrix"
"maunium.net/go/gomuks/ui"
- "maunium.net/go/gomuks/ui/debug"
"maunium.net/go/tview"
)
@@ -35,7 +35,6 @@ type Gomuks struct {
app *tview.Application
ui *ui.GomuksUI
matrix *matrix.Container
- debug *debug.Pane
debugMode bool
config *config.Config
stop chan bool
@@ -43,19 +42,14 @@ type Gomuks struct {
// NewGomuks creates a new Gomuks instance with everything initialized,
// but does not start it.
-func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
+func NewGomuks(enableDebug bool) *Gomuks {
configDir := filepath.Join(os.Getenv("HOME"), ".config/gomuks")
gmx := &Gomuks{
- app: tview.NewApplication(),
- stop: make(chan bool, 1),
+ app: tview.NewApplication(),
+ stop: make(chan bool, 1),
+ debugMode: enableDebug,
}
- gmx.debug = debug.NewPane()
- gmx.debug.SetChangedFunc(func() {
- gmx.ui.Render()
- })
- debug.Default = gmx.debug
-
gmx.config = config.NewConfig(configDir)
gmx.ui = ui.NewGomuksUI(gmx)
gmx.matrix = matrix.NewContainer(gmx)
@@ -68,15 +62,6 @@ func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
_ = gmx.matrix.InitClient()
main := gmx.ui.InitViews()
- if enableDebug {
- debug.EnableExternal()
- if forceExternalDebug {
- debug.RedirectAllExt = true
- } else {
- main = gmx.debug.Wrap(main, debug.Right)
- }
- gmx.debugMode = true
- }
gmx.app.SetRoot(main, true)
return gmx
@@ -85,10 +70,10 @@ func NewGomuks(enableDebug, forceExternalDebug bool) *Gomuks {
// Save saves the active session and message history.
func (gmx *Gomuks) Save() {
if gmx.config.Session != nil {
- gmx.debug.Print("Saving session...")
+ debug.Print("Saving session...")
_ = gmx.config.Session.Save()
}
- gmx.debug.Print("Saving history...")
+ debug.Print("Saving history...")
gmx.ui.MainView().SaveAllHistory()
}
@@ -112,9 +97,9 @@ func (gmx *Gomuks) StartAutosave() {
// Stop stops the Matrix syncer, the tview app and the autosave goroutine,
// then saves everything and calls os.Exit(0).
func (gmx *Gomuks) Stop() {
- gmx.debug.Print("Disconnecting from Matrix...")
+ debug.Print("Disconnecting from Matrix...")
gmx.matrix.Stop()
- gmx.debug.Print("Cleaning up UI...")
+ debug.Print("Cleaning up UI...")
gmx.app.Stop()
gmx.stop <- true
gmx.Save()
@@ -132,7 +117,7 @@ func (gmx *Gomuks) Recover() {
if gmx.debugMode {
panic(p)
} else {
- debug.PrettyPanic()
+ debug.PrettyPanic(p)
}
}
}
@@ -170,8 +155,8 @@ func (gmx *Gomuks) UI() ifc.GomuksUI {
}
func main() {
- debugVar := os.Getenv("DEBUG")
- NewGomuks(len(debugVar) > 0, debugVar == "ext").Start()
+ enableDebug := len(os.Getenv("DEBUG")) > 0
+ NewGomuks(enableDebug).Start()
// We use os.Exit() everywhere, so exiting by returning from Start() shouldn't happen.
time.Sleep(5 * time.Second)
diff --git a/interface/matrix.go b/interface/matrix.go
index f811dff..3a1ec14 100644
--- a/interface/matrix.go
+++ b/interface/matrix.go
@@ -34,4 +34,6 @@ type MatrixContainer interface {
LeaveRoom(roomID string) error
GetHistory(roomID, prevBatch string, limit int) ([]gomatrix.Event, string, error)
GetRoom(roomID string) *rooms.Room
+ Download(mxcURL string) ([]byte, string, string, error)
+ GetCachePath(homeserver, fileID string) string
}
diff --git a/interface/ui.go b/interface/ui.go
index c0ddf53..e6071fc 100644
--- a/interface/ui.go
+++ b/interface/ui.go
@@ -17,11 +17,12 @@
package ifc
import (
+ "time"
+
+ "maunium.net/go/tcell"
"maunium.net/go/gomatrix"
"maunium.net/go/gomuks/matrix/pushrules"
"maunium.net/go/gomuks/matrix/rooms"
- "maunium.net/go/gomuks/ui/types"
- "maunium.net/go/gomuks/ui/widget"
"maunium.net/go/tview"
)
@@ -42,7 +43,7 @@ type GomuksUI interface {
}
type MainView interface {
- GetRoom(roomID string) *widget.RoomView
+ GetRoom(roomID string) RoomView
HasRoom(roomID string) bool
AddRoom(roomID string)
RemoveRoom(roomID string)
@@ -50,11 +51,76 @@ type MainView interface {
SaveAllHistory()
SetTyping(roomID string, users []string)
- AddServiceMessage(roomID *widget.RoomView, message string)
- ProcessMessageEvent(roomView *widget.RoomView, evt *gomatrix.Event) *types.Message
- ProcessMembershipEvent(roomView *widget.RoomView, evt *gomatrix.Event) *types.Message
- NotifyMessage(room *rooms.Room, message *types.Message, should pushrules.PushActionArrayShould)
+ ParseEvent(roomView RoomView, evt *gomatrix.Event) Message
+ //ProcessMessageEvent(roomView RoomView, evt *gomatrix.Event) Message
+ //ProcessMembershipEvent(roomView RoomView, evt *gomatrix.Event) Message
+ NotifyMessage(room *rooms.Room, message Message, should pushrules.PushActionArrayShould)
}
type LoginView interface {
}
+
+type MessageDirection int
+
+const (
+ AppendMessage MessageDirection = iota
+ PrependMessage
+ IgnoreMessage
+)
+
+type RoomView interface {
+ MxRoom() *rooms.Room
+ SaveHistory(dir string) error
+ LoadHistory(gmx Gomuks, dir string) (int, error)
+
+ SetStatus(status string)
+ SetTyping(users []string)
+ UpdateUserList()
+
+ NewMessage(id, sender, msgtype, text string, timestamp time.Time) Message
+ NewTempMessage(msgtype, text string) Message
+ AddMessage(message Message, direction MessageDirection)
+ AddServiceMessage(message string)
+}
+
+type MessageMeta interface {
+ Sender() string
+ SenderColor() tcell.Color
+ TextColor() tcell.Color
+ TimestampColor() tcell.Color
+ Timestamp() time.Time
+ FormatTime() string
+ FormatDate() string
+ CopyFrom(from MessageMeta)
+}
+
+// MessageState is an enum to specify if a Message is being sent, failed to send or was successfully sent.
+type MessageState int
+
+// Allowed MessageStates.
+const (
+ MessageStateSending MessageState = iota
+ MessageStateDefault
+ MessageStateFailed
+)
+
+type Message interface {
+ MessageMeta
+
+ SetIsHighlight(isHighlight bool)
+ IsHighlight() bool
+
+ SetIsService(isService bool)
+ IsService() bool
+
+ SetID(id string)
+ ID() string
+
+ SetType(msgtype string)
+ Type() string
+
+ NotificationContent() string
+
+ SetState(state MessageState)
+ State() MessageState
+}
diff --git a/lib/ansimage/LICENSE b/lib/ansimage/LICENSE
new file mode 100644
index 0000000..14e2f77
--- /dev/null
+++ b/lib/ansimage/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
diff --git a/lib/ansimage/ansimage.go b/lib/ansimage/ansimage.go
new file mode 100644
index 0000000..b117b3d
--- /dev/null
+++ b/lib/ansimage/ansimage.go
@@ -0,0 +1,287 @@
+// ___ _____ ____
+// / _ \/ _/ |/_/ /____ ______ _
+// / ___// /_> </ __/ -_) __/ ' \
+// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
+//
+// Copyright 2017 Eliuk Blau
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package ansimage
+
+import (
+ "errors"
+ "image"
+ "image/color"
+ "image/draw"
+ _ "image/gif" // initialize decoder
+ _ "image/jpeg" // initialize decoder
+ _ "image/png" // initialize decoder
+ "io"
+ "os"
+
+ "github.com/disintegration/imaging"
+ _ "golang.org/x/image/bmp" // initialize decoder
+ _ "golang.org/x/image/tiff" // initialize decoder
+ _ "golang.org/x/image/webp" // initialize decoder
+ "maunium.net/go/gomuks/ui/messages/tstring"
+ "maunium.net/go/tcell"
+)
+
+var (
+ // ErrHeightNonMoT happens when ANSImage height is not a Multiple of Two value.
+ ErrHeightNonMoT = errors.New("ANSImage: height must be a Multiple of Two value")
+
+ // ErrInvalidBoundsMoT happens when ANSImage height or width are invalid values (Multiple of Two).
+ ErrInvalidBoundsMoT = errors.New("ANSImage: height or width must be >=2")
+
+ // ErrOutOfBounds happens when ANSI-pixel coordinates are out of ANSImage bounds.
+ ErrOutOfBounds = errors.New("ANSImage: out of bounds")
+)
+
+// ANSIpixel represents a pixel of an ANSImage.
+type ANSIpixel struct {
+ Brightness uint8
+ R, G, B uint8
+ upper bool
+ source *ANSImage
+}
+
+// ANSImage represents an image encoded in ANSI escape codes.
+type ANSImage struct {
+ h, w int
+ maxprocs int
+ bgR uint8
+ bgG uint8
+ bgB uint8
+ pixmap [][]*ANSIpixel
+}
+
+func (ai *ANSImage) Pixmap() [][]*ANSIpixel {
+ return ai.pixmap
+}
+
+// Height gets total rows of ANSImage.
+func (ai *ANSImage) Height() int {
+ return ai.h
+}
+
+// Width gets total columns of ANSImage.
+func (ai *ANSImage) Width() int {
+ return ai.w
+}
+
+// SetMaxProcs sets the maximum number of parallel goroutines to render the ANSImage
+// (user should manually sets `runtime.GOMAXPROCS(max)` before to this change takes effect).
+func (ai *ANSImage) SetMaxProcs(max int) {
+ ai.maxprocs = max
+}
+
+// GetMaxProcs gets the maximum number of parallels goroutines to render the ANSImage.
+func (ai *ANSImage) GetMaxProcs() int {
+ return ai.maxprocs
+}
+
+// SetAt sets ANSI-pixel color (RBG) and brightness in coordinates (y,x).
+func (ai *ANSImage) SetAt(y, x int, r, g, b, brightness uint8) error {
+ if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
+ ai.pixmap[y][x].R = r
+ ai.pixmap[y][x].G = g
+ ai.pixmap[y][x].B = b
+ ai.pixmap[y][x].Brightness = brightness
+ ai.pixmap[y][x].upper = y%2 == 0
+ return nil
+ }
+ return ErrOutOfBounds
+}
+
+// GetAt gets ANSI-pixel in coordinates (y,x).
+func (ai *ANSImage) GetAt(y, x int) (*ANSIpixel, error) {
+ if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
+ return &ANSIpixel{
+ R: ai.pixmap[y][x].R,
+ G: ai.pixmap[y][x].G,
+ B: ai.pixmap[y][x].B,
+ Brightness: ai.pixmap[y][x].Brightness,
+ upper: ai.pixmap[y][x].upper,
+ source: ai.pixmap[y][x].source,
+ },
+ nil
+ }
+ return nil, ErrOutOfBounds
+}
+
+// Render returns the ANSI-compatible string form of ANSImage.
+// (Nice info for ANSI True Colour - https://gist.github.com/XVilka/8346728)
+func (ai *ANSImage) Render() []tstring.TString {
+ type renderData struct {
+ row int
+ render tstring.TString
+ }
+
+ rows := make([]tstring.TString, ai.h/2)
+ for y := 0; y < ai.h; y += ai.maxprocs {
+ ch := make(chan renderData, ai.maxprocs)
+ for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n {
+ go func(row, y int) {
+ str := make(tstring.TString, ai.w)
+ for x := 0; x < ai.w; x++ {
+ topPixel := ai.pixmap[y][x]
+ topColor := tcell.NewRGBColor(int32(topPixel.R), int32(topPixel.G), int32(topPixel.B))
+
+ bottomPixel := ai.pixmap[y+1][x]
+ bottomColor := tcell.NewRGBColor(int32(bottomPixel.R), int32(bottomPixel.G), int32(bottomPixel.B))
+
+ str[x] = tstring.Cell{
+ Char: '▄',
+ Style: tcell.StyleDefault.Background(topColor).Foreground(bottomColor),
+ }
+ }
+ ch <- renderData{row: row, render: str}
+ }(row, 2*row)
+ }
+ for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n {
+ data := <-ch
+ rows[data.row] = data.render
+ }
+ }
+ return rows
+}
+
+// New creates a new empty ANSImage ready to draw on it.
+func New(h, w int, bg color.Color) (*ANSImage, error) {
+ if h%2 != 0 {
+ return nil, ErrHeightNonMoT
+ }
+
+ if h < 2 || w < 2 {
+ return nil, ErrInvalidBoundsMoT
+ }
+
+ r, g, b, _ := bg.RGBA()
+ ansimage := &ANSImage{
+ h: h, w: w,
+ maxprocs: 1,
+ bgR: uint8(r),
+ bgG: uint8(g),
+ bgB: uint8(b),
+ pixmap: nil,
+ }
+
+ ansimage.pixmap = func() [][]*ANSIpixel {
+ v := make([][]*ANSIpixel, h)
+ for y := 0; y < h; y++ {
+ v[y] = make([]*ANSIpixel, w)
+ for x := 0; x < w; x++ {
+ v[y][x] = &ANSIpixel{
+ R: 0,
+ G: 0,
+ B: 0,
+ Brightness: 0,
+ source: ansimage,
+ upper: y%2 == 0,
+ }
+ }
+ }
+ return v
+ }()
+
+ return ansimage, nil
+}
+
+// NewFromReader creates a new ANSImage from an io.Reader.
+// Background color is used to fill when image has transparency or dithering mode is enabled
+// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
+func NewFromReader(reader io.Reader, bg color.Color) (*ANSImage, error) {
+ img, _, err := image.Decode(reader)
+ if err != nil {
+ return nil, err
+ }
+
+ return createANSImage(img, bg)
+}
+
+// NewScaledFromReader creates a new scaled ANSImage from an io.Reader.
+// Background color is used to fill when image has transparency or dithering mode is enabled
+// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
+func NewScaledFromReader(reader io.Reader, y, x int, bg color.Color) (*ANSImage, error) {
+ img, _, err := image.Decode(reader)
+ if err != nil {
+ return nil, err
+ }
+
+ img = imaging.Resize(img, x, y, imaging.Lanczos)
+
+ return createANSImage(img, bg)
+}
+
+// NewFromFile creates a new ANSImage from a file.
+// Background color is used to fill when image has transparency or dithering mode is enabled
+// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
+func NewFromFile(name string, bg color.Color) (*ANSImage, error) {
+ reader, err := os.Open(name)
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ return NewFromReader(reader, bg)
+}
+
+// NewScaledFromFile creates a new scaled ANSImage from a file.
+// Background color is used to fill when image has transparency or dithering mode is enabled
+// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
+func NewScaledFromFile(name string, y, x int, bg color.Color) (*ANSImage, error) {
+ reader, err := os.Open(name)
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ return NewScaledFromReader(reader, y, x, bg)
+}
+
+// createANSImage loads data from an image and returns an ANSImage.
+// Background color is used to fill when image has transparency or dithering mode is enabled
+// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
+func createANSImage(img image.Image, bg color.Color) (*ANSImage, error) {
+ var rgbaOut *image.RGBA
+ bounds := img.Bounds()
+
+ // do compositing only if background color has no transparency (thank you @disq for the idea!)
+ // (info - http://stackoverflow.com/questions/36595687/transparent-pixel-color-go-lang-image)
+ if _, _, _, a := bg.RGBA(); a >= 0xffff {
+ rgbaOut = image.NewRGBA(bounds)
+ draw.Draw(rgbaOut, bounds, image.NewUniform(bg), image.ZP, draw.Src)
+ draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Over)
+ } else {
+ if v, ok := img.(*image.RGBA); ok {
+ rgbaOut = v
+ } else {
+ rgbaOut = image.NewRGBA(bounds)
+ draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Src)
+ }
+ }
+
+ yMin, xMin := bounds.Min.Y, bounds.Min.X
+ yMax, xMax := bounds.Max.Y, bounds.Max.X
+
+ // always sets an even number of ANSIPixel rows...
+ yMax = yMax - yMax%2 // one for upper pixel and another for lower pixel --> without dithering
+
+ ansimage, err := New(yMax, xMax, bg)
+ if err != nil {
+ return nil, err
+ }
+
+ for y := yMin; y < yMax; y++ {
+ for x := xMin; x < xMax; x++ {
+ v := rgbaOut.RGBAAt(x, y)
+ if err := ansimage.SetAt(y, x, v.R, v.G, v.B, 0); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ return ansimage, nil
+}
diff --git a/lib/ansimage/doc.go b/lib/ansimage/doc.go
new file mode 100644
index 0000000..56bc0c5
--- /dev/null
+++ b/lib/ansimage/doc.go
@@ -0,0 +1,12 @@
+// Package ansimage is a simplified version of the ansimage package
+// in https://github.com/eliukblau/pixterm focused in rendering images
+// to a tcell-based TUI app.
+//
+// ___ _____ ____
+// / _ \/ _/ |/_/ /____ ______ _
+// / ___// /_> </ __/ -_) __/ ' \
+// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
+//
+// This package is licensed under the Mozilla Public License v2.0.
+package ansimage
+
diff --git a/lib/htmlparser/doc.go b/lib/htmlparser/doc.go
new file mode 100644
index 0000000..0e31960
--- /dev/null
+++ b/lib/htmlparser/doc.go
@@ -0,0 +1,3 @@
+// Package htmlparser contains a HTML parsing system similar to html.parser.HTMLParser in Python 3.
+// The parser uses x/net/html.Tokenizer in the background.
+package htmlparser
diff --git a/lib/htmlparser/htmlparser.go b/lib/htmlparser/htmlparser.go
new file mode 100644
index 0000000..5ef8d98
--- /dev/null
+++ b/lib/htmlparser/htmlparser.go
@@ -0,0 +1,142 @@
+// 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 htmlparser
+
+import (
+ "io"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+// HTMLProcessor contains the functions to process parsed HTML data.
+type HTMLProcessor interface {
+ // Preprocess is called before the parsing is started.
+ Preprocess()
+
+ // HandleStartTag is called with the tag name and attributes when
+ // the parser encounters a StartTagToken, except if the tag is
+ // always self-closing.
+ HandleStartTag(tagName string, attrs map[string]string)
+ // HandleSelfClosingTag is called with the tag name and attributes
+ // when the parser encounters a SelfClosingTagToken OR a StartTagToken
+ // with a tag that's always self-closing.
+ HandleSelfClosingTag(tagName string, attrs map[string]string)
+ // HandleText is called with the text when the parser encounters
+ // a TextToken.
+ HandleText(text string)
+ // HandleEndTag is called with the tag name when the parser encounters
+ // an EndTagToken.
+ HandleEndTag(tagName string)
+
+ // ReceiveError is called with the error when the parser encounters
+ // an ErrorToken that IS NOT io.EOF.
+ ReceiveError(err error)
+
+ // Postprocess is called after parsing is completed successfully.
+ // An unsuccessful parsing will trigger a ReceiveError() call.
+ Postprocess()
+}
+
+// HTMLParser wraps a net/html.Tokenizer and a HTMLProcessor to call
+// the HTMLProcessor with data from the Tokenizer.
+type HTMLParser struct {
+ *html.Tokenizer
+ processor HTMLProcessor
+}
+
+// NewHTMLParserFromTokenizer creates a new HTMLParser from an existing html Tokenizer.
+func NewHTMLParserFromTokenizer(z *html.Tokenizer, processor HTMLProcessor) HTMLParser {
+ return HTMLParser{
+ z,
+ processor,
+ }
+}
+
+// NewHTMLParserFromReader creates a Tokenizer with the given io.Reader and
+// then uses that to create a new HTMLParser.
+func NewHTMLParserFromReader(reader io.Reader, processor HTMLProcessor) HTMLParser {
+ return NewHTMLParserFromTokenizer(html.NewTokenizer(reader), processor)
+}
+
+// NewHTMLParserFromString creates a Tokenizer with a reader of the given
+// string and then uses that to create a new HTMLParser.
+func NewHTMLParserFromString(html string, processor HTMLProcessor) HTMLParser {
+ return NewHTMLParserFromReader(strings.NewReader(html), processor)
+}
+
+// SelfClosingTags is the list of tags that always call
+// HTMLProcessor.HandleSelfClosingTag() even if it is encountered
+// as a html.StartTagToken rather than html.SelfClosingTagToken.
+var SelfClosingTags = []string{"img", "br", "hr", "area", "base", "basefont", "input", "link", "meta"}
+
+func (parser HTMLParser) mapAttrs() map[string]string {
+ attrs := make(map[string]string)
+ hasMore := true
+ for hasMore {
+ var key, val []byte
+ key, val, hasMore = parser.TagAttr()
+ attrs[string(key)] = string(val)
+ }
+ return attrs
+}
+
+func (parser HTMLParser) isSelfClosing(tag string) bool {
+ for _, selfClosingTag := range SelfClosingTags {
+ if tag == selfClosingTag {
+ return true
+ }
+ }
+ return false
+}
+
+// Process parses the HTML using the tokenizer in this parser and
+// calls the appropriate functions of the HTML processor.
+func (parser HTMLParser) Process() {
+ parser.processor.Preprocess()
+Loop:
+ for {
+ tt := parser.Next()
+ switch tt {
+ case html.ErrorToken:
+ if parser.Err() != io.EOF {
+ parser.processor.ReceiveError(parser.Err())
+ return
+ }
+ break Loop
+ case html.TextToken:
+ parser.processor.HandleText(string(parser.Text()))
+ case html.StartTagToken, html.SelfClosingTagToken:
+ tagb, _ := parser.TagName()
+ attrs := parser.mapAttrs()
+ tag := string(tagb)
+
+ selfClosing := tt == html.SelfClosingTagToken || parser.isSelfClosing(tag)
+
+ if selfClosing {
+ parser.processor.HandleSelfClosingTag(tag, attrs)
+ } else {
+ parser.processor.HandleStartTag(tag, attrs)
+ }
+ case html.EndTagToken:
+ tagb, _ := parser.TagName()
+ parser.processor.HandleEndTag(string(tagb))
+ }
+ }
+
+ parser.processor.Postprocess()
+}
diff --git a/notification/doc.go b/lib/notification/doc.go
index 05295c6..05295c6 100644
--- a/notification/doc.go
+++ b/lib/notification/doc.go
diff --git a/notification/notify_darwin.go b/lib/notification/notify_darwin.go
index 1e09ef8..1e09ef8 100644
--- a/notification/notify_darwin.go
+++ b/lib/notification/notify_darwin.go
diff --git a/notification/notify_linux.go b/lib/notification/notify_linux.go
index f8ecdaf..f8ecdaf 100644
--- a/notification/notify_linux.go
+++ b/lib/notification/notify_linux.go
diff --git a/notification/notify_unsupported.go b/lib/notification/notify_unsupported.go
index 0b350e8..0b350e8 100644
--- a/notification/notify_unsupported.go
+++ b/lib/notification/notify_unsupported.go
diff --git a/notification/notify_windows.go b/lib/notification/notify_windows.go
index d927ee1..d927ee1 100644
--- a/notification/notify_windows.go
+++ b/lib/notification/notify_windows.go
diff --git a/lib/open/doc.go b/lib/open/doc.go
new file mode 100644
index 0000000..367ffb7
--- /dev/null
+++ b/lib/open/doc.go
@@ -0,0 +1,4 @@
+// Package open contains a simple cross-platform way to open files in the program the OS wants to use.
+//
+// Based on https://github.com/skratchdot/open-golang
+package open
diff --git a/lib/open/open.go b/lib/open/open.go
new file mode 100644
index 0000000..3fde8e3
--- /dev/null
+++ b/lib/open/open.go
@@ -0,0 +1,27 @@
+// +build !windows,!darwin
+
+// 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 open
+
+import (
+ "os/exec"
+)
+
+func Open(input string) error {
+ return exec.Command("xdg-open", input).Start()
+}
diff --git a/lib/open/open_darwin.go b/lib/open/open_darwin.go
new file mode 100644
index 0000000..9f53ef5
--- /dev/null
+++ b/lib/open/open_darwin.go
@@ -0,0 +1,25 @@
+// 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 open
+
+import (
+ "os/exec"
+)
+
+func Open(input string) error {
+ return exec.Command("open", input).Start()
+}
diff --git a/ui/debug/external.go b/lib/open/open_windows.go
index faabbcc..aced662 100644
--- a/ui/debug/external.go
+++ b/lib/open/open_windows.go
@@ -14,32 +14,16 @@
// 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 debug
+package open
import (
- "fmt"
- "io"
- "os"
+ "os/exec"
)
-var writer io.Writer
+const FileProtocolHandler = "url.dll,FileProtocolHandler"
-func EnableExternal() {
- var err error
- writer, err = os.OpenFile("/tmp/gomuks-debug.log", os.O_WRONLY|os.O_APPEND, 0644)
- if err != nil {
- writer = nil
- }
-}
-
-func ExtPrintf(text string, args ...interface{}) {
- if writer != nil {
- fmt.Fprintf(writer, text+"\n", args...)
- }
-}
+var RunDLL32 = filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe")
-func ExtPrint(text ...interface{}) {
- if writer != nil {
- fmt.Fprintln(writer, text...)
- }
+func Open(input string) error {
+ return exec.Command(RunDLL32, FileProtocolHandler, input).Start()
}
diff --git a/matrix/matrix.go b/matrix/matrix.go
index 7391ca0..48bb57f 100644
--- a/matrix/matrix.go
+++ b/matrix/matrix.go
@@ -17,18 +17,26 @@
package matrix
import (
+ "bytes"
"encoding/json"
"fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
"strings"
"time"
"maunium.net/go/gomatrix"
"maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/matrix/pushrules"
"maunium.net/go/gomuks/matrix/rooms"
- "maunium.net/go/gomuks/ui/debug"
- "maunium.net/go/gomuks/ui/widget"
)
// Container is a wrapper for a gomatrix Client and some other stuff.
@@ -221,13 +229,13 @@ func (c *Container) HandleMessage(evt *gomatrix.Event) {
return
}
- message := mainView.ProcessMessageEvent(roomView, evt)
+ message := mainView.ParseEvent(roomView, evt)
if message != nil {
if c.syncer.FirstSyncDone {
- pushRules := c.PushRules().GetActions(roomView.Room, evt).Should()
- mainView.NotifyMessage(roomView.Room, message, pushRules)
+ pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt).Should()
+ mainView.NotifyMessage(roomView.MxRoom(), message, pushRules)
}
- roomView.AddMessage(message, widget.AppendMessage)
+ roomView.AddMessage(message, ifc.AppendMessage)
c.ui.Render()
}
}
@@ -255,8 +263,7 @@ func (c *Container) processOwnMembershipChange(evt *gomatrix.Event) {
if evt.Unsigned.PrevContent != nil {
prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string)
}
- const Hour = 1 * 60 * 60 * 1000
- if membership == prevMembership || evt.Unsigned.Age > Hour {
+ if membership == prevMembership {
return
}
switch membership {
@@ -279,18 +286,18 @@ func (c *Container) HandleMembership(evt *gomatrix.Event) {
return
}
- message := mainView.ProcessMembershipEvent(roomView, evt)
+ message := mainView.ParseEvent(roomView, evt)
if message != nil {
// TODO this shouldn't be necessary
- roomView.Room.UpdateState(evt)
+ roomView.MxRoom().UpdateState(evt)
// TODO This should probably also be in a different place
roomView.UpdateUserList()
if c.syncer.FirstSyncDone {
- pushRules := c.PushRules().GetActions(roomView.Room, evt).Should()
- mainView.NotifyMessage(roomView.Room, message, pushRules)
+ pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt).Should()
+ mainView.NotifyMessage(roomView.MxRoom(), message, pushRules)
}
- roomView.AddMessage(message, widget.AppendMessage)
+ roomView.AddMessage(message, ifc.AppendMessage)
c.ui.Render()
}
}
@@ -403,3 +410,55 @@ func (c *Container) GetRoom(roomID string) *rooms.Room {
}
return room
}
+
+var mxcRegex = regexp.MustCompile("mxc://(.+)/(.+)")
+
+func (c *Container) Download(mxcURL string) (data []byte, hs, id string, err error) {
+ parts := mxcRegex.FindStringSubmatch(mxcURL)
+ if parts == nil || len(parts) != 3 {
+ err = fmt.Errorf("invalid matrix content URL")
+ return
+ }
+ hs = parts[1]
+ id = parts[2]
+
+ cacheFile := c.GetCachePath(hs, id)
+ if _, err = os.Stat(cacheFile); err != nil {
+ data, err = ioutil.ReadFile(cacheFile)
+ if err == nil {
+ return
+ }
+ }
+
+ dlURL, _ := url.Parse(c.client.HomeserverURL.String())
+ dlURL.Path = path.Join(dlURL.Path, "/_matrix/media/v1/download", hs, id)
+
+ var resp *http.Response
+ resp, err = c.client.Client.Get(dlURL.String())
+ if err != nil {
+ return
+ }
+ defer resp.Body.Close()
+
+ var buf bytes.Buffer
+ _, err = io.Copy(&buf, resp.Body)
+ if err != nil {
+ return
+ }
+
+ data = buf.Bytes()
+
+ err = ioutil.WriteFile(cacheFile, data, 0600)
+ return
+}
+
+func (c *Container) GetCachePath(homeserver, fileID string) string {
+ dir := filepath.Join(c.config.MediaDir, homeserver)
+
+ err := os.MkdirAll(dir, 0700)
+ if err != nil {
+ return ""
+ }
+
+ return filepath.Join(dir, fileID)
+}
diff --git a/matrix/rooms/room.go b/matrix/rooms/room.go
index 7dd2af4..2e12a28 100644
--- a/matrix/rooms/room.go
+++ b/matrix/rooms/room.go
@@ -94,11 +94,7 @@ func (room *Room) UpdateState(event *gomatrix.Event) {
room.memberCache = nil
room.firstMemberCache = ""
fallthrough
- case "m.room.name":
- fallthrough
- case "m.room.canonical_alias":
- fallthrough
- case "m.room.alias":
+ case "m.room.name", "m.room.canonical_alias", "m.room.alias":
room.nameCache = ""
case "m.room.topic":
room.topicCache = ""
diff --git a/ui/debug/doc.go b/ui/debug/doc.go
deleted file mode 100644
index a321689..0000000
--- a/ui/debug/doc.go
+++ /dev/null
@@ -1,2 +0,0 @@
-// Package debug contains utilities to display debug messages while running an interactive tview program.
-package debug
diff --git a/ui/message-view.go b/ui/message-view.go
new file mode 100644
index 0000000..80add59
--- /dev/null
+++ b/ui/message-view.go
@@ -0,0 +1,464 @@
+// 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 ui
+
+import (
+ "encoding/gob"
+ "fmt"
+ "math"
+ "os"
+
+ "maunium.net/go/gomuks/debug"
+ "maunium.net/go/gomuks/interface"
+ "maunium.net/go/gomuks/lib/open"
+ "maunium.net/go/gomuks/ui/messages"
+ "maunium.net/go/gomuks/ui/messages/tstring"
+ "maunium.net/go/gomuks/ui/widget"
+ "maunium.net/go/tcell"
+ "maunium.net/go/tview"
+)
+
+type MessageView struct {
+ *tview.Box
+
+ parent *RoomView
+
+ ScrollOffset int
+ MaxSenderWidth int
+ DateFormat string
+ TimestampFormat string
+ TimestampWidth int
+ LoadingMessages bool
+
+ widestSender int
+ prevWidth int
+ prevHeight int
+ prevMsgCount int
+
+ messageIDs map[string]messages.UIMessage
+ messages []messages.UIMessage
+
+ textBuffer []tstring.TString
+ metaBuffer []ifc.MessageMeta
+}
+
+func NewMessageView(parent *RoomView) *MessageView {
+ return &MessageView{
+ Box: tview.NewBox(),
+ parent: parent,
+
+ MaxSenderWidth: 15,
+ TimestampWidth: len(messages.TimeFormat),
+ ScrollOffset: 0,
+
+ messages: make([]messages.UIMessage, 0),
+ messageIDs: make(map[string]messages.UIMessage),
+ textBuffer: make([]tstring.TString, 0),
+ metaBuffer: make([]ifc.MessageMeta, 0),
+
+ widestSender: 5,
+ prevWidth: -1,
+ prevHeight: -1,
+ prevMsgCount: -1,
+ }
+}
+
+func (view *MessageView) SaveHistory(path string) error {
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ enc := gob.NewEncoder(file)
+ err = enc.Encode(view.messages)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (view *MessageView) LoadHistory(gmx ifc.Gomuks, path string) (int, error) {
+ file, err := os.OpenFile(path, os.O_RDONLY, 0600)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return 0, nil
+ }
+ return -1, err
+ }
+ defer file.Close()
+
+ var msgs []messages.UIMessage
+
+ dec := gob.NewDecoder(file)
+ err = dec.Decode(&msgs)
+ if err != nil {
+ return -1, err
+ }
+
+ view.messages = make([]messages.UIMessage, len(msgs))
+ indexOffset := 0
+ for index, message := range msgs {
+ if message != nil {
+ view.messages[index-indexOffset] = message
+ view.updateWidestSender(message.Sender())
+ message.RegisterGomuks(gmx)
+ } else {
+ indexOffset++
+ }
+ }
+
+ return len(view.messages), nil
+}
+
+func (view *MessageView) updateWidestSender(sender string) {
+ if len(sender) > view.widestSender {
+ view.widestSender = len(sender)
+ if view.widestSender > view.MaxSenderWidth {
+ view.widestSender = view.MaxSenderWidth
+ }
+ }
+}
+
+func (view *MessageView) UpdateMessageID(ifcMessage ifc.Message, newID string) {
+ message, ok := ifcMessage.(messages.UIMessage)
+ if !ok {
+ debug.Print("[Warning] Passed non-UIMessage ifc.Message object to UpdateMessageID().")
+ debug.PrintStack()
+ return
+ }
+ delete(view.messageIDs, message.ID())
+ message.SetID(newID)
+ view.messageIDs[message.ID()] = message
+}
+
+func (view *MessageView) AddMessage(ifcMessage ifc.Message, direction ifc.MessageDirection) {
+ if ifcMessage == nil {
+ return
+ }
+ message, ok := ifcMessage.(messages.UIMessage)
+ if !ok {
+ debug.Print("[Warning] Passed non-UIMessage ifc.Message object to AddMessage().")
+ debug.PrintStack()
+ return
+ }
+
+ oldMsg, messageExists := view.messageIDs[message.ID()]
+ if messageExists {
+ oldMsg.CopyFrom(message)
+ message = oldMsg
+ direction = ifc.IgnoreMessage
+ }
+
+ view.updateWidestSender(message.Sender())
+
+ _, _, width, _ := view.GetInnerRect()
+ width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap
+ message.CalculateBuffer(width)
+
+ if direction == ifc.AppendMessage {
+ if view.ScrollOffset > 0 {
+ view.ScrollOffset += message.Height()
+ }
+ view.messages = append(view.messages, message)
+ view.appendBuffer(message)
+ } else if direction == ifc.PrependMessage {
+ view.messages = append([]messages.UIMessage{message}, view.messages...)
+ } else {
+ view.replaceBuffer(message)
+ }
+
+ view.messageIDs[message.ID()] = message
+}
+
+func (view *MessageView) appendBuffer(message messages.UIMessage) {
+ if len(view.metaBuffer) > 0 {
+ prevMeta := view.metaBuffer[len(view.metaBuffer)-1]
+ if prevMeta != nil && prevMeta.FormatDate() != message.FormatDate() {
+ view.textBuffer = append(view.textBuffer, tstring.NewColorTString(
+ fmt.Sprintf("Date changed to %s", message.FormatDate()),
+ tcell.ColorGreen))
+ view.metaBuffer = append(view.metaBuffer, &messages.BasicMeta{
+ BTimestampColor: tcell.ColorDefault, BTextColor: tcell.ColorGreen})
+ }
+ }
+
+ view.textBuffer = append(view.textBuffer, message.Buffer()...)
+ for range message.Buffer() {
+ view.metaBuffer = append(view.metaBuffer, message)
+ }
+ view.prevMsgCount++
+}
+
+func (view *MessageView) replaceBuffer(message messages.UIMessage) {
+ start := -1
+ end := -1
+ for index, meta := range view.metaBuffer {
+ if meta == message {
+ if start == -1 {
+ start = index
+ }
+ end = index
+ } else if start != -1 {
+ break
+ }
+ }
+
+ if len(view.textBuffer) > end {
+ end++
+ }
+
+ view.textBuffer = append(append(view.textBuffer[0:start], message.Buffer()...), view.textBuffer[end:]...)
+ if len(message.Buffer()) != end-start+1 {
+ metaBuffer := view.metaBuffer[0:start]
+ for range message.Buffer() {
+ metaBuffer = append(metaBuffer, message)
+ }
+ view.metaBuffer = append(metaBuffer, view.metaBuffer[end:]...)
+ }
+}
+
+func (view *MessageView) recalculateBuffers() {
+ _, _, width, height := view.GetInnerRect()
+
+ width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap
+ recalculateMessageBuffers := width != view.prevWidth
+ if height != view.prevHeight || recalculateMessageBuffers || len(view.messages) != view.prevMsgCount {
+ view.textBuffer = []tstring.TString{}
+ view.metaBuffer = []ifc.MessageMeta{}
+ view.prevMsgCount = 0
+ for i, message := range view.messages {
+ if message == nil {
+ debug.Print("O.o found nil message at", i)
+ break
+ }
+ if recalculateMessageBuffers {
+ message.CalculateBuffer(width)
+ }
+ view.appendBuffer(message)
+ }
+ view.prevHeight = height
+ view.prevWidth = width
+ }
+}
+
+func (view *MessageView) HandleClick(x, y int, button tcell.ButtonMask) bool {
+ if button != tcell.Button1 {
+ return false
+ }
+
+ _, _, _, height := view.GetRect()
+ line := view.TotalHeight() - view.ScrollOffset - height + y
+ if line < 0 || line >= view.TotalHeight() {
+ return false
+ }
+
+ message := view.metaBuffer[line]
+ var prevMessage ifc.MessageMeta
+ if line > 0 {
+ prevMessage = view.metaBuffer[line-1]
+ }
+
+ usernameX := view.TimestampWidth + TimestampSenderGap
+ messageX := usernameX + view.widestSender + SenderMessageGap
+ if x >= messageX {
+ switch message := message.(type) {
+ case *messages.ImageMessage:
+ open.Open(message.Path())
+ case messages.UIMessage:
+ debug.Print("Message clicked:", message.NotificationContent())
+ }
+ } else if x >= usernameX {
+ uiMessage, ok := message.(messages.UIMessage)
+ if !ok {
+ return false
+ }
+
+ prevUIMessage, _ := prevMessage.(messages.UIMessage)
+ if prevUIMessage != nil && prevUIMessage.Sender() == uiMessage.Sender() {
+ return false
+ }
+
+ sender := []rune(uiMessage.Sender())
+ if len(sender) == 0 {
+ return false
+ }
+
+ cursorPos := view.parent.input.GetCursorOffset()
+ text := []rune(view.parent.input.GetText())
+ var newText []rune
+ if cursorPos == 0 {
+ newText = append(sender, ':', ' ')
+ newText = append(newText, text...)
+ } else {
+ newText = append(text[0:cursorPos], sender...)
+ newText = append(newText, ' ')
+ newText = append(newText, text[cursorPos:]...)
+ }
+ view.parent.input.SetText(string(newText))
+ view.parent.input.SetCursorOffset(cursorPos + len(newText) - len(text))
+ return true
+ }
+ return false
+}
+
+const PaddingAtTop = 5
+
+func (view *MessageView) AddScrollOffset(diff int) {
+ _, _, _, height := view.GetInnerRect()
+
+ totalHeight := view.TotalHeight()
+ if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop {
+ view.ScrollOffset = totalHeight - height + PaddingAtTop
+ } else {
+ view.ScrollOffset += diff
+ }
+
+ if view.ScrollOffset > totalHeight-height+PaddingAtTop {
+ view.ScrollOffset = totalHeight - height + PaddingAtTop
+ }
+ if view.ScrollOffset < 0 {
+ view.ScrollOffset = 0
+ }
+}
+
+func (view *MessageView) Height() int {
+ _, _, _, height := view.GetInnerRect()
+ return height
+}
+
+func (view *MessageView) TotalHeight() int {
+ return len(view.textBuffer)
+}
+
+func (view *MessageView) IsAtTop() bool {
+ _, _, _, height := view.GetInnerRect()
+ totalHeight := len(view.textBuffer)
+ return view.ScrollOffset >= totalHeight-height+PaddingAtTop
+}
+
+const (
+ TimestampSenderGap = 1
+ SenderSeparatorGap = 1
+ SenderMessageGap = 3
+)
+
+func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) {
+ char = '│'
+ style = tcell.StyleDefault
+ if scrollbarHere {
+ style = style.Foreground(tcell.ColorGreen)
+ }
+ if isTop {
+ if scrollbarHere {
+ char = '╥'
+ } else {
+ char = '┬'
+ }
+ } else if isBottom {
+ if scrollbarHere {
+ char = '╨'
+ } else {
+ char = '┴'
+ }
+ } else if scrollbarHere {
+ char = '║'
+ }
+ return
+}
+
+func (view *MessageView) Draw(screen tcell.Screen) {
+ view.Box.Draw(screen)
+
+ x, y, _, height := view.GetInnerRect()
+ view.recalculateBuffers()
+
+ if view.TotalHeight() == 0 {
+ widget.WriteLineSimple(screen, "It's quite empty in here.", x, y+height)
+ return
+ }
+
+ usernameX := x + view.TimestampWidth + TimestampSenderGap
+ messageX := usernameX + view.widestSender + SenderMessageGap
+ separatorX := usernameX + view.widestSender + SenderSeparatorGap
+
+ indexOffset := view.TotalHeight() - view.ScrollOffset - height
+ if indexOffset <= -PaddingAtTop {
+ message := "Scroll up to load more messages."
+ if view.LoadingMessages {
+ message = "Loading more messages..."
+ }
+ widget.WriteLineSimpleColor(screen, message, messageX, y, tcell.ColorGreen)
+ }
+
+ if len(view.textBuffer) != len(view.metaBuffer) {
+ debug.Printf("Unexpected text/meta buffer length mismatch: %d != %d.", len(view.textBuffer), len(view.metaBuffer))
+ return
+ }
+
+ var scrollBarHeight, scrollBarPos int
+ // Black magic (aka math) used to figure out where the scroll bar should be put.
+ {
+ viewportHeight := float64(height)
+ contentHeight := float64(view.TotalHeight())
+
+ scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight)))
+
+ scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight))
+ }
+
+ var prevMeta ifc.MessageMeta
+ firstLine := true
+ skippedLines := 0
+
+ for line := 0; line < height; line++ {
+ index := indexOffset + line
+ if index < 0 {
+ skippedLines++
+ continue
+ } else if index >= view.TotalHeight() {
+ break
+ }
+
+ showScrollbar := line-skippedLines >= scrollBarPos-scrollBarHeight && line-skippedLines < scrollBarPos
+ isTop := firstLine && view.ScrollOffset+height >= view.TotalHeight()
+ isBottom := line == height-1 && view.ScrollOffset == 0
+
+ borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom)
+
+ firstLine = false
+
+ screen.SetContent(separatorX, y+line, borderChar, nil, borderStyle)
+
+ text, meta := view.textBuffer[index], view.metaBuffer[index]
+ if meta != prevMeta {
+ if len(meta.FormatTime()) > 0 {
+ widget.WriteLineSimpleColor(screen, meta.FormatTime(), x, y+line, meta.TimestampColor())
+ }
+ if prevMeta == nil || meta.Sender() != prevMeta.Sender() {
+ widget.WriteLineColor(
+ screen, tview.AlignRight, meta.Sender(),
+ usernameX, y+line, view.widestSender,
+ meta.SenderColor())
+ }
+ prevMeta = meta
+ }
+
+ text.Draw(screen, messageX, y+line)
+ }
+}
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]
+}
diff --git a/ui/widget/room-list.go b/ui/room-list.go
index d2fb543..b85ed24 100644
--- a/ui/widget/room-list.go
+++ b/ui/room-list.go
@@ -14,14 +14,15 @@
// 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 widget
+package ui
import (
"fmt"
"strconv"
- "github.com/gdamore/tcell"
+ "maunium.net/go/tcell"
"maunium.net/go/gomuks/matrix/rooms"
+ "maunium.net/go/gomuks/ui/widget"
"maunium.net/go/tview"
)
@@ -126,11 +127,11 @@ func (list *RoomList) Draw(screen tcell.Screen) {
unreadMessageCount += "!"
}
unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount)
- writeLine(screen, tview.AlignRight, unreadMessageCount, x+lineWidth-6, y, 6, style)
+ widget.WriteLine(screen, tview.AlignRight, unreadMessageCount, x+lineWidth-6, y, 6, style)
lineWidth -= len(unreadMessageCount) + 1
}
- writeLine(screen, tview.AlignLeft, text, x, y, lineWidth, style)
+ widget.WriteLine(screen, tview.AlignLeft, text, x, y, lineWidth, style)
y++
if y >= bottomLimit {
diff --git a/ui/widget/room-view.go b/ui/room-view.go
index 4bab779..d7824fe 100644
--- a/ui/widget/room-view.go
+++ b/ui/room-view.go
@@ -14,7 +14,7 @@
// 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 widget
+package ui
import (
"fmt"
@@ -23,9 +23,11 @@ import (
"strings"
"time"
- "github.com/gdamore/tcell"
+ "maunium.net/go/tcell"
+ "maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/matrix/rooms"
- "maunium.net/go/gomuks/ui/types"
+ "maunium.net/go/gomuks/ui/messages"
+ "maunium.net/go/gomuks/ui/widget"
"maunium.net/go/tview"
)
@@ -36,8 +38,8 @@ type RoomView struct {
content *MessageView
status *tview.TextView
userList *tview.TextView
- ulBorder *Border
- input *AdvancedInputField
+ ulBorder *widget.Border
+ input *widget.AdvancedInputField
Room *rooms.Room
}
@@ -45,13 +47,13 @@ func NewRoomView(room *rooms.Room) *RoomView {
view := &RoomView{
Box: tview.NewBox(),
topic: tview.NewTextView(),
- content: NewMessageView(),
status: tview.NewTextView(),
userList: tview.NewTextView(),
- ulBorder: NewBorder(),
- input: NewAdvancedInputField(),
+ ulBorder: widget.NewBorder(),
+ input: widget.NewAdvancedInputField(),
Room: room,
}
+ view.content = NewMessageView(view)
view.input.
SetFieldBackgroundColor(tcell.ColorDefault).
@@ -79,8 +81,8 @@ func (view *RoomView) SaveHistory(dir string) error {
return view.MessageView().SaveHistory(view.logPath(dir))
}
-func (view *RoomView) LoadHistory(dir string) (int, error) {
- return view.MessageView().LoadHistory(view.logPath(dir))
+func (view *RoomView) LoadHistory(gmx ifc.Gomuks, dir string) (int, error) {
+ return view.MessageView().LoadHistory(gmx, view.logPath(dir))
}
func (view *RoomView) SetTabCompleteFunc(fn func(room *RoomView, text string, cursorOffset int) string) *RoomView {
@@ -129,7 +131,7 @@ func (view *RoomView) GetInputText() string {
return view.input.GetText()
}
-func (view *RoomView) GetInputField() *AdvancedInputField {
+func (view *RoomView) GetInputField() *widget.AdvancedInputField {
return view.input
}
@@ -230,15 +232,19 @@ func (view *RoomView) MessageView() *MessageView {
return view.content
}
+func (view *RoomView) MxRoom() *rooms.Room {
+ return view.Room
+}
+
func (view *RoomView) UpdateUserList() {
var joined strings.Builder
var invited strings.Builder
for _, user := range view.Room.GetMembers() {
if user.Membership == "join" {
- joined.WriteString(AddHashColor(user.DisplayName))
+ joined.WriteString(widget.AddHashColor(user.DisplayName))
joined.WriteRune('\n')
} else if user.Membership == "invite" {
- invited.WriteString(AddHashColor(user.DisplayName))
+ invited.WriteString(widget.AddHashColor(user.DisplayName))
invited.WriteRune('\n')
}
}
@@ -249,27 +255,37 @@ func (view *RoomView) UpdateUserList() {
}
}
-func (view *RoomView) NewMessage(id, sender, msgtype, text string, timestamp time.Time) *types.Message {
+func (view *RoomView) newUIMessage(id, sender, msgtype, text string, timestamp time.Time) messages.UIMessage {
member := view.Room.GetMember(sender)
if member != nil {
sender = member.DisplayName
}
- return view.content.NewMessage(id, sender, msgtype, text, timestamp)
+ return messages.NewTextMessage(id, sender, msgtype, text, timestamp)
+}
+
+func (view *RoomView) NewMessage(id, sender, msgtype, text string, timestamp time.Time) ifc.Message {
+ return view.newUIMessage(id, sender, msgtype, text, timestamp)
}
-func (view *RoomView) NewTempMessage(msgtype, text string) *types.Message {
+func (view *RoomView) NewTempMessage(msgtype, text string) ifc.Message {
now := time.Now()
id := strconv.FormatInt(now.UnixNano(), 10)
sender := ""
if ownerMember := view.Room.GetSessionOwner(); ownerMember != nil {
sender = ownerMember.DisplayName
}
- message := view.NewMessage(id, sender, msgtype, text, now)
- message.State = types.MessageStateSending
- view.AddMessage(message, AppendMessage)
+ message := view.newUIMessage(id, sender, msgtype, text, now)
+ message.SetState(ifc.MessageStateSending)
+ view.AddMessage(message, ifc.AppendMessage)
return message
}
-func (view *RoomView) AddMessage(message *types.Message, direction MessageDirection) {
+func (view *RoomView) AddServiceMessage(text string) {
+ message := view.newUIMessage("", "*", "gomuks.service", text, time.Now())
+ message.SetIsService(true)
+ view.AddMessage(message, ifc.AppendMessage)
+}
+
+func (view *RoomView) AddMessage(message ifc.Message, direction ifc.MessageDirection) {
view.content.AddMessage(message, direction)
}
diff --git a/ui/types/doc.go b/ui/types/doc.go
deleted file mode 100644
index 5bc229c..0000000
--- a/ui/types/doc.go
+++ /dev/null
@@ -1,2 +0,0 @@
-// Package types contains common type definitions used mostly by the UI, but also other parts of gomuks.
-package types
diff --git a/ui/types/message.go b/ui/types/message.go
deleted file mode 100644
index fa3b6ef..0000000
--- a/ui/types/message.go
+++ /dev/null
@@ -1,234 +0,0 @@
-// 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 types
-
-import (
- "fmt"
- "regexp"
- "strings"
-
- "github.com/gdamore/tcell"
- "github.com/mattn/go-runewidth"
-)
-
-// MessageState is an enum to specify if a Message is being sent, failed to send or was successfully sent.
-type MessageState int
-
-// Allowed MessageStates.
-const (
- MessageStateSending MessageState = iota
- MessageStateDefault
- MessageStateFailed
-)
-
-// Message is a wrapper for the content and metadata of a Matrix message intended to be displayed.
-type Message struct {
- ID string
- Type string
- Sender string
- SenderColor tcell.Color
- TextColor tcell.Color
- Timestamp string
- Date string
- Text string
- State MessageState
- buffer []string
- prevBufferWidth int
-}
-
-// NewMessage creates a new Message object with the provided values and the default state.
-func NewMessage(id, sender, msgtype, text, timestamp, date string, senderColor tcell.Color) *Message {
- return &Message{
- Sender: sender,
- Timestamp: timestamp,
- Date: date,
- SenderColor: senderColor,
- TextColor: tcell.ColorDefault,
- Type: msgtype,
- Text: text,
- ID: id,
- prevBufferWidth: 0,
- State: MessageStateDefault,
- }
-}
-
-// CopyTo copies the content of this message to the given message.
-func (message *Message) CopyTo(to *Message) {
- to.ID = message.ID
- to.Type = message.Type
- to.Sender = message.Sender
- to.SenderColor = message.SenderColor
- to.TextColor = message.TextColor
- to.Timestamp = message.Timestamp
- to.Date = message.Date
- to.Text = message.Text
- to.State = message.State
- to.RecalculateBuffer()
-}
-
-// GetSender 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 (message *Message) GetSender() string {
- switch message.State {
- case MessageStateSending:
- return "Sending..."
- case MessageStateFailed:
- return "Error"
- }
- switch message.Type {
- case "m.emote":
- // Emotes don't show a separate sender, it's included in the buffer.
- return ""
- default:
- return message.Sender
- }
-}
-
-func (message *Message) getStateSpecificColor() tcell.Color {
- switch message.State {
- case MessageStateSending:
- return tcell.ColorGray
- case MessageStateFailed:
- return tcell.ColorRed
- case MessageStateDefault:
- fallthrough
- default:
- return tcell.ColorDefault
- }
-}
-
-// GetSenderColor 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 (message *Message) GetSenderColor() (color tcell.Color) {
- color = message.getStateSpecificColor()
- if color == tcell.ColorDefault {
- color = message.SenderColor
- }
- return
-}
-
-// GetTextColor returns the color the actual content of the message should be shown in.
-//
-// This returns the same colors as GetSenderColor(), but takes the default color from a different variable.
-func (message *Message) GetTextColor() (color tcell.Color) {
- color = message.getStateSpecificColor()
- if color == tcell.ColorDefault {
- color = message.TextColor
- }
- return
-}
-
-// GetTimestampColor returns the color the timestamp should be shown in.
-//
-// As with GetSenderColor(), 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 (message *Message) GetTimestampColor() tcell.Color {
- return message.getStateSpecificColor()
-}
-
-// RecalculateBuffer calculates the buffer again with the previously provided width.
-func (message *Message) RecalculateBuffer() {
- message.CalculateBuffer(message.prevBufferWidth)
-}
-
-// 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 (message *Message) Buffer() []string {
- return message.buffer
-}
-
-// Height returns the number of rows in the computed buffer (see Buffer()).
-func (message *Message) Height() int {
- return len(message.buffer)
-}
-
-// GetTimestamp returns the formatted time when the message was sent.
-func (message *Message) GetTimestamp() string {
- return message.Timestamp
-}
-
-// GetDate returns the formatted date when the message was sent.
-func (message *Message) GetDate() string {
- return message.Date
-}
-
-// 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 (message *Message) CalculateBuffer(width int) {
- if width < 2 {
- return
- }
-
- message.buffer = []string{}
- text := message.Text
- if message.Type == "m.emote" {
- text = fmt.Sprintf("* %s %s", message.Sender, message.Text)
- }
-
- forcedLinebreaks := strings.Split(text, "\n")
- newlines := 0
- for _, str := range forcedLinebreaks {
- if len(str) == 0 && newlines < 1 {
- message.buffer = append(message.buffer, "")
- newlines++
- } else {
- newlines = 0
- }
- // From tview/textview.go#reindexBuffer()
- for len(str) > 0 {
- extract := runewidth.Truncate(str, width, "")
- if len(extract) < len(str) {
- if spaces := spacePattern.FindStringIndex(str[len(extract):]); spaces != nil && spaces[0] == 0 {
- extract = str[:len(extract)+spaces[1]]
- }
-
- matches := boundaryPattern.FindAllStringIndex(extract, -1)
- if len(matches) > 0 {
- extract = extract[:matches[len(matches)-1][1]]
- }
- }
- message.buffer = append(message.buffer, extract)
- str = str[len(extract):]
- }
- }
- message.prevBufferWidth = width
-}
diff --git a/ui/types/meta.go b/ui/types/meta.go
deleted file mode 100644
index fdc6dba..0000000
--- a/ui/types/meta.go
+++ /dev/null
@@ -1,71 +0,0 @@
-// 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 types
-
-import (
- "github.com/gdamore/tcell"
-)
-
-// MessageMeta is an interface to get the metadata of a message.
-//
-// See BasicMeta for a simple implementation and documentation of methods.
-type MessageMeta interface {
- GetSender() string
- GetSenderColor() tcell.Color
- GetTextColor() tcell.Color
- GetTimestampColor() tcell.Color
- GetTimestamp() string
- GetDate() string
-}
-
-// BasicMeta is a simple variable store implementation of MessageMeta.
-type BasicMeta struct {
- Sender, Timestamp, Date string
- SenderColor, TextColor, TimestampColor tcell.Color
-}
-
-// GetSender gets the string that should be displayed as the sender of this message.
-func (meta *BasicMeta) GetSender() string {
- return meta.Sender
-}
-
-// GetSenderColor returns the color the name of the sender should be shown in.
-func (meta *BasicMeta) GetSenderColor() tcell.Color {
- return meta.SenderColor
-}
-
-// GetTimestamp returns the formatted time when the message was sent.
-func (meta *BasicMeta) GetTimestamp() string {
- return meta.Timestamp
-}
-
-// GetDate returns the formatted date when the message was sent.
-func (meta *BasicMeta) GetDate() string {
- return meta.Date
-}
-
-// GetTextColor returns the color the actual content of the message should be shown in.
-func (meta *BasicMeta) GetTextColor() tcell.Color {
- return meta.TextColor
-}
-
-// GetTimestampColor 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) GetTimestampColor() tcell.Color {
- return meta.TimestampColor
-}
diff --git a/ui/ui.go b/ui/ui.go
index 16321b8..b5f847d 100644
--- a/ui/ui.go
+++ b/ui/ui.go
@@ -17,7 +17,7 @@
package ui
import (
- "github.com/gdamore/tcell"
+ "maunium.net/go/tcell"
"maunium.net/go/gomuks/interface"
"maunium.net/go/tview"
)
diff --git a/ui/view-login.go b/ui/view-login.go
index ff0e44e..8343aaa 100644
--- a/ui/view-login.go
+++ b/ui/view-login.go
@@ -19,7 +19,7 @@ package ui
import (
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/interface"
- "maunium.net/go/gomuks/ui/debug"
+ "maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/ui/widget"
"maunium.net/go/tview"
)
diff --git a/ui/view-main.go b/ui/view-main.go
index fd05492..d4ffd39 100644
--- a/ui/view-main.go
+++ b/ui/view-main.go
@@ -23,26 +23,26 @@ import (
"time"
"unicode"
- "github.com/gdamore/tcell"
"github.com/mattn/go-runewidth"
"maunium.net/go/gomatrix"
"maunium.net/go/gomuks/config"
+ "maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/interface"
+ "maunium.net/go/gomuks/lib/notification"
"maunium.net/go/gomuks/matrix/pushrules"
"maunium.net/go/gomuks/matrix/rooms"
- "maunium.net/go/gomuks/notification"
- "maunium.net/go/gomuks/ui/debug"
- "maunium.net/go/gomuks/ui/types"
+ "maunium.net/go/gomuks/ui/messages/parser"
"maunium.net/go/gomuks/ui/widget"
+ "maunium.net/go/tcell"
"maunium.net/go/tview"
)
type MainView struct {
*tview.Flex
- roomList *widget.RoomList
+ roomList *RoomList
roomView *tview.Pages
- rooms map[string]*widget.RoomView
+ rooms map[string]*RoomView
currentRoomIndex int
roomIDs []string
@@ -57,9 +57,9 @@ type MainView struct {
func (ui *GomuksUI) NewMainView() tview.Primitive {
mainView := &MainView{
Flex: tview.NewFlex(),
- roomList: widget.NewRoomList(),
+ roomList: NewRoomList(),
roomView: tview.NewPages(),
- rooms: make(map[string]*widget.RoomView),
+ rooms: make(map[string]*RoomView),
matrix: ui.gmx.Matrix(),
gmx: ui.gmx,
@@ -81,7 +81,7 @@ func (view *MainView) BumpFocus() {
view.lastFocusTime = time.Now()
}
-func (view *MainView) InputChanged(roomView *widget.RoomView, text string) {
+func (view *MainView) InputChanged(roomView *RoomView, text string) {
if len(text) == 0 {
go view.matrix.SendTyping(roomView.Room.ID, false)
} else if text[0] != '/' {
@@ -101,7 +101,7 @@ func findWordToTabComplete(text string) string {
return output
}
-func (view *MainView) InputTabComplete(roomView *widget.RoomView, text string, cursorOffset int) string {
+func (view *MainView) InputTabComplete(roomView *RoomView, text string, cursorOffset int) string {
str := runewidth.Truncate(text, cursorOffset, "")
word := findWordToTabComplete(str)
userCompletions := roomView.AutocompleteUser(word)
@@ -118,7 +118,7 @@ func (view *MainView) InputTabComplete(roomView *widget.RoomView, text string, c
return text
}
-func (view *MainView) InputSubmit(roomView *widget.RoomView, text string) {
+func (view *MainView) InputSubmit(roomView *RoomView, text string) {
if len(text) == 0 {
return
} else if text[0] == '/' {
@@ -132,29 +132,30 @@ func (view *MainView) InputSubmit(roomView *widget.RoomView, text string) {
roomView.SetInputText("")
}
-func (view *MainView) SendMessage(roomView *widget.RoomView, text string) {
+func (view *MainView) SendMessage(roomView *RoomView, text string) {
tempMessage := roomView.NewTempMessage("m.text", text)
- go view.sendTempMessage(roomView, tempMessage)
+ go view.sendTempMessage(roomView, tempMessage, text)
}
-func (view *MainView) sendTempMessage(roomView *widget.RoomView, tempMessage *types.Message) {
+func (view *MainView) sendTempMessage(roomView *RoomView, tempMessage ifc.Message, text string) {
defer view.gmx.Recover()
- eventID, err := view.matrix.SendMessage(roomView.Room.ID, tempMessage.Type, tempMessage.Text)
+ eventID, err := view.matrix.SendMessage(roomView.Room.ID, tempMessage.Type(), text)
if err != nil {
- tempMessage.State = types.MessageStateFailed
+ tempMessage.SetState(ifc.MessageStateFailed)
roomView.SetStatus(fmt.Sprintf("Failed to send message: %s", err))
} else {
roomView.MessageView().UpdateMessageID(tempMessage, eventID)
}
}
-func (view *MainView) HandleCommand(roomView *widget.RoomView, command string, args []string) {
+func (view *MainView) HandleCommand(roomView *RoomView, command string, args []string) {
defer view.gmx.Recover()
debug.Print("Handling command", command, args)
switch command {
case "/me":
- tempMessage := roomView.NewTempMessage("m.emote", strings.Join(args, " "))
- go view.sendTempMessage(roomView, tempMessage)
+ text := strings.Join(args, " ")
+ tempMessage := roomView.NewTempMessage("m.emote", text)
+ go view.sendTempMessage(roomView, tempMessage, text)
view.parent.Render()
case "/quit":
view.gmx.Stop()
@@ -163,22 +164,20 @@ func (view *MainView) HandleCommand(roomView *widget.RoomView, command string, a
view.gmx.Stop()
case "/panic":
panic("This is a test panic.")
- case "/part":
- fallthrough
- case "/leave":
+ case "/part", "/leave":
debug.Print("Leave room result:", view.matrix.LeaveRoom(roomView.Room.ID))
case "/join":
if len(args) == 0 {
- view.AddServiceMessage(roomView, "Usage: /join <room>")
+ roomView.AddServiceMessage("Usage: /join <room>")
break
}
debug.Print("Join room result:", view.matrix.JoinRoom(args[0]))
default:
- view.AddServiceMessage(roomView, "Unknown command.")
+ roomView.AddServiceMessage("Unknown command.")
}
}
-func (view *MainView) KeyEventHandler(roomView *widget.RoomView, key *tcell.EventKey) *tcell.EventKey {
+func (view *MainView) KeyEventHandler(roomView *RoomView, key *tcell.EventKey) *tcell.EventKey {
view.BumpFocus()
k := key.Key()
@@ -220,8 +219,8 @@ func (view *MainView) KeyEventHandler(roomView *widget.RoomView, key *tcell.Even
const WheelScrollOffsetDiff = 3
-func (view *MainView) MouseEventHandler(roomView *widget.RoomView, event *tcell.EventMouse) *tcell.EventMouse {
- if event.Buttons() == tcell.ButtonNone {
+func (view *MainView) MouseEventHandler(roomView *RoomView, event *tcell.EventMouse) *tcell.EventMouse {
+ if event.Buttons() == tcell.ButtonNone || event.HasMotion() {
return event
}
view.BumpFocus()
@@ -247,7 +246,14 @@ func (view *MainView) MouseEventHandler(roomView *widget.RoomView, event *tcell.
roomView.Room.MarkRead()
}
default:
- debug.Print("Mouse event received:", event.Buttons(), event.Modifiers(), x, y)
+ mx, my, mw, mh := msgView.GetRect()
+ if x >= mx && y >= my && x < mx+mw && y < my+mh {
+ if msgView.HandleClick(x-mx, y-my, event.Buttons()) {
+ view.parent.Render()
+ }
+ } else {
+ debug.Print("Mouse event received:", event.Buttons(), event.Modifiers(), x, y)
+ }
return event
}
@@ -300,7 +306,7 @@ func (view *MainView) addRoom(index int, room string) {
view.roomList.Add(roomStore)
if !view.roomView.HasPage(room) {
- roomView := widget.NewRoomView(roomStore).
+ roomView := NewRoomView(roomStore).
SetInputSubmitFunc(view.InputSubmit).
SetInputChangedFunc(view.InputChanged).
SetTabCompleteFunc(view.InputTabComplete).
@@ -310,7 +316,7 @@ func (view *MainView) addRoom(index int, room string) {
view.roomView.AddPage(room, roomView, true, false)
roomView.UpdateUserList()
- count, err := roomView.LoadHistory(view.config.HistoryDir)
+ count, err := roomView.LoadHistory(view.gmx, view.config.HistoryDir)
if err != nil {
debug.Printf("Failed to load history of %s: %v", roomView.Room.GetTitle(), err)
} else if count <= 0 {
@@ -319,7 +325,7 @@ func (view *MainView) addRoom(index int, room string) {
}
}
-func (view *MainView) GetRoom(id string) *widget.RoomView {
+func (view *MainView) GetRoom(id string) ifc.RoomView {
return view.rooms[id]
}
@@ -352,7 +358,7 @@ func (view *MainView) RemoveRoom(room string) {
} else {
removeIndex = sort.StringSlice(view.roomIDs).Search(room)
}
- view.roomList.Remove(roomView.Room)
+ view.roomList.Remove(roomView.MxRoom())
view.roomIDs = append(view.roomIDs[:removeIndex], view.roomIDs[removeIndex+1:]...)
view.roomView.RemovePage(room)
delete(view.rooms, room)
@@ -363,7 +369,7 @@ func (view *MainView) SetRooms(rooms []string) {
view.roomIDs = rooms
view.roomList.Clear()
view.roomView.Clear()
- view.rooms = make(map[string]*widget.RoomView)
+ view.rooms = make(map[string]*RoomView)
for index, room := range rooms {
view.addRoom(index, room)
}
@@ -385,14 +391,14 @@ func sendNotification(room *rooms.Room, sender, text string, critical, sound boo
notification.Send(sender, text, critical, sound)
}
-func (view *MainView) NotifyMessage(room *rooms.Room, message *types.Message, should pushrules.PushActionArrayShould) {
+func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) {
// Whether or not the room where the message came is the currently shown room.
isCurrent := room.ID == view.CurrentRoomID()
// Whether or not the terminal window is focused.
isFocused := view.lastFocusTime.Add(30 * time.Second).Before(time.Now())
// Whether or not the push rules say this message should be notified about.
- shouldNotify := (should.Notify || !should.NotifySpecified) && message.Sender != view.config.Session.UserID
+ shouldNotify := (should.Notify || !should.NotifySpecified) && message.Sender() != view.config.Session.UserID
if !isCurrent {
// The message is not in the current room, show new message status in room list.
@@ -406,21 +412,10 @@ func (view *MainView) NotifyMessage(room *rooms.Room, message *types.Message, sh
if shouldNotify && !isFocused {
// Push rules say notify and the terminal is not focused, send desktop notification.
shouldPlaySound := should.PlaySound && should.SoundName == "default"
- sendNotification(room, message.Sender, message.Text, should.Highlight, shouldPlaySound)
+ sendNotification(room, message.Sender(), message.NotificationContent(), should.Highlight, shouldPlaySound)
}
- if should.Highlight {
- // Message is highlight, set color.
- message.TextColor = tcell.ColorYellow
- }
-}
-
-func (view *MainView) AddServiceMessage(roomView *widget.RoomView, text string) {
- message := roomView.NewMessage("", "*", "gomuks.service", text, time.Now())
- message.TextColor = tcell.ColorGray
- message.SenderColor = tcell.ColorGray
- roomView.AddMessage(message, widget.AppendMessage)
- view.parent.Render()
+ message.SetIsHighlight(should.Highlight)
}
func (view *MainView) LoadHistory(room string, initial bool) {
@@ -452,20 +447,15 @@ func (view *MainView) LoadHistory(room string, initial bool) {
}
history, prevBatch, err := view.matrix.GetHistory(roomView.Room.ID, batch, 50)
if err != nil {
- view.AddServiceMessage(roomView, "Failed to fetch history")
+ roomView.AddServiceMessage("Failed to fetch history")
debug.Print("Failed to fetch history for", roomView.Room.ID, err)
return
}
roomView.Room.PrevBatch = prevBatch
for _, evt := range history {
- var message *types.Message
- if evt.Type == "m.room.message" {
- message = view.ProcessMessageEvent(roomView, &evt)
- } else if evt.Type == "m.room.member" {
- message = view.ProcessMembershipEvent(roomView, &evt)
- }
+ message := view.ParseEvent(roomView, &evt)
if message != nil {
- roomView.AddMessage(message, widget.PrependMessage)
+ roomView.AddMessage(message, ifc.PrependMessage)
}
}
err = roomView.SaveHistory(view.config.HistoryDir)
@@ -476,63 +466,6 @@ func (view *MainView) LoadHistory(room string, initial bool) {
view.parent.Render()
}
-func (view *MainView) ProcessMessageEvent(room *widget.RoomView, evt *gomatrix.Event) (message *types.Message) {
- text, _ := evt.Content["body"].(string)
- msgtype, _ := evt.Content["msgtype"].(string)
- return room.NewMessage(evt.ID, evt.Sender, msgtype, text, unixToTime(evt.Timestamp))
-}
-
-func (view *MainView) getMembershipEventContent(evt *gomatrix.Event) (sender, text string) {
- 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 = fmt.Sprintf("%s invited %s.", evt.Sender, displayname)
- case "join":
- sender = "-->"
- text = fmt.Sprintf("%s joined the room.", displayname)
- case "leave":
- sender = "<--"
- if evt.Sender != *evt.StateKey {
- reason, _ := evt.Content["reason"].(string)
- text = fmt.Sprintf("%s kicked %s: %s", evt.Sender, displayname, reason)
- } else {
- text = fmt.Sprintf("%s left the room.", displayname)
- }
- }
- } else if displayname != prevDisplayname {
- sender = "---"
- text = fmt.Sprintf("%s changed their display name to %s.", prevDisplayname, displayname)
- }
- return
-}
-
-func (view *MainView) ProcessMembershipEvent(room *widget.RoomView, evt *gomatrix.Event) (message *types.Message) {
- sender, text := view.getMembershipEventContent(evt)
- if len(text) == 0 {
- return
- }
- message = room.NewMessage(evt.ID, sender, "m.room.member", text, unixToTime(evt.Timestamp))
- message.TextColor = tcell.ColorGreen
- return
-}
-
-func unixToTime(unix int64) time.Time {
- timestamp := time.Now()
- if unix != 0 {
- timestamp = time.Unix(unix/1000, unix%1000*1000)
- }
- return timestamp
+func (view *MainView) ParseEvent(roomView ifc.RoomView, evt *gomatrix.Event) ifc.Message {
+ return parser.ParseEvent(view.gmx, roomView.MxRoom(), evt)
}
diff --git a/ui/widget/advanced-inputfield.go b/ui/widget/advanced-inputfield.go
index f74ce29..7e01478 100644
--- a/ui/widget/advanced-inputfield.go
+++ b/ui/widget/advanced-inputfield.go
@@ -24,7 +24,7 @@ import (
"strings"
"unicode/utf8"
- "github.com/gdamore/tcell"
+ "maunium.net/go/tcell"
"github.com/mattn/go-runewidth"
"github.com/zyedidia/clipboard"
"maunium.net/go/tview"
diff --git a/ui/widget/border.go b/ui/widget/border.go
index 7c42f3d..b3eb65d 100644
--- a/ui/widget/border.go
+++ b/ui/widget/border.go
@@ -17,7 +17,7 @@
package widget
import (
- "github.com/gdamore/tcell"
+ "maunium.net/go/tcell"
"maunium.net/go/tview"
)
diff --git a/ui/widget/color.go b/ui/widget/color.go
index 12ee791..c4f1abf 100644
--- a/ui/widget/color.go
+++ b/ui/widget/color.go
@@ -21,7 +21,7 @@ import (
"hash/fnv"
"sort"
- "github.com/gdamore/tcell"
+ "maunium.net/go/tcell"
)
var colorNames []string
diff --git a/ui/widget/message-view.go b/ui/widget/message-view.go
deleted file mode 100644
index f0bdbad..0000000
--- a/ui/widget/message-view.go
+++ /dev/null
@@ -1,354 +0,0 @@
-// 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 widget
-
-import (
- "encoding/gob"
- "fmt"
- "math"
- "os"
- "time"
-
- "github.com/gdamore/tcell"
- "maunium.net/go/gomuks/ui/debug"
- "maunium.net/go/gomuks/ui/types"
- "maunium.net/go/tview"
-)
-
-type MessageView struct {
- *tview.Box
-
- ScrollOffset int
- MaxSenderWidth int
- DateFormat string
- TimestampFormat string
- TimestampWidth int
- LoadingMessages bool
-
- widestSender int
- prevWidth int
- prevHeight int
- prevMsgCount int
-
- messageIDs map[string]*types.Message
- messages []*types.Message
-
- textBuffer []string
- metaBuffer []types.MessageMeta
-}
-
-func NewMessageView() *MessageView {
- return &MessageView{
- Box: tview.NewBox(),
- MaxSenderWidth: 15,
- DateFormat: "January _2, 2006",
- TimestampFormat: "15:04:05",
- TimestampWidth: 8,
- ScrollOffset: 0,
-
- messages: make([]*types.Message, 0),
- messageIDs: make(map[string]*types.Message),
- textBuffer: make([]string, 0),
- metaBuffer: make([]types.MessageMeta, 0),
-
- widestSender: 5,
- prevWidth: -1,
- prevHeight: -1,
- prevMsgCount: -1,
- }
-}
-
-func (view *MessageView) NewMessage(id, sender, msgtype, text string, timestamp time.Time) *types.Message {
- return types.NewMessage(id, sender, msgtype, text,
- timestamp.Format(view.TimestampFormat),
- timestamp.Format(view.DateFormat),
- GetHashColor(sender))
-}
-
-func (view *MessageView) SaveHistory(path string) error {
- file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600)
- if err != nil {
- return err
- }
- defer file.Close()
-
- enc := gob.NewEncoder(file)
- err = enc.Encode(view.messages)
- if err != nil {
- return err
- }
-
- return nil
-}
-
-func (view *MessageView) LoadHistory(path string) (int, error) {
- file, err := os.OpenFile(path, os.O_RDONLY, 0600)
- if err != nil {
- if os.IsNotExist(err) {
- return 0, nil
- }
- return -1, err
- }
- defer file.Close()
-
- dec := gob.NewDecoder(file)
- err = dec.Decode(&view.messages)
- if err != nil {
- return -1, err
- }
-
- for _, message := range view.messages {
- view.updateWidestSender(message.Sender)
- }
-
- return len(view.messages), nil
-}
-
-func (view *MessageView) updateWidestSender(sender string) {
- if len(sender) > view.widestSender {
- view.widestSender = len(sender)
- if view.widestSender > view.MaxSenderWidth {
- view.widestSender = view.MaxSenderWidth
- }
- }
-}
-
-type MessageDirection int
-
-const (
- AppendMessage MessageDirection = iota
- PrependMessage
- IgnoreMessage
-)
-
-func (view *MessageView) UpdateMessageID(message *types.Message, newID string) {
- delete(view.messageIDs, message.ID)
- message.ID = newID
- view.messageIDs[message.ID] = message
-}
-
-func (view *MessageView) AddMessage(message *types.Message, direction MessageDirection) {
- if message == nil {
- return
- }
-
- msg, messageExists := view.messageIDs[message.ID]
- if msg != nil && messageExists {
- message.CopyTo(msg)
- message = msg
- direction = IgnoreMessage
- }
-
- view.updateWidestSender(message.Sender)
-
- _, _, width, _ := view.GetInnerRect()
- width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap
- message.CalculateBuffer(width)
-
- if direction == AppendMessage {
- if view.ScrollOffset > 0 {
- view.ScrollOffset += message.Height()
- }
- view.messages = append(view.messages, message)
- view.appendBuffer(message)
- } else if direction == PrependMessage {
- view.messages = append([]*types.Message{message}, view.messages...)
- }
-
- view.messageIDs[message.ID] = message
-}
-
-func (view *MessageView) appendBuffer(message *types.Message) {
- if len(view.metaBuffer) > 0 {
- prevMeta := view.metaBuffer[len(view.metaBuffer)-1]
- if prevMeta != nil && prevMeta.GetDate() != message.Date {
- view.textBuffer = append(view.textBuffer, fmt.Sprintf("Date changed to %s", message.Date))
- view.metaBuffer = append(view.metaBuffer, &types.BasicMeta{TextColor: tcell.ColorGreen})
- }
- }
-
- view.textBuffer = append(view.textBuffer, message.Buffer()...)
- for range message.Buffer() {
- view.metaBuffer = append(view.metaBuffer, message)
- }
- view.prevMsgCount++
-}
-
-func (view *MessageView) recalculateBuffers() {
- _, _, width, height := view.GetInnerRect()
-
- width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap
- recalculateMessageBuffers := width != view.prevWidth
- if height != view.prevHeight || recalculateMessageBuffers || len(view.messages) != view.prevMsgCount {
- view.textBuffer = []string{}
- view.metaBuffer = []types.MessageMeta{}
- view.prevMsgCount = 0
- for _, message := range view.messages {
- if recalculateMessageBuffers {
- message.CalculateBuffer(width)
- }
- view.appendBuffer(message)
- }
- view.prevHeight = height
- view.prevWidth = width
- }
-}
-
-const PaddingAtTop = 5
-
-func (view *MessageView) AddScrollOffset(diff int) {
- _, _, _, height := view.GetInnerRect()
-
- totalHeight := len(view.textBuffer)
- if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop {
- view.ScrollOffset = totalHeight - height + PaddingAtTop
- } else {
- view.ScrollOffset += diff
- }
-
- if view.ScrollOffset > totalHeight-height+PaddingAtTop {
- view.ScrollOffset = totalHeight - height + PaddingAtTop
- }
- if view.ScrollOffset < 0 {
- view.ScrollOffset = 0
- }
-}
-
-func (view *MessageView) Height() int {
- _, _, _, height := view.GetInnerRect()
- return height
-}
-
-func (view *MessageView) TotalHeight() int {
- return len(view.textBuffer)
-}
-
-func (view *MessageView) IsAtTop() bool {
- _, _, _, height := view.GetInnerRect()
- totalHeight := len(view.textBuffer)
- return view.ScrollOffset >= totalHeight-height+PaddingAtTop
-}
-
-const (
- TimestampSenderGap = 1
- SenderSeparatorGap = 1
- SenderMessageGap = 3
-)
-
-func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) {
- char = '│'
- style = tcell.StyleDefault
- if scrollbarHere {
- style = style.Foreground(tcell.ColorGreen)
- }
- if isTop {
- if scrollbarHere {
- char = '╥'
- } else {
- char = '┬'
- }
- } else if isBottom {
- if scrollbarHere {
- char = '╨'
- } else {
- char = '┴'
- }
- } else if scrollbarHere {
- char = '║'
- }
- return
-}
-
-func (view *MessageView) Draw(screen tcell.Screen) {
- view.Box.Draw(screen)
-
- x, y, _, height := view.GetInnerRect()
- view.recalculateBuffers()
-
- if len(view.textBuffer) == 0 {
- writeLineSimple(screen, "It's quite empty in here.", x, y+height)
- return
- }
-
- usernameX := x + view.TimestampWidth + TimestampSenderGap
- messageX := usernameX + view.widestSender + SenderMessageGap
- separatorX := usernameX + view.widestSender + SenderSeparatorGap
-
- indexOffset := len(view.textBuffer) - view.ScrollOffset - height
- if indexOffset <= -PaddingAtTop {
- message := "Scroll up to load more messages."
- if view.LoadingMessages {
- message = "Loading more messages..."
- }
- writeLineSimpleColor(screen, message, messageX, y, tcell.ColorGreen)
- }
-
- if len(view.textBuffer) != len(view.metaBuffer) {
- debug.ExtPrintf("Unexpected text/meta buffer length mismatch: %d != %d.", len(view.textBuffer), len(view.metaBuffer))
- return
- }
-
- var scrollBarHeight, scrollBarPos int
- // Black magic (aka math) used to figure out where the scroll bar should be put.
- {
- viewportHeight := float64(height)
- contentHeight := float64(len(view.textBuffer))
-
- scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight)))
-
- scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight))
- }
-
- var prevMeta types.MessageMeta
- firstLine := true
- skippedLines := 0
-
- for line := 0; line < height; line++ {
- index := indexOffset + line
- if index < 0 {
- skippedLines++
- continue
- } else if index >= len(view.textBuffer) {
- break
- }
-
- showScrollbar := line-skippedLines >= scrollBarPos-scrollBarHeight && line-skippedLines < scrollBarPos
- isTop := firstLine && view.ScrollOffset+height >= len(view.textBuffer)
- isBottom := line == height-1 && view.ScrollOffset == 0
-
- borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom)
-
- firstLine = false
-
- screen.SetContent(separatorX, y+line, borderChar, nil, borderStyle)
-
- text, meta := view.textBuffer[index], view.metaBuffer[index]
- if meta != prevMeta {
- if len(meta.GetTimestamp()) > 0 {
- writeLineSimpleColor(screen, meta.GetTimestamp(), x, y+line, meta.GetTimestampColor())
- }
- if prevMeta == nil || meta.GetSender() != prevMeta.GetSender() {
- writeLineColor(
- screen, tview.AlignRight, meta.GetSender(),
- usernameX, y+line, view.widestSender,
- meta.GetSenderColor())
- }
- prevMeta = meta
- }
- writeLineSimpleColor(screen, text, messageX, y+line, meta.GetTextColor())
- }
-}
diff --git a/ui/widget/util.go b/ui/widget/util.go
index 0888210..920afad 100644
--- a/ui/widget/util.go
+++ b/ui/widget/util.go
@@ -17,24 +17,24 @@
package widget
import (
- "github.com/gdamore/tcell"
+ "maunium.net/go/tcell"
"github.com/mattn/go-runewidth"
"maunium.net/go/tview"
)
-func writeLineSimple(screen tcell.Screen, line string, x, y int) {
- writeLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
+func WriteLineSimple(screen tcell.Screen, line string, x, y int) {
+ WriteLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
}
-func writeLineSimpleColor(screen tcell.Screen, line string, x, y int, color tcell.Color) {
- writeLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color))
+func WriteLineSimpleColor(screen tcell.Screen, line string, x, y int, color tcell.Color) {
+ WriteLine(screen, tview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color))
}
-func writeLineColor(screen tcell.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) {
- writeLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color))
+func WriteLineColor(screen tcell.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) {
+ WriteLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color))
}
-func writeLine(screen tcell.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
+func WriteLine(screen tcell.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
offsetX := 0
if align == tview.AlignRight {
offsetX = maxWidth - runewidth.StringWidth(line)