From 72945c9a284b6858594f1e8a43743c397e90c380 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 18 Mar 2018 21:24:03 +0200 Subject: Organize files --- advanced-inputfield.go | 445 --------------------------------------- border.go | 44 ---- config.go | 80 ------- config/config.go | 77 +++++++ config/session.go | 125 +++++++++++ debug.go | 75 ------- go.mod | 14 -- gomuks.go | 60 +++--- interface/gomuks.go | 35 +++ interface/matrix.go | 40 ++++ interface/ui.go | 54 +++++ matrix.go | 263 ----------------------- matrix/matrix.go | 284 +++++++++++++++++++++++++ matrix/room/member.go | 51 +++++ matrix/room/room.go | 145 +++++++++++++ matrix/sync.go | 126 +++++++++++ message-view.go | 350 ------------------------------ room-view.go | 179 ---------------- room.go | 175 --------------- session.go | 126 ----------- sync.go | 125 ----------- ui.go | 76 ------- ui/debug/debug.go | 83 ++++++++ ui/types/message.go | 85 ++++++++ ui/ui.go | 65 ++++++ ui/view-login.go | 53 +++++ ui/view-main.go | 387 ++++++++++++++++++++++++++++++++++ ui/widget/advanced-inputfield.go | 445 +++++++++++++++++++++++++++++++++++++++ ui/widget/border.go | 44 ++++ ui/widget/center.go | 32 +++ ui/widget/color.go | 60 ++++++ ui/widget/form-text-view.go | 44 ++++ ui/widget/message-view.go | 293 ++++++++++++++++++++++++++ ui/widget/room-view.go | 152 +++++++++++++ uiutil.go | 55 ----- view-login.go | 50 ----- view-main.go | 385 --------------------------------- 37 files changed, 2705 insertions(+), 2477 deletions(-) delete mode 100644 advanced-inputfield.go delete mode 100644 border.go delete mode 100644 config.go create mode 100644 config/config.go create mode 100644 config/session.go delete mode 100644 debug.go delete mode 100644 go.mod create mode 100644 interface/gomuks.go create mode 100644 interface/matrix.go create mode 100644 interface/ui.go delete mode 100644 matrix.go create mode 100644 matrix/matrix.go create mode 100644 matrix/room/member.go create mode 100644 matrix/room/room.go create mode 100644 matrix/sync.go delete mode 100644 message-view.go delete mode 100644 room-view.go delete mode 100644 room.go delete mode 100644 session.go delete mode 100644 sync.go delete mode 100644 ui.go create mode 100644 ui/debug/debug.go create mode 100644 ui/types/message.go create mode 100644 ui/ui.go create mode 100644 ui/view-login.go create mode 100644 ui/view-main.go create mode 100644 ui/widget/advanced-inputfield.go create mode 100644 ui/widget/border.go create mode 100644 ui/widget/center.go create mode 100644 ui/widget/color.go create mode 100644 ui/widget/form-text-view.go create mode 100644 ui/widget/message-view.go create mode 100644 ui/widget/room-view.go delete mode 100644 uiutil.go delete mode 100644 view-login.go delete mode 100644 view-main.go diff --git a/advanced-inputfield.go b/advanced-inputfield.go deleted file mode 100644 index 8b5b47a..0000000 --- a/advanced-inputfield.go +++ /dev/null @@ -1,445 +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 . - -package main - -import ( - "math" - "regexp" - "strings" - "unicode/utf8" - - "github.com/gdamore/tcell" - "github.com/mattn/go-runewidth" - "github.com/zyedidia/clipboard" - "maunium.net/go/tview" -) - -// AdvancedInputField is a multi-line user-editable text area. -// -// Use SetMaskCharacter() to hide input from onlookers (e.g. for password -// input). -type AdvancedInputField struct { - *tview.Box - - // Cursor position - cursorOffset int - viewOffset int - - // The text that was entered. - text string - - // The text to be displayed before the input area. - label string - - // The text to be displayed in the input area when "text" is empty. - placeholder string - - // The label color. - labelColor tcell.Color - - // The background color of the input area. - fieldBackgroundColor tcell.Color - - // The text color of the input area. - fieldTextColor tcell.Color - - // The text color of the placeholder. - placeholderTextColor tcell.Color - - // The screen width of the input area. A value of 0 means extend as much as - // possible. - fieldWidth int - - // A character to mask entered text (useful for password fields). A value of 0 - // disables masking. - maskCharacter rune - - // An optional function which may reject the last character that was entered. - accept func(text string, ch rune) bool - - // An optional function which is called when the input has changed. - changed func(text string) - - // An optional function which is called when the user indicated that they - // are done entering text. The key which was pressed is provided (enter or escape). - done func(tcell.Key) - - // An optional function which is called when the user presses tab. - tabComplete func(text string, cursorOffset int) string -} - -// NewAdvancedInputField returns a new input field. -func NewAdvancedInputField() *AdvancedInputField { - return &AdvancedInputField{ - Box: tview.NewBox(), - labelColor: tview.Styles.SecondaryTextColor, - fieldBackgroundColor: tview.Styles.ContrastBackgroundColor, - fieldTextColor: tview.Styles.PrimaryTextColor, - placeholderTextColor: tview.Styles.ContrastSecondaryTextColor, - } -} - -// SetText sets the current text of the input field. -func (field *AdvancedInputField) SetText(text string) *AdvancedInputField { - field.text = text - if field.changed != nil { - field.changed(text) - } - return field -} - -// GetText returns the current text of the input field. -func (field *AdvancedInputField) GetText() string { - return field.text -} - -// SetLabel sets the text to be displayed before the input area. -func (field *AdvancedInputField) SetLabel(label string) *AdvancedInputField { - field.label = label - return field -} - -// GetLabel returns the text to be displayed before the input area. -func (field *AdvancedInputField) GetLabel() string { - return field.label -} - -// SetPlaceholder sets the text to be displayed when the input text is empty. -func (field *AdvancedInputField) SetPlaceholder(text string) *AdvancedInputField { - field.placeholder = text - return field -} - -// SetLabelColor sets the color of the label. -func (field *AdvancedInputField) SetLabelColor(color tcell.Color) *AdvancedInputField { - field.labelColor = color - return field -} - -// SetFieldBackgroundColor sets the background color of the input area. -func (field *AdvancedInputField) SetFieldBackgroundColor(color tcell.Color) *AdvancedInputField { - field.fieldBackgroundColor = color - return field -} - -// SetFieldTextColor sets the text color of the input area. -func (field *AdvancedInputField) SetFieldTextColor(color tcell.Color) *AdvancedInputField { - field.fieldTextColor = color - return field -} - -// SetPlaceholderExtColor sets the text color of placeholder text. -func (field *AdvancedInputField) SetPlaceholderExtColor(color tcell.Color) *AdvancedInputField { - field.placeholderTextColor = color - return field -} - -// SetFormAttributes sets attributes shared by all form items. -func (field *AdvancedInputField) SetFormAttributes(label string, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { - field.label = label - field.labelColor = labelColor - field.SetBackgroundColor(bgColor) - field.fieldTextColor = fieldTextColor - field.fieldBackgroundColor = fieldBgColor - return field -} - -// SetFieldWidth sets the screen width of the input area. A value of 0 means -// extend as much as possible. -func (field *AdvancedInputField) SetFieldWidth(width int) *AdvancedInputField { - field.fieldWidth = width - return field -} - -// GetFieldWidth returns this primitive's field width. -func (field *AdvancedInputField) GetFieldWidth() int { - return field.fieldWidth -} - -// SetMaskCharacter sets a character that masks user input on a screen. A value -// of 0 disables masking. -func (field *AdvancedInputField) SetMaskCharacter(mask rune) *AdvancedInputField { - field.maskCharacter = mask - return field -} - -// SetAcceptanceFunc sets a handler which may reject the last character that was -// entered (by returning false). -// -// This package defines a number of variables Prefixed with AdvancedInputField which may -// be used for common input (e.g. numbers, maximum text length). -func (field *AdvancedInputField) SetAcceptanceFunc(handler func(textToCheck string, lastChar rune) bool) *AdvancedInputField { - field.accept = handler - return field -} - -// SetChangedFunc sets a handler which is called whenever the text of the input -// field has changed. It receives the current text (after the change). -func (field *AdvancedInputField) SetChangedFunc(handler func(text string)) *AdvancedInputField { - field.changed = handler - return field -} - -// SetDoneFunc sets a handler which is called when the user is done entering -// text. The callback function is provided with the key that was pressed, which -// is one of the following: -// -// - KeyEnter: Done entering text. -// - KeyEscape: Abort text input. -func (field *AdvancedInputField) SetDoneFunc(handler func(key tcell.Key)) *AdvancedInputField { - field.done = handler - return field -} - -func (field *AdvancedInputField) SetTabCompleteFunc(handler func(text string, cursorOffset int) string) *AdvancedInputField { - field.tabComplete = handler - return field -} - -// SetFinishedFunc calls SetDoneFunc(). -func (field *AdvancedInputField) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem { - return field.SetDoneFunc(handler) -} - -// Draw draws this primitive onto the screen. -func (field *AdvancedInputField) Draw(screen tcell.Screen) { - field.Box.Draw(screen) - - // Prepare - x, y, width, height := field.GetInnerRect() - rightLimit := x + width - if height < 1 || rightLimit <= x { - return - } - - // Draw label. - _, drawnWidth := tview.Print(screen, field.label, x, y, rightLimit-x, tview.AlignLeft, field.labelColor) - x += drawnWidth - - // Draw input area. - fieldWidth := field.fieldWidth - if fieldWidth == 0 { - fieldWidth = math.MaxInt32 - } - if rightLimit-x < fieldWidth { - fieldWidth = rightLimit - x - } - fieldStyle := tcell.StyleDefault.Background(field.fieldBackgroundColor) - for index := 0; index < fieldWidth; index++ { - screen.SetContent(x+index, y, ' ', nil, fieldStyle) - } - - text := field.text - if text == "" && field.placeholder != "" { - tview.Print(screen, field.placeholder, x, y, fieldWidth, tview.AlignLeft, field.placeholderTextColor) - } - - // Draw entered text. - if field.maskCharacter > 0 { - text = strings.Repeat(string(field.maskCharacter), utf8.RuneCountInString(field.text)) - } - textWidth := runewidth.StringWidth(text) - if field.cursorOffset >= textWidth { - fieldWidth-- - } - - // Recalculate view offset - if field.cursorOffset < field.viewOffset { - field.viewOffset = field.cursorOffset - } else if field.cursorOffset > field.viewOffset+fieldWidth { - field.viewOffset = field.cursorOffset - fieldWidth - } else if textWidth-field.viewOffset < fieldWidth { - field.viewOffset = textWidth - fieldWidth - } - // Make sure view offset didn't become negative - if field.viewOffset < 0 { - field.viewOffset = 0 - } - - // Draw entered text. - runes := []rune(text) - relPos := 0 - for pos := field.viewOffset; pos <= fieldWidth+field.viewOffset && pos < len(runes); pos++ { - ch := runes[pos] - w := runewidth.RuneWidth(ch) - _, _, style, _ := screen.GetContent(x+relPos, y) - style = style.Foreground(field.fieldTextColor) - for w > 0 { - screen.SetContent(x+relPos, y, ch, nil, style) - relPos++ - w-- - } - } - - // Set cursor. - if field.GetFocusable().HasFocus() { - field.setCursor(screen) - } -} - -func (field *AdvancedInputField) GetCursorOffset() int { - return field.cursorOffset -} - -func (field *AdvancedInputField) SetCursorOffset(offset int) *AdvancedInputField { - if offset < 0 { - offset = 0 - } else { - width := runewidth.StringWidth(field.text) - if offset >= width { - offset = width - } - } - field.cursorOffset = offset - return field -} - -// setCursor sets the cursor position. -func (field *AdvancedInputField) setCursor(screen tcell.Screen) { - x, y, width, _ := field.GetRect() - origX, origY := x, y - rightLimit := x + width - if field.HasBorder() { - x++ - y++ - rightLimit -= 2 - } - fieldWidth := runewidth.StringWidth(field.text) - if field.fieldWidth > 0 && fieldWidth > field.fieldWidth-1 { - fieldWidth = field.fieldWidth - 1 - } - x = x + tview.StringWidth(field.label) + field.cursorOffset - field.viewOffset - if x >= rightLimit { - x = rightLimit - 1 - } else if x < origX { - x = origY - } - screen.ShowCursor(x, y) -} - -var ( - lastWord = regexp.MustCompile(`\S+\s*$`) - firstWord = regexp.MustCompile(`^\s*\S+`) -) - -func SubstringBefore(s string, w int) string { - return runewidth.Truncate(s, w, "") -} - -// InputHandler returns the handler for this primitive. -func (field *AdvancedInputField) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { - return field.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { - currentText := field.text - defer func() { - // Trigger changed events. - if field.text != currentText && field.changed != nil { - field.changed(field.text) - } - // Make sure cursor offset is valid - if field.cursorOffset < 0 { - field.cursorOffset = 0 - } - width := runewidth.StringWidth(field.text) - if field.cursorOffset > width { - field.cursorOffset = width - } - }() - - // Process key event. - switch key := event.Key(); key { - case tcell.KeyRune: // Regular character. - leftPart := SubstringBefore(field.text, field.cursorOffset) - newText := leftPart + string(event.Rune()) + field.text[len(leftPart):] - if field.accept != nil { - if !field.accept(newText, event.Rune()) { - break - } - } - field.text = newText - field.cursorOffset += runewidth.RuneWidth(event.Rune()) - case tcell.KeyCtrlV: - clip, _ := clipboard.ReadAll("clipboard") - leftPart := SubstringBefore(field.text, field.cursorOffset) - field.text = leftPart + clip + field.text[len(leftPart):] - field.cursorOffset += runewidth.StringWidth(clip) - case tcell.KeyLeft: // Move cursor left. - before := SubstringBefore(field.text, field.cursorOffset) - if event.Modifiers() == tcell.ModCtrl { - found := lastWord.FindString(before) - field.cursorOffset -= runewidth.StringWidth(found) - } else if len(before) > 0 { - beforeRunes := []rune(before) - char := beforeRunes[len(beforeRunes)-1] - field.cursorOffset -= runewidth.RuneWidth(char) - } - case tcell.KeyRight: // Move cursor right. - before := SubstringBefore(field.text, field.cursorOffset) - after := field.text[len(before):] - if event.Modifiers() == tcell.ModCtrl { - found := firstWord.FindString(after) - field.cursorOffset += runewidth.StringWidth(found) - } else if len(after) > 0 { - char := []rune(after)[0] - field.cursorOffset += runewidth.RuneWidth(char) - } - case tcell.KeyDelete: // Delete next character. - if field.cursorOffset >= runewidth.StringWidth(field.text) { - break - } - leftPart := SubstringBefore(field.text, field.cursorOffset) - rightPart := field.text[len(leftPart):] - rightPartRunes := []rune(rightPart) - rightPartRunes = rightPartRunes[1:] - rightPart = string(rightPartRunes) - field.text = leftPart + rightPart - case tcell.KeyBackspace, tcell.KeyBackspace2: // Delete last character. - if field.cursorOffset == 0 { - break - } - if key == tcell.KeyBackspace { // Ctrl+backspace - leftPart := SubstringBefore(field.text, field.cursorOffset) - rightPart := field.text[len(leftPart):] - replacement := lastWord.ReplaceAllString(leftPart, "") - field.text = replacement + rightPart - - field.cursorOffset -= runewidth.StringWidth(leftPart) - runewidth.StringWidth(replacement) - } else { // Just backspace - leftPart := SubstringBefore(field.text, field.cursorOffset) - rightPart := field.text[len(leftPart):] - leftPartRunes := []rune(leftPart) - leftPartRunes = leftPartRunes[0 : len(leftPartRunes)-1] - leftPart = string(leftPartRunes) - removedChar := field.text[len(leftPart) : len(field.text)-len(rightPart)] - field.text = leftPart + rightPart - field.cursorOffset -= runewidth.StringWidth(removedChar) - } - case tcell.KeyTab: // Tab-completion - if field.tabComplete != nil { - oldWidth := runewidth.StringWidth(field.text) - field.text = field.tabComplete(field.text, field.cursorOffset) - newWidth := runewidth.StringWidth(field.text) - if oldWidth != newWidth { - field.cursorOffset += newWidth - oldWidth - } - } - case tcell.KeyEnter, tcell.KeyEscape: // We're done. - if field.done != nil { - field.done(key) - } - } - }) -} diff --git a/border.go b/border.go deleted file mode 100644 index cd0b8a1..0000000 --- a/border.go +++ /dev/null @@ -1,44 +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 . - -package main - -import ( - "github.com/gdamore/tcell" - "maunium.net/go/tview" -) - -type Border struct { - *tview.Box -} - -func NewBorder() *Border { - return &Border{tview.NewBox()} -} - -func (border *Border) Draw(screen tcell.Screen) { - background := tcell.StyleDefault.Background(border.GetBackgroundColor()).Foreground(border.GetBorderColor()) - x, y, width, height := border.GetRect() - if width == 1 { - for borderY := y; borderY < y+height; borderY++ { - screen.SetContent(x, borderY, tview.GraphicsVertBar, nil, background) - } - } else if height == 1 { - for borderX := x; borderX < x+width; borderX++ { - screen.SetContent(borderX, y, tview.GraphicsHoriBar, nil, background) - } - } -} diff --git a/config.go b/config.go deleted file mode 100644 index f8696a4..0000000 --- a/config.go +++ /dev/null @@ -1,80 +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 . - -package main - -import ( - "fmt" - "io/ioutil" - "os" - "path/filepath" - - "gopkg.in/yaml.v2" -) - -type Config struct { - MXID string `yaml:"mxid"` - HS string `yaml:"homeserver"` - - dir string `yaml:"-"` - gmx Gomuks `yaml:"-"` - debug DebugPrinter `yaml:"-"` - Session *Session `yaml:"-"` -} - -func NewConfig(gmx Gomuks, dir string) *Config { - return &Config{ - gmx: gmx, - debug: gmx.Debug(), - dir: dir, - } -} - -func (config *Config) Load() { - os.MkdirAll(config.dir, 0700) - configPath := filepath.Join(config.dir, "config.yaml") - data, err := ioutil.ReadFile(configPath) - if err != nil { - if os.IsNotExist(err) { - return - } else { - fmt.Println("Failed to read config from", configPath) - panic(err) - } - } - - err = yaml.Unmarshal(data, &config) - if err != nil { - fmt.Println("Failed to parse config at", configPath) - panic(err) - } -} - -func (config *Config) Save() { - os.MkdirAll(config.dir, 0700) - data, err := yaml.Marshal(&config) - if err != nil { - config.debug.Print("Failed to marshal config") - panic(err) - } - - path := filepath.Join(config.dir, "config.yaml") - err = ioutil.WriteFile(path, data, 0600) - if err != nil { - config.debug.Print("Failed to write config to", path) - panic(err) - } -} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d4ede80 --- /dev/null +++ b/config/config.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 . + +package config + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "gopkg.in/yaml.v2" + "maunium.net/go/gomuks/ui/debug" +) + +type Config struct { + MXID string `yaml:"mxid"` + HS string `yaml:"homeserver"` + + dir string `yaml:"-"` + Session *Session `yaml:"-"` +} + +func NewConfig(dir string) *Config { + return &Config{ + dir: dir, + } +} + +func (config *Config) Load() { + os.MkdirAll(config.dir, 0700) + configPath := filepath.Join(config.dir, "config.yaml") + data, err := ioutil.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return + } else { + fmt.Println("Failed to read config from", configPath) + panic(err) + } + } + + err = yaml.Unmarshal(data, &config) + if err != nil { + fmt.Println("Failed to parse config at", configPath) + panic(err) + } +} + +func (config *Config) Save() { + os.MkdirAll(config.dir, 0700) + data, err := yaml.Marshal(&config) + if err != nil { + debug.Print("Failed to marshal config") + panic(err) + } + + path := filepath.Join(config.dir, "config.yaml") + err = ioutil.WriteFile(path, data, 0600) + if err != nil { + debug.Print("Failed to write config to", path) + panic(err) + } +} diff --git a/config/session.go b/config/session.go new file mode 100644 index 0000000..a90fc20 --- /dev/null +++ b/config/session.go @@ -0,0 +1,125 @@ +// 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 . + +package config + +import ( + "encoding/json" + "io/ioutil" + "path/filepath" + + "maunium.net/go/gomatrix" + rooms "maunium.net/go/gomuks/matrix/room" + "maunium.net/go/gomuks/ui/debug" +) + +type Session struct { + MXID string `json:"-"` + path string `json:"-"` + AccessToken string + NextBatch string + FilterID string + Rooms map[string]*rooms.Room +} + +func (config *Config) LoadSession(mxid string) { + config.Session = config.NewSession(mxid) + config.Session.Load() +} + +func (config *Config) NewSession(mxid string) *Session { + return &Session{ + MXID: mxid, + path: filepath.Join(config.dir, mxid+".session"), + Rooms: make(map[string]*rooms.Room), + } +} + +func (s *Session) Clear() { + s.Rooms = make(map[string]*rooms.Room) + s.NextBatch = "" + s.FilterID = "" + s.Save() +} + +func (s *Session) Load() { + data, err := ioutil.ReadFile(s.path) + if err != nil { + debug.Print("Failed to read session from", s.path) + panic(err) + } + + err = json.Unmarshal(data, s) + if err != nil { + debug.Print("Failed to parse session at", s.path) + panic(err) + } +} + +func (s *Session) Save() { + data, err := json.Marshal(s) + if err != nil { + debug.Print("Failed to marshal session of", s.MXID) + panic(err) + } + + err = ioutil.WriteFile(s.path, data, 0600) + if err != nil { + debug.Print("Failed to write session to", s.path) + panic(err) + } +} + +func (s *Session) LoadFilterID(_ string) string { + return s.FilterID +} + +func (s *Session) LoadNextBatch(_ string) string { + return s.NextBatch +} + +func (s *Session) GetRoom(mxid string) *rooms.Room { + room, _ := s.Rooms[mxid] + if room == nil { + room = rooms.NewRoom(mxid) + s.Rooms[room.ID] = room + } + return room +} + +func (s *Session) PutRoom(room *rooms.Room) { + s.Rooms[room.ID] = room + s.Save() +} + +func (s *Session) SaveFilterID(_, filterID string) { + s.FilterID = filterID + s.Save() +} + +func (s *Session) SaveNextBatch(_, nextBatch string) { + s.NextBatch = nextBatch + s.Save() +} + +func (s *Session) LoadRoom(mxid string) *gomatrix.Room { + return s.GetRoom(mxid).Room +} + +func (s *Session) SaveRoom(room *gomatrix.Room) { + s.GetRoom(room.ID).Room = room + s.Save() +} diff --git a/debug.go b/debug.go deleted file mode 100644 index 15aac60..0000000 --- a/debug.go +++ /dev/null @@ -1,75 +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 . - -package main - -import ( - "fmt" - - "maunium.net/go/tview" -) - -const DebugPaneHeight = 35 - -type DebugPrinter interface { - Printf(text string, args ...interface{}) - Print(text ...interface{}) -} - -type DebugPane struct { - pane *tview.TextView - num int - gmx Gomuks -} - -func NewDebugPane(gmx Gomuks) *DebugPane { - pane := tview.NewTextView() - pane. - SetScrollable(true). - SetWrap(true) - pane.SetChangedFunc(func() { - gmx.App().Draw() - }) - pane.SetBorder(true).SetTitle("Debug output") - fmt.Fprintln(pane, "[0] Debug pane initialized") - - return &DebugPane{ - pane: pane, - num: 0, - gmx: gmx, - } -} - -func (db *DebugPane) Printf(text string, args ...interface{}) { - db.Write(fmt.Sprintf(text, args...) + "\n") -} - -func (db *DebugPane) Print(text ...interface{}) { - db.Write(fmt.Sprintln(text...)) -} - -func (db *DebugPane) Write(text string) { - if db.pane != nil { - db.num++ - fmt.Fprintf(db.pane, "[%d] %s", db.num, text) - } -} - -func (db *DebugPane) Wrap(main tview.Primitive) tview.Primitive { - return tview.NewGrid().SetRows(0, DebugPaneHeight).SetColumns(0). - AddItem(main, 0, 0, 1, 1, 1, 1, true). - AddItem(db.pane, 1, 0, 1, 1, 1, 1, false) -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 4888c39..0000000 --- a/go.mod +++ /dev/null @@ -1,14 +0,0 @@ -module "maunium.net/go/gomuks" - -require ( - "github.com/gdamore/encoding" v0.0.0-20151215212835-b23993cbb635 - "github.com/gdamore/tcell" v1.0.0 - "github.com/jroimartin/gocui" v0.0.0-20170827195011-4f518eddb04b - "github.com/lucasb-eyer/go-colorful" v0.0.0-20170903184257-231272389856 - "github.com/matrix-org/gomatrix" v0.0.0-20171003113848-a7fc80c8060c - "github.com/mattn/go-runewidth" v0.0.2 - "github.com/nsf/termbox-go" v0.0.0-20180303152453-e2050e41c884 - "github.com/rivo/tview" v0.0.0-20180313071706-0b69b9b58142 - "golang.org/x/text" v0.0.0-20171214130843-f21a4dfb5e38 - "gopkg.in/yaml.v2" v1.1.1-gopkgin-v2.1.1 -) diff --git a/gomuks.go b/gomuks.go index 5fa0a8a..fecb67a 100644 --- a/gomuks.go +++ b/gomuks.go @@ -21,43 +21,37 @@ import ( "path/filepath" "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/config" + "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" ) -type Gomuks interface { - Debug() DebugPrinter - Matrix() *gomatrix.Client - MatrixContainer() *MatrixContainer - App() *tview.Application - UI() *GomuksUI - Config() *Config - - Start() - Stop() - Recover() -} - type gomuks struct { app *tview.Application - ui *GomuksUI - matrix *MatrixContainer - debug *DebugPane - config *Config + ui *ui.GomuksUI + matrix *matrix.Container + debug *debug.Pane + config *config.Config } -var gdebug DebugPrinter - -func NewGomuks(debug bool) *gomuks { +func NewGomuks(enableDebug bool) *gomuks { configDir := filepath.Join(os.Getenv("HOME"), ".config/gomuks") gmx := &gomuks{ app: tview.NewApplication(), } - gmx.debug = NewDebugPane(gmx) - gdebug = gmx.debug - gmx.config = NewConfig(gmx, configDir) - gmx.ui = NewGomuksUI(gmx) - gmx.matrix = NewMatrixContainer(gmx) - gmx.ui.matrix = gmx.matrix + + 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.NewMatrixContainer(gmx) gmx.config.Load() if len(gmx.config.MXID) > 0 { @@ -67,7 +61,7 @@ func NewGomuks(debug bool) *gomuks { gmx.matrix.InitClient() main := gmx.ui.InitViews() - if debug { + if enableDebug { main = gmx.debug.Wrap(main) } gmx.app.SetRoot(main, true) @@ -101,15 +95,11 @@ func (gmx *gomuks) Start() { } } -func (gmx *gomuks) Debug() DebugPrinter { - return gmx.debug -} - func (gmx *gomuks) Matrix() *gomatrix.Client { - return gmx.matrix.client + return gmx.matrix.Client() } -func (gmx *gomuks) MatrixContainer() *MatrixContainer { +func (gmx *gomuks) MatrixContainer() ifc.MatrixContainer { return gmx.matrix } @@ -117,11 +107,11 @@ func (gmx *gomuks) App() *tview.Application { return gmx.app } -func (gmx *gomuks) Config() *Config { +func (gmx *gomuks) Config() *config.Config { return gmx.config } -func (gmx *gomuks) UI() *GomuksUI { +func (gmx *gomuks) UI() ifc.GomuksUI { return gmx.ui } diff --git a/interface/gomuks.go b/interface/gomuks.go new file mode 100644 index 0000000..b90aa88 --- /dev/null +++ b/interface/gomuks.go @@ -0,0 +1,35 @@ +// 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 . + +package ifc + +import ( + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/config" + "maunium.net/go/tview" +) + +type Gomuks interface { + Matrix() *gomatrix.Client + MatrixContainer() MatrixContainer + App() *tview.Application + UI() GomuksUI + Config() *config.Config + + Start() + Stop() + Recover() +} diff --git a/interface/matrix.go b/interface/matrix.go new file mode 100644 index 0000000..4c30b5e --- /dev/null +++ b/interface/matrix.go @@ -0,0 +1,40 @@ +// 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 . + +package ifc + +import ( + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/matrix/room" +) + +type MatrixContainer interface { + Client() *gomatrix.Client + InitClient() error + Initialized() bool + Login(user, password string) error + Start() + Stop() + // HandleMessage(evt *gomatrix.Event) + // HandleMembership(evt *gomatrix.Event) + // HandleTyping(evt *gomatrix.Event) + SendMessage(roomID, message string) + SendTyping(roomID string, typing bool) + JoinRoom(roomID string) error + LeaveRoom(roomID string) error + GetHistory(roomID, prevBatch string, limit int) ([]gomatrix.Event, string, error) + GetRoom(roomID string) *room.Room +} diff --git a/interface/ui.go b/interface/ui.go new file mode 100644 index 0000000..406aa2f --- /dev/null +++ b/interface/ui.go @@ -0,0 +1,54 @@ +// 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 . + +package ifc + +import ( + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/ui/types" + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/tview" +) + +type View string + +// Allowed views in GomuksUI +const ( + ViewLogin View = "login" + ViewMain View = "main" +) + +type GomuksUI interface { + Render() + SetView(name View) + InitViews() tview.Primitive + MainView() MainView +} + +type MainView interface { + InputTabComplete(text string, cursorOffset int) string + GetRoom(roomID string) *widget.RoomView + HasRoom(roomID string) bool + AddRoom(roomID string) + RemoveRoom(roomID string) + SetRooms(roomIDs []string) + + SetTyping(roomID string, users []string) + AddServiceMessage(roomID string, message string) + GetHistory(room string) + ProcessMessageEvent(evt *gomatrix.Event) (*widget.RoomView, *types.Message) + ProcessMembershipEvent(evt *gomatrix.Event, new bool) (*widget.RoomView, *types.Message) +} diff --git a/matrix.go b/matrix.go deleted file mode 100644 index ea1c5c6..0000000 --- a/matrix.go +++ /dev/null @@ -1,263 +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 . - -package main - -import ( - "fmt" - "strings" - "time" - - "maunium.net/go/gomatrix" -) - -type MatrixContainer struct { - client *gomatrix.Client - gmx Gomuks - ui *GomuksUI - debug DebugPrinter - config *Config - running bool - stop chan bool - - typing int64 -} - -func NewMatrixContainer(gmx Gomuks) *MatrixContainer { - c := &MatrixContainer{ - config: gmx.Config(), - debug: gmx.Debug(), - ui: gmx.UI(), - gmx: gmx, - } - - return c -} - -func (c *MatrixContainer) InitClient() error { - if len(c.config.HS) == 0 { - return fmt.Errorf("no homeserver in config") - } - - if c.client != nil { - c.Stop() - c.client = nil - } - - var mxid, accessToken string - if c.config.Session != nil { - accessToken = c.config.Session.AccessToken - mxid = c.config.MXID - } - - var err error - c.client, err = gomatrix.NewClient(c.config.HS, mxid, accessToken) - if err != nil { - return err - } - - c.stop = make(chan bool, 1) - - if c.config.Session != nil { - go c.Start() - } - return nil -} - -func (c *MatrixContainer) Initialized() bool { - return c.client != nil -} - -func (c *MatrixContainer) Login(user, password string) error { - resp, err := c.client.Login(&gomatrix.ReqLogin{ - Type: "m.login.password", - User: user, - Password: password, - }) - if err != nil { - return err - } - c.client.SetCredentials(resp.UserID, resp.AccessToken) - c.config.MXID = resp.UserID - c.config.Save() - - c.config.Session = c.config.NewSession(resp.UserID) - c.config.Session.AccessToken = resp.AccessToken - c.config.Session.Save() - - go c.Start() - - return nil -} - -func (c *MatrixContainer) Stop() { - if c.running { - c.stop <- true - c.client.StopSync() - } -} - -func (c *MatrixContainer) UpdateRoomList() { - rooms, err := c.client.JoinedRooms() - if err != nil { - c.debug.Print("Error fetching room list:", err) - return - } - - c.ui.MainView().SetRoomList(rooms.JoinedRooms) -} - -func (c *MatrixContainer) OnLogin() { - c.client.Store = c.config.Session - - syncer := NewGomuksSyncer(c.config.Session) - syncer.OnEventType("m.room.message", c.HandleMessage) - syncer.OnEventType("m.room.member", c.HandleMembership) - syncer.OnEventType("m.typing", c.HandleTyping) - c.client.Syncer = syncer - - c.UpdateRoomList() -} - -func (c *MatrixContainer) Start() { - defer c.gmx.Recover() - - c.OnLogin() - - c.debug.Print("Starting sync...") - c.running = true - c.ui.SetView(ViewMain) - for { - select { - case <-c.stop: - c.debug.Print("Stopping sync...") - c.running = false - return - default: - if err := c.client.Sync(); err != nil { - c.debug.Print("Sync() errored", err) - } else { - c.debug.Print("Sync() returned without error") - } - } - } -} - -func (c *MatrixContainer) HandleMessage(evt *gomatrix.Event) { - room, message := c.ui.MainView().ProcessMessageEvent(evt) - if room != nil { - room.AddMessage(message, AppendMessage) - } -} - -func (c *MatrixContainer) HandleMembership(evt *gomatrix.Event) { - const Hour = 1 * 60 * 60 * 1000 - if evt.Unsigned.Age > Hour { - return - } - - room, message := c.ui.MainView().ProcessMembershipEvent(evt, true) - if room != nil { - // TODO this shouldn't be necessary - room.room.UpdateState(evt) - // TODO This should probably also be in a different place - room.UpdateUserList() - - room.AddMessage(message, AppendMessage) - } -} - -func (c *MatrixContainer) HandleTyping(evt *gomatrix.Event) { - users := evt.Content["user_ids"].([]interface{}) - - strUsers := make([]string, len(users)) - for i, user := range users { - strUsers[i] = user.(string) - } - c.ui.MainView().SetTyping(evt.RoomID, strUsers) -} - -func (c *MatrixContainer) SendMessage(roomID, message string) { - c.gmx.Recover() - c.SendTyping(roomID, false) - c.client.SendText(roomID, message) -} - -func (c *MatrixContainer) SendTyping(roomID string, typing bool) { - c.gmx.Recover() - time := time.Now().Unix() - if c.typing > time && typing { - return - } - - if typing { - c.client.UserTyping(roomID, true, 5000) - c.typing = time + 5 - } else { - c.client.UserTyping(roomID, false, 0) - c.typing = 0 - } -} - -func (c *MatrixContainer) JoinRoom(roomID string) error { - if len(roomID) == 0 { - return fmt.Errorf("invalid room ID") - } - - server := "" - if roomID[0] == '!' { - server = roomID[strings.Index(roomID, ":")+1:] - } - - resp, err := c.client.JoinRoom(roomID, server, nil) - if err != nil { - return err - } - - c.ui.MainView().AddRoom(resp.RoomID) - return nil -} - -func (c *MatrixContainer) getState(roomID string) []*gomatrix.Event { - content := make([]*gomatrix.Event, 0) - err := c.client.StateEvent(roomID, "", "", &content) - if err != nil { - c.debug.Print("Error getting state of", roomID, err) - return nil - } - return content -} - -func (c *MatrixContainer) GetHistory(roomID, prevBatch string, limit int) ([]gomatrix.Event, string, error) { - resp, err := c.client.Messages(roomID, prevBatch, "", 'b', limit) - if err != nil { - return nil, "", err - } - return resp.Chunk, resp.End, nil -} - -func (c *MatrixContainer) GetRoom(roomID string) *Room { - room := c.config.Session.GetRoom(roomID) - if room != nil && len(room.State) == 0 { - events := c.getState(room.ID) - if events != nil { - for _, event := range events { - room.UpdateState(event) - } - } - } - return room -} diff --git a/matrix/matrix.go b/matrix/matrix.go new file mode 100644 index 0000000..7652188 --- /dev/null +++ b/matrix/matrix.go @@ -0,0 +1,284 @@ +// 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 . + +package matrix + +import ( + "fmt" + "strings" + "time" + + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/interface" + rooms "maunium.net/go/gomuks/matrix/room" + "maunium.net/go/gomuks/ui/debug" + "maunium.net/go/gomuks/ui/widget" +) + +type Container struct { + client *gomatrix.Client + gmx ifc.Gomuks + ui ifc.GomuksUI + config *config.Config + running bool + stop chan bool + + typing int64 +} + +func NewMatrixContainer(gmx ifc.Gomuks) *Container { + c := &Container{ + config: gmx.Config(), + ui: gmx.UI(), + gmx: gmx, + } + + return c +} + +func (c *Container) InitClient() error { + if len(c.config.HS) == 0 { + return fmt.Errorf("no homeserver in config") + } + + if c.client != nil { + c.Stop() + c.client = nil + } + + var mxid, accessToken string + if c.config.Session != nil { + accessToken = c.config.Session.AccessToken + mxid = c.config.MXID + } + + var err error + c.client, err = gomatrix.NewClient(c.config.HS, mxid, accessToken) + if err != nil { + return err + } + + c.stop = make(chan bool, 1) + + if c.config.Session != nil { + go c.Start() + } + return nil +} + +func (c *Container) Initialized() bool { + return c.client != nil +} + +func (c *Container) Login(user, password string) error { + resp, err := c.client.Login(&gomatrix.ReqLogin{ + Type: "m.login.password", + User: user, + Password: password, + }) + if err != nil { + return err + } + c.client.SetCredentials(resp.UserID, resp.AccessToken) + c.config.MXID = resp.UserID + c.config.Save() + + c.config.Session = c.config.NewSession(resp.UserID) + c.config.Session.AccessToken = resp.AccessToken + c.config.Session.Save() + + go c.Start() + + return nil +} + +func (c *Container) Stop() { + if c.running { + c.stop <- true + c.client.StopSync() + } +} + +func (c *Container) Client() *gomatrix.Client { + return c.client +} + +func (c *Container) UpdateRoomList() { + resp, err := c.client.JoinedRooms() + if err != nil { + debug.Print("Error fetching room list:", err) + return + } + + c.ui.MainView().SetRooms(resp.JoinedRooms) +} + +func (c *Container) OnLogin() { + c.client.Store = c.config.Session + + syncer := NewGomuksSyncer(c.config.Session) + syncer.OnEventType("m.room.message", c.HandleMessage) + syncer.OnEventType("m.room.member", c.HandleMembership) + syncer.OnEventType("m.typing", c.HandleTyping) + c.client.Syncer = syncer + + c.UpdateRoomList() +} + +func (c *Container) Start() { + defer c.gmx.Recover() + + c.ui.SetView(ifc.ViewMain) + c.OnLogin() + + debug.Print("Starting sync...") + c.running = true + for { + select { + case <-c.stop: + debug.Print("Stopping sync...") + c.running = false + return + default: + if err := c.client.Sync(); err != nil { + debug.Print("Sync() errored", err) + } else { + debug.Print("Sync() returned without error") + } + } + } +} + +func (c *Container) HandleMessage(evt *gomatrix.Event) { + room, message := c.ui.MainView().ProcessMessageEvent(evt) + if room != nil { + room.AddMessage(message, widget.AppendMessage) + } +} + +func (c *Container) HandleMembership(evt *gomatrix.Event) { + const Hour = 1 * 60 * 60 * 1000 + if evt.Unsigned.Age > Hour { + return + } + + room, message := c.ui.MainView().ProcessMembershipEvent(evt, true) + if room != nil { + // TODO this shouldn't be necessary + room.Room.UpdateState(evt) + // TODO This should probably also be in a different place + room.UpdateUserList() + + room.AddMessage(message, widget.AppendMessage) + } +} + +func (c *Container) HandleTyping(evt *gomatrix.Event) { + users := evt.Content["user_ids"].([]interface{}) + + strUsers := make([]string, len(users)) + for i, user := range users { + strUsers[i] = user.(string) + } + c.ui.MainView().SetTyping(evt.RoomID, strUsers) +} + +func (c *Container) SendMessage(roomID, message string) { + c.gmx.Recover() + c.SendTyping(roomID, false) + c.client.SendText(roomID, message) +} + +func (c *Container) SendTyping(roomID string, typing bool) { + c.gmx.Recover() + ts := time.Now().Unix() + if c.typing > ts && typing { + return + } + + if typing { + c.client.UserTyping(roomID, true, 5000) + c.typing = ts + 5 + } else { + c.client.UserTyping(roomID, false, 0) + c.typing = 0 + } +} + +func (c *Container) JoinRoom(roomID string) error { + if len(roomID) == 0 { + return fmt.Errorf("invalid room ID") + } + + server := "" + if roomID[0] == '!' { + server = roomID[strings.Index(roomID, ":")+1:] + } + + _, err := c.client.JoinRoom(roomID, server, nil) + if err != nil { + return err + } + + // TODO probably safe to remove + // c.ui.MainView().AddRoom(resp.RoomID) + return nil +} + +func (c *Container) LeaveRoom(roomID string) error { + if len(roomID) == 0 { + return fmt.Errorf("invalid room ID") + } + + _, err := c.client.LeaveRoom(roomID) + if err != nil { + return err + } + + return nil +} + +func (c *Container) getState(roomID string) []*gomatrix.Event { + content := make([]*gomatrix.Event, 0) + err := c.client.StateEvent(roomID, "", "", &content) + if err != nil { + debug.Print("Error getting state of", roomID, err) + return nil + } + return content +} + +func (c *Container) GetHistory(roomID, prevBatch string, limit int) ([]gomatrix.Event, string, error) { + resp, err := c.client.Messages(roomID, prevBatch, "", 'b', limit) + if err != nil { + return nil, "", err + } + return resp.Chunk, resp.End, nil +} + +func (c *Container) GetRoom(roomID string) *rooms.Room { + room := c.config.Session.GetRoom(roomID) + if room != nil && len(room.State) == 0 { + events := c.getState(room.ID) + if events != nil { + for _, event := range events { + room.UpdateState(event) + } + } + } + return room +} diff --git a/matrix/room/member.go b/matrix/room/member.go new file mode 100644 index 0000000..474d2fd --- /dev/null +++ b/matrix/room/member.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 . + +package room + +import ( + "maunium.net/go/gomatrix" +) + +type Member struct { + UserID string `json:"-"` + Membership string `json:"membership"` + DisplayName string `json:"displayname"` + AvatarURL string `json:"avatar_url"` +} + +func eventToRoomMember(userID string, event *gomatrix.Event) *Member { + if event == nil { + return &Member{ + UserID: userID, + Membership: "leave", + } + } + membership, _ := event.Content["membership"].(string) + avatarURL, _ := event.Content["avatar_url"].(string) + + displayName, _ := event.Content["displayname"].(string) + if len(displayName) == 0 { + displayName = userID + } + + return &Member{ + UserID: userID, + Membership: membership, + DisplayName: displayName, + AvatarURL: avatarURL, + } +} diff --git a/matrix/room/room.go b/matrix/room/room.go new file mode 100644 index 0000000..4b3bda2 --- /dev/null +++ b/matrix/room/room.go @@ -0,0 +1,145 @@ +// 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 . + +package room + +import ( + "maunium.net/go/gomatrix" +) + +// Room represents a single Matrix room. +type Room struct { + *gomatrix.Room + + PrevBatch string + memberCache map[string]*Member + nameCache string + topicCache string +} + +// UpdateState updates the room's current state with the given Event. This will clobber events based +// on the type/state_key combination. +func (room *Room) UpdateState(event *gomatrix.Event) { + _, exists := room.State[event.Type] + if !exists { + room.State[event.Type] = make(map[string]*gomatrix.Event) + } + switch event.Type { + case "m.room.member": + room.memberCache = nil + case "m.room.name": + case "m.room.canonical_alias": + case "m.room.alias": + room.nameCache = "" + case "m.room.topic": + room.topicCache = "" + } + room.State[event.Type][*event.StateKey] = event +} + +// GetStateEvent returns the state event for the given type/state_key combo, or nil. +func (room *Room) GetStateEvent(eventType string, stateKey string) *gomatrix.Event { + stateEventMap, _ := room.State[eventType] + event, _ := stateEventMap[stateKey] + return event +} + +// GetStateEvents returns the state events for the given type. +func (room *Room) GetStateEvents(eventType string) map[string]*gomatrix.Event { + stateEventMap, _ := room.State[eventType] + return stateEventMap +} + +// GetTopic returns the topic of the room. +func (room *Room) GetTopic() string { + if len(room.topicCache) == 0 { + topicEvt := room.GetStateEvent("m.room.topic", "") + if topicEvt != nil { + room.topicCache, _ = topicEvt.Content["topic"].(string) + } + } + return room.topicCache +} + +// GetTitle returns the display title of the room. +func (room *Room) GetTitle() string { + if len(room.nameCache) == 0 { + nameEvt := room.GetStateEvent("m.room.name", "") + if nameEvt != nil { + room.nameCache, _ = nameEvt.Content["name"].(string) + } + } + if len(room.nameCache) == 0 { + canonicalAliasEvt := room.GetStateEvent("m.room.canonical_alias", "") + if canonicalAliasEvt != nil { + room.nameCache, _ = canonicalAliasEvt.Content["alias"].(string) + } + } + if len(room.nameCache) == 0 { + // TODO the spec says clients should not use m.room.aliases for room names. + // However, Riot also uses m.room.aliases, so this is here now. + aliasEvents := room.GetStateEvents("m.room.aliases") + for _, event := range aliasEvents { + aliases, _ := event.Content["aliases"].([]interface{}) + if len(aliases) > 0 { + room.nameCache, _ = aliases[0].(string) + break + } + } + } + if len(room.nameCache) == 0 { + // TODO follow other title rules in spec + room.nameCache = room.ID + } + return room.nameCache +} + +func (room *Room) createMemberCache() map[string]*Member { + cache := make(map[string]*Member) + events := room.GetStateEvents("m.room.member") + if events != nil { + for userID, event := range events { + member := eventToRoomMember(userID, event) + if member.Membership != "leave" { + cache[member.UserID] = member + } + } + } + room.memberCache = cache + return cache +} + +func (room *Room) GetMembers() map[string]*Member { + if len(room.memberCache) == 0 { + room.createMemberCache() + } + return room.memberCache +} + +func (room *Room) GetMember(userID string) *Member { + if len(room.memberCache) == 0 { + room.createMemberCache() + } + member, _ := room.memberCache[userID] + return member +} + +// NewRoom creates a new Room with the given ID +func NewRoom(roomID string) *Room { + return &Room{ + Room: gomatrix.NewRoom(roomID), + } +} diff --git a/matrix/sync.go b/matrix/sync.go new file mode 100644 index 0000000..ab5d047 --- /dev/null +++ b/matrix/sync.go @@ -0,0 +1,126 @@ +package matrix + +import ( + "encoding/json" + "fmt" + "runtime/debug" + "time" + + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/config" +) + +// GomuksSyncer is the default syncing implementation. You can either write your own syncer, or selectively +// replace parts of this default syncer (e.g. the ProcessResponse method). The default syncer uses the observer +// pattern to notify callers about incoming events. See GomuksSyncer.OnEventType for more information. +type GomuksSyncer struct { + Session *config.Session + listeners map[string][]gomatrix.OnEventListener // event type to listeners array +} + +// NewGomuksSyncer returns an instantiated GomuksSyncer +func NewGomuksSyncer(session *config.Session) *GomuksSyncer { + return &GomuksSyncer{ + Session: session, + listeners: make(map[string][]gomatrix.OnEventListener), + } +} + +func (s *GomuksSyncer) ProcessResponse(res *gomatrix.RespSync, since string) (err error) { + if !s.shouldProcessResponse(res, since) { + return + } + // gdebug.Print("Processing sync response", since, res) + + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("ProcessResponse panicked! userID=%s since=%s panic=%s\n%s", s.Session.MXID, since, r, debug.Stack()) + } + }() + + for _, event := range res.Presence.Events { + s.notifyListeners(event) + } + for roomID, roomData := range res.Rooms.Join { + room := s.Session.GetRoom(roomID) + for _, event := range roomData.State.Events { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + for _, event := range roomData.Timeline.Events { + event.RoomID = roomID + s.notifyListeners(event) + } + for _, event := range roomData.Ephemeral.Events { + event.RoomID = roomID + s.notifyListeners(event) + } + + if len(room.PrevBatch) == 0 { + room.PrevBatch = roomData.Timeline.PrevBatch + } + } + for roomID, roomData := range res.Rooms.Invite { + room := s.Session.GetRoom(roomID) + for _, event := range roomData.State.Events { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + } + for roomID, roomData := range res.Rooms.Leave { + room := s.Session.GetRoom(roomID) + for _, event := range roomData.Timeline.Events { + if event.StateKey != nil { + event.RoomID = roomID + room.UpdateState(event) + s.notifyListeners(event) + } + } + + if len(room.PrevBatch) == 0 { + room.PrevBatch = roomData.Timeline.PrevBatch + } + } + return +} + +// OnEventType allows callers to be notified when there are new events for the given event type. +// There are no duplicate checks. +func (s *GomuksSyncer) OnEventType(eventType string, callback gomatrix.OnEventListener) { + _, exists := s.listeners[eventType] + if !exists { + s.listeners[eventType] = []gomatrix.OnEventListener{} + } + s.listeners[eventType] = append(s.listeners[eventType], callback) +} + +// shouldProcessResponse returns true if the response should be processed. May modify the response to remove +// stuff that shouldn't be processed. +func (s *GomuksSyncer) shouldProcessResponse(resp *gomatrix.RespSync, since string) bool { + if since == "" { + return false + } + return true +} + +func (s *GomuksSyncer) notifyListeners(event *gomatrix.Event) { + listeners, exists := s.listeners[event.Type] + if !exists { + return + } + for _, fn := range listeners { + fn(event) + } +} + +// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error. +func (s *GomuksSyncer) OnFailedSync(res *gomatrix.RespSync, err error) (time.Duration, error) { + return 10 * time.Second, nil +} + +// GetFilterJSON returns a filter with a timeline limit of 50. +func (s *GomuksSyncer) GetFilterJSON(userID string) json.RawMessage { + return json.RawMessage(`{"room":{"timeline":{"limit":50}}}`) +} diff --git a/message-view.go b/message-view.go deleted file mode 100644 index d3d2df9..0000000 --- a/message-view.go +++ /dev/null @@ -1,350 +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 . - -package main - -import ( - "fmt" - "regexp" - "strings" - "time" - - "github.com/gdamore/tcell" - "github.com/mattn/go-runewidth" - "maunium.net/go/tview" -) - -type Message struct { - ID string - Sender string - Text string - Timestamp string - Date string - - buffer []string - senderColor tcell.Color -} - -func NewMessage(id, sender, text, timestamp, date string, senderColor tcell.Color) *Message { - return &Message{ - ID: id, - Sender: sender, - Text: text, - Timestamp: timestamp, - Date: date, - senderColor: senderColor, - } -} - -var ( - boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)") - spacePattern = regexp.MustCompile(`\s+`) -) - -func (message *Message) calculateBuffer(width int) { - if width < 1 { - return - } - message.buffer = []string{} - forcedLinebreaks := strings.Split(message.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):] - } - } -} - -type MessageView struct { - *tview.Box - - ScrollOffset int - MaxSenderWidth int - DateFormat string - TimestampFormat string - TimestampWidth int - Separator rune - - widestSender int - prevWidth int - prevHeight int - prevScrollOffset int - firstDisplayMessage int - lastDisplayMessage int - totalHeight int - - messageIDs map[string]bool - messages []*Message -} - -func NewMessageView() *MessageView { - return &MessageView{ - Box: tview.NewBox(), - MaxSenderWidth: 20, - DateFormat: "January _2, 2006", - TimestampFormat: "15:04:05", - TimestampWidth: 8, - Separator: '|', - ScrollOffset: 0, - - messages: make([]*Message, 0), - messageIDs: make(map[string]bool), - - widestSender: 5, - prevWidth: -1, - prevHeight: -1, - prevScrollOffset: -1, - firstDisplayMessage: -1, - lastDisplayMessage: -1, - totalHeight: -1, - } -} - -func (view *MessageView) NewMessage(id, sender, text string, timestamp time.Time) *Message { - return NewMessage(id, sender, text, - timestamp.Format(view.TimestampFormat), - timestamp.Format(view.DateFormat), - getColor(sender)) -} - -func (view *MessageView) recalculateBuffers() { - _, _, width, _ := view.GetInnerRect() - width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap - if width != view.prevWidth { - for _, message := range view.messages { - message.calculateBuffer(width) - } - view.prevWidth = width - } -} - -func (view *MessageView) updateWidestSender(sender string) { - if len(sender) > view.widestSender { - view.widestSender = len(sender) - if view.widestSender > view.MaxSenderWidth { - view.widestSender = view.MaxSenderWidth - } - } -} - -const ( - AppendMessage int = iota - PrependMessage -) - -func (view *MessageView) AddMessage(message *Message, direction int) { - _, messageExists := view.messageIDs[message.ID] - if messageExists { - return - } - - 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 += len(message.buffer) - } - view.messages = append(view.messages, message) - } else if direction == PrependMessage { - view.messages = append([]*Message{message}, view.messages...) - } - - view.messageIDs[message.ID] = true - view.recalculateHeight() -} - -func (view *MessageView) recalculateHeight() { - _, _, width, height := view.GetInnerRect() - if height != view.prevHeight || width != view.prevWidth || view.ScrollOffset != view.prevScrollOffset { - view.firstDisplayMessage = -1 - view.lastDisplayMessage = -1 - view.totalHeight = 0 - prevDate := "" - for i := len(view.messages) - 1; i >= 0; i-- { - prevTotalHeight := view.totalHeight - message := view.messages[i] - view.totalHeight += len(message.buffer) - if message.Date != prevDate { - if len(prevDate) != 0 { - view.totalHeight++ - } - prevDate = message.Date - } - - if view.totalHeight < view.ScrollOffset { - continue - } else if view.firstDisplayMessage == -1 { - view.lastDisplayMessage = i - view.firstDisplayMessage = i - } - - if prevTotalHeight < height+view.ScrollOffset { - view.lastDisplayMessage = i - } - } - view.prevScrollOffset = view.ScrollOffset - } -} - -func (view *MessageView) PageUp() { - _, _, _, height := view.GetInnerRect() - view.ScrollOffset += height / 2 - if view.ScrollOffset > view.totalHeight-height { - view.ScrollOffset = view.totalHeight - height + 5 - } -} - -func (view *MessageView) PageDown() { - _, _, _, height := view.GetInnerRect() - view.ScrollOffset -= height / 2 - if view.ScrollOffset < 0 { - view.ScrollOffset = 0 - } -} - -func (view *MessageView) writeLine(screen tcell.Screen, line string, x, y int, color tcell.Color) { - offsetX := 0 - for _, ch := range line { - chWidth := runewidth.RuneWidth(ch) - if chWidth == 0 { - continue - } - - for localOffset := 0; localOffset < chWidth; localOffset++ { - screen.SetContent(x+offsetX+localOffset, y, ch, nil, tcell.StyleDefault.Foreground(color)) - } - offsetX += chWidth - } -} - -func (view *MessageView) writeLineRight(screen tcell.Screen, line string, x, y, maxWidth int, color tcell.Color) { - offsetX := maxWidth - runewidth.StringWidth(line) - if offsetX < 0 { - offsetX = 0 - } - for _, ch := range line { - chWidth := runewidth.RuneWidth(ch) - if chWidth == 0 { - continue - } - - for localOffset := 0; localOffset < chWidth; localOffset++ { - screen.SetContent(x+offsetX+localOffset, y, ch, nil, tcell.StyleDefault.Foreground(color)) - } - offsetX += chWidth - } -} - -const ( - TimestampSenderGap = 1 - SenderSeparatorGap = 1 - SenderMessageGap = 3 -) - -func (view *MessageView) Draw(screen tcell.Screen) { - view.Box.Draw(screen) - - x, y, _, height := view.GetInnerRect() - view.recalculateBuffers() - view.recalculateHeight() - - if view.firstDisplayMessage == -1 || view.lastDisplayMessage == -1 { - view.writeLine(screen, "It's quite empty in here.", x, y+height, tcell.ColorDefault) - return - } - - usernameOffsetX := view.TimestampWidth + TimestampSenderGap - messageOffsetX := usernameOffsetX + view.widestSender + SenderMessageGap - separatorX := x + usernameOffsetX + view.widestSender + SenderSeparatorGap - for separatorY := y; separatorY < y+height; separatorY++ { - screen.SetContent(separatorX, separatorY, view.Separator, nil, tcell.StyleDefault) - } - - writeOffset := 0 - prevDate := "" - prevSender := "" - prevSenderLine := -1 - for i := view.firstDisplayMessage; i >= view.lastDisplayMessage; i-- { - message := view.messages[i] - messageHeight := len(message.buffer) - - // Show message when the date changes. - if message.Date != prevDate { - if len(prevDate) > 0 { - writeOffset++ - view.writeLine( - screen, fmt.Sprintf("Date changed to %s", prevDate), - x+messageOffsetX, y+height-writeOffset, tcell.ColorGreen) - } - prevDate = message.Date - } - - senderAtLine := y + height - writeOffset - messageHeight - // The message may be only partially on screen, so we need to make sure the sender - // is on screen even when the message is not shown completely. - if senderAtLine < y { - senderAtLine = y - } - - view.writeLine(screen, message.Timestamp, x, senderAtLine, tcell.ColorDefault) - view.writeLineRight(screen, message.Sender, - x+usernameOffsetX, senderAtLine, - view.widestSender, message.senderColor) - - if message.Sender == prevSender { - // Sender is same as previous. We're looping from bottom to top, and we want the - // sender name only on the topmost message, so clear out the duplicate sender name - // below. - view.writeLineRight(screen, strings.Repeat(" ", view.widestSender), - x+usernameOffsetX, prevSenderLine, - view.widestSender, message.senderColor) - } - prevSender = message.Sender - prevSenderLine = senderAtLine - - for num, line := range message.buffer { - offsetY := height - messageHeight - writeOffset + num - // Only render message if it's within the message view. - if offsetY >= 0 { - view.writeLine(screen, line, x+messageOffsetX, y+offsetY, tcell.ColorDefault) - } - } - writeOffset += messageHeight - } -} diff --git a/room-view.go b/room-view.go deleted file mode 100644 index 5710788..0000000 --- a/room-view.go +++ /dev/null @@ -1,179 +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 . - -package main - -import ( - "fmt" - "hash/fnv" - "sort" - "strings" - "time" - - "github.com/gdamore/tcell" - "maunium.net/go/tview" -) - -type RoomView struct { - *tview.Box - - topic *tview.TextView - content *MessageView - status *tview.TextView - userList *tview.TextView - room *Room - - parent *MainView -} - -var colorNames []string - -func init() { - colorNames = make([]string, len(tcell.ColorNames)) - i := 0 - for name, _ := range tcell.ColorNames { - colorNames[i] = name - i++ - } - sort.Sort(sort.StringSlice(colorNames)) -} - -func NewRoomView(parent *MainView, room *Room) *RoomView { - view := &RoomView{ - Box: tview.NewBox(), - topic: tview.NewTextView(), - content: NewMessageView(), - status: tview.NewTextView(), - userList: tview.NewTextView(), - room: room, - parent: parent, - } - view.topic. - SetText(strings.Replace(room.GetTopic(), "\n", " ", -1)). - SetBackgroundColor(tcell.ColorDarkGreen) - view.status.SetBackgroundColor(tcell.ColorDimGray) - view.userList.SetDynamicColors(true) - return view -} - -func (view *RoomView) Draw(screen tcell.Screen) { - view.Box.Draw(screen) - - x, y, width, height := view.GetRect() - view.topic.SetRect(x, y, width, 1) - view.content.SetRect(x, y+1, width-30, height-2) - view.status.SetRect(x, y+height-1, width, 1) - view.userList.SetRect(x+width-29, y+1, 29, height-2) - - view.topic.Draw(screen) - view.content.Draw(screen) - view.status.Draw(screen) - - borderX := x + width - 30 - background := tcell.StyleDefault.Background(view.GetBackgroundColor()).Foreground(view.GetBorderColor()) - for borderY := y + 1; borderY < y+height-1; borderY++ { - screen.SetContent(borderX, borderY, tview.GraphicsVertBar, nil, background) - } - view.userList.Draw(screen) -} - -func (view *RoomView) SetTyping(users []string) { - for index, user := range users { - member := view.room.GetMember(user) - if member != nil { - users[index] = member.DisplayName - } - } - if len(users) == 0 { - view.status.SetText("") - } else if len(users) < 2 { - view.status.SetText("Typing: " + strings.Join(users, " and ")) - } else { - view.status.SetText(fmt.Sprintf( - "Typing: %s and %s", - strings.Join(users[:len(users)-1], ", "), users[len(users)-1])) - } -} - -func (view *RoomView) AutocompleteUser(existingText string) (completions []string) { - for _, user := range view.room.GetMembers() { - if strings.HasPrefix(user.DisplayName, existingText) { - completions = append(completions, user.DisplayName) - } else if strings.HasPrefix(user.UserID, existingText) { - completions = append(completions, user.UserID) - } - } - return -} - -func (view *RoomView) MessageView() *MessageView { - return view.content -} - -func getColorName(s string) string { - switch s { - case "-->": - return "green" - case "<--": - return "red" - case "---": - return "yellow" - default: - h := fnv.New32a() - h.Write([]byte(s)) - return colorNames[int(h.Sum32())%len(colorNames)] - } -} - -func getColor(s string) tcell.Color { - return tcell.ColorNames[getColorName(s)] -} - -func color(s string) string { - return fmt.Sprintf("[%s]%s[white]", getColorName(s), s) -} - -func (view *RoomView) UpdateUserList() { - var joined strings.Builder - var invited strings.Builder - for _, user := range view.room.GetMembers() { - if user.Membership == "join" { - joined.WriteString(color(user.DisplayName)) - joined.WriteRune('\n') - } else if user.Membership == "invite" { - invited.WriteString(color(user.DisplayName)) - invited.WriteRune('\n') - } - } - view.userList.Clear() - fmt.Fprintf(view.userList, "%s\n", joined.String()) - if invited.Len() > 0 { - fmt.Fprintf(view.userList, "\nInvited:\n%s", invited.String()) - } -} - -func (view *RoomView) NewMessage(id, sender, text string, timestamp time.Time) *Message { - member := view.room.GetMember(sender) - if member != nil { - sender = member.DisplayName - } - return view.content.NewMessage(id, sender, text, timestamp) -} - -func (view *RoomView) AddMessage(message *Message, direction int) { - view.content.AddMessage(message, direction) - view.parent.Render() -} diff --git a/room.go b/room.go deleted file mode 100644 index 19c6865..0000000 --- a/room.go +++ /dev/null @@ -1,175 +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 . - -package main - -import ( - "maunium.net/go/gomatrix" -) - -// Room represents a single Matrix room. -type Room struct { - *gomatrix.Room - - PrevBatch string - memberCache map[string]*RoomMember - nameCache string - topicCache string -} - -// UpdateState updates the room's current state with the given Event. This will clobber events based -// on the type/state_key combination. -func (room *Room) UpdateState(event *gomatrix.Event) { - _, exists := room.State[event.Type] - if !exists { - room.State[event.Type] = make(map[string]*gomatrix.Event) - } - switch event.Type { - case "m.room.member": - room.memberCache = nil - case "m.room.name": - case "m.room.canonical_alias": - case "m.room.alias": - room.nameCache = "" - case "m.room.topic": - room.topicCache = "" - } - room.State[event.Type][*event.StateKey] = event -} - -// GetStateEvent returns the state event for the given type/state_key combo, or nil. -func (room *Room) GetStateEvent(eventType string, stateKey string) *gomatrix.Event { - stateEventMap, _ := room.State[eventType] - event, _ := stateEventMap[stateKey] - return event -} - -// GetStateEvents returns the state events for the given type. -func (room *Room) GetStateEvents(eventType string) map[string]*gomatrix.Event { - stateEventMap, _ := room.State[eventType] - return stateEventMap -} - -// GetTopic returns the topic of the room. -func (room *Room) GetTopic() string { - if len(room.topicCache) == 0 { - topicEvt := room.GetStateEvent("m.room.topic", "") - if topicEvt != nil { - room.topicCache, _ = topicEvt.Content["topic"].(string) - } - } - return room.topicCache -} - -// GetTitle returns the display title of the room. -func (room *Room) GetTitle() string { - if len(room.nameCache) == 0 { - nameEvt := room.GetStateEvent("m.room.name", "") - if nameEvt != nil { - room.nameCache, _ = nameEvt.Content["name"].(string) - } - } - if len(room.nameCache) == 0 { - canonicalAliasEvt := room.GetStateEvent("m.room.canonical_alias", "") - if canonicalAliasEvt != nil { - room.nameCache, _ = canonicalAliasEvt.Content["alias"].(string) - } - } - if len(room.nameCache) == 0 { - // TODO the spec says clients should not use m.room.aliases for room names. - // However, Riot also uses m.room.aliases, so this is here now. - aliasEvents := room.GetStateEvents("m.room.aliases") - for _, event := range aliasEvents { - aliases, _ := event.Content["aliases"].([]interface{}) - if len(aliases) > 0 { - room.nameCache, _ = aliases[0].(string) - break - } - } - } - if len(room.nameCache) == 0 { - // TODO follow other title rules in spec - room.nameCache = room.ID - } - return room.nameCache -} - -type RoomMember struct { - UserID string `json:"-"` - Membership string `json:"membership"` - DisplayName string `json:"displayname"` - AvatarURL string `json:"avatar_url"` -} - -func eventToRoomMember(userID string, event *gomatrix.Event) *RoomMember { - if event == nil { - return &RoomMember{ - UserID: userID, - Membership: "leave", - } - } - membership, _ := event.Content["membership"].(string) - avatarURL, _ := event.Content["avatar_url"].(string) - - displayName, _ := event.Content["displayname"].(string) - if len(displayName) == 0 { - displayName = userID - } - - return &RoomMember{ - UserID: userID, - Membership: membership, - DisplayName: displayName, - AvatarURL: avatarURL, - } -} - -func (room *Room) createMemberCache() map[string]*RoomMember { - cache := make(map[string]*RoomMember) - events := room.GetStateEvents("m.room.member") - if events != nil { - for userID, event := range events { - member := eventToRoomMember(userID, event) - if member.Membership != "leave" { - cache[member.UserID] = member - } - } - } - room.memberCache = cache - return cache -} - -func (room *Room) GetMembers() map[string]*RoomMember { - if len(room.memberCache) == 0 { - room.createMemberCache() - } - return room.memberCache -} - -func (room *Room) GetMember(userID string) *RoomMember { - if len(room.memberCache) == 0 { - room.createMemberCache() - } - member, _ := room.memberCache[userID] - return member -} - -// NewRoom creates a new Room with the given ID -func NewRoom(roomID string) *Room { - return &Room{ - Room: gomatrix.NewRoom(roomID), - } -} diff --git a/session.go b/session.go deleted file mode 100644 index eda49dc..0000000 --- a/session.go +++ /dev/null @@ -1,126 +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 . - -package main - -import ( - "encoding/json" - "io/ioutil" - "path/filepath" - - "maunium.net/go/gomatrix" -) - -type Session struct { - MXID string `json:"-"` - path string `json:"-"` - AccessToken string - NextBatch string - FilterID string - Rooms map[string]*Room - - debug DebugPrinter `json:"-"` -} - -func (config *Config) LoadSession(mxid string) { - config.Session = config.NewSession(mxid) - config.Session.Load() -} - -func (config *Config) NewSession(mxid string) *Session { - return &Session{ - MXID: mxid, - path: filepath.Join(config.dir, mxid+".session"), - Rooms: make(map[string]*Room), - debug: config.debug, - } -} - -func (s *Session) Clear() { - s.Rooms = make(map[string]*Room) - s.NextBatch = "" - s.FilterID = "" - s.Save() -} - -func (s *Session) Load() { - data, err := ioutil.ReadFile(s.path) - if err != nil { - s.debug.Print("Failed to read session from", s.path) - panic(err) - } - - err = json.Unmarshal(data, s) - if err != nil { - s.debug.Print("Failed to parse session at", s.path) - panic(err) - } -} - -func (s *Session) Save() { - data, err := json.Marshal(s) - if err != nil { - s.debug.Print("Failed to marshal session of", s.MXID) - panic(err) - } - - err = ioutil.WriteFile(s.path, data, 0600) - if err != nil { - s.debug.Print("Failed to write session to", s.path) - panic(err) - } -} - -func (s *Session) LoadFilterID(_ string) string { - return s.FilterID -} - -func (s *Session) LoadNextBatch(_ string) string { - return s.NextBatch -} - -func (s *Session) GetRoom(mxid string) *Room { - room, _ := s.Rooms[mxid] - if room == nil { - room = NewRoom(mxid) - s.Rooms[room.ID] = room - } - return room -} - -func (s *Session) PutRoom(room *Room) { - s.Rooms[room.ID] = room - s.Save() -} - -func (s *Session) SaveFilterID(_, filterID string) { - s.FilterID = filterID - s.Save() -} - -func (s *Session) SaveNextBatch(_, nextBatch string) { - s.NextBatch = nextBatch - s.Save() -} - -func (s *Session) LoadRoom(mxid string) *gomatrix.Room { - return s.GetRoom(mxid).Room -} - -func (s *Session) SaveRoom(room *gomatrix.Room) { - s.GetRoom(room.ID).Room = room - s.Save() -} diff --git a/sync.go b/sync.go deleted file mode 100644 index 2e0bbcf..0000000 --- a/sync.go +++ /dev/null @@ -1,125 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "runtime/debug" - "time" - - "maunium.net/go/gomatrix" -) - -// GomuksSyncer is the default syncing implementation. You can either write your own syncer, or selectively -// replace parts of this default syncer (e.g. the ProcessResponse method). The default syncer uses the observer -// pattern to notify callers about incoming events. See GomuksSyncer.OnEventType for more information. -type GomuksSyncer struct { - Session *Session - listeners map[string][]gomatrix.OnEventListener // event type to listeners array -} - -// NewGomuksSyncer returns an instantiated GomuksSyncer -func NewGomuksSyncer(session *Session) *GomuksSyncer { - return &GomuksSyncer{ - Session: session, - listeners: make(map[string][]gomatrix.OnEventListener), - } -} - -func (s *GomuksSyncer) ProcessResponse(res *gomatrix.RespSync, since string) (err error) { - if !s.shouldProcessResponse(res, since) { - return - } - // gdebug.Print("Processing sync response", since, res) - - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("ProcessResponse panicked! userID=%s since=%s panic=%s\n%s", s.Session.MXID, since, r, debug.Stack()) - } - }() - - for _, event := range res.Presence.Events { - s.notifyListeners(&event) - } - for roomID, roomData := range res.Rooms.Join { - room := s.Session.GetRoom(roomID) - for _, event := range roomData.State.Events { - event.RoomID = roomID - room.UpdateState(&event) - s.notifyListeners(&event) - } - for _, event := range roomData.Timeline.Events { - event.RoomID = roomID - s.notifyListeners(&event) - } - for _, event := range roomData.Ephemeral.Events { - event.RoomID = roomID - s.notifyListeners(&event) - } - - if len(room.PrevBatch) == 0 { - room.PrevBatch = roomData.Timeline.PrevBatch - } - } - for roomID, roomData := range res.Rooms.Invite { - room := s.Session.GetRoom(roomID) - for _, event := range roomData.State.Events { - event.RoomID = roomID - room.UpdateState(&event) - s.notifyListeners(&event) - } - } - for roomID, roomData := range res.Rooms.Leave { - room := s.Session.GetRoom(roomID) - for _, event := range roomData.Timeline.Events { - if event.StateKey != nil { - event.RoomID = roomID - room.UpdateState(&event) - s.notifyListeners(&event) - } - } - - if len(room.PrevBatch) == 0 { - room.PrevBatch = roomData.Timeline.PrevBatch - } - } - return -} - -// OnEventType allows callers to be notified when there are new events for the given event type. -// There are no duplicate checks. -func (s *GomuksSyncer) OnEventType(eventType string, callback gomatrix.OnEventListener) { - _, exists := s.listeners[eventType] - if !exists { - s.listeners[eventType] = []gomatrix.OnEventListener{} - } - s.listeners[eventType] = append(s.listeners[eventType], callback) -} - -// shouldProcessResponse returns true if the response should be processed. May modify the response to remove -// stuff that shouldn't be processed. -func (s *GomuksSyncer) shouldProcessResponse(resp *gomatrix.RespSync, since string) bool { - if since == "" { - return false - } - return true -} - -func (s *GomuksSyncer) notifyListeners(event *gomatrix.Event) { - listeners, exists := s.listeners[event.Type] - if !exists { - return - } - for _, fn := range listeners { - fn(event) - } -} - -// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error. -func (s *GomuksSyncer) OnFailedSync(res *gomatrix.RespSync, err error) (time.Duration, error) { - return 10 * time.Second, nil -} - -// GetFilterJSON returns a filter with a timeline limit of 50. -func (s *GomuksSyncer) GetFilterJSON(userID string) json.RawMessage { - return json.RawMessage(`{"room":{"timeline":{"limit":50}}}`) -} diff --git a/ui.go b/ui.go deleted file mode 100644 index 96b4e41..0000000 --- a/ui.go +++ /dev/null @@ -1,76 +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 . - -package main - -import ( - "github.com/gdamore/tcell" - "maunium.net/go/tview" -) - -// Allowed views in GomuksUI -const ( - ViewLogin = "login" - ViewMain = "main" -) - -type GomuksUI struct { - gmx Gomuks - app *tview.Application - matrix *MatrixContainer - debug DebugPrinter - config *Config - views *tview.Pages - - mainView *MainView - loginView *tview.Form -} - -func init() { - tview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault - tview.Styles.ContrastBackgroundColor = tcell.ColorDarkGreen -} - -func NewGomuksUI(gmx Gomuks) (ui *GomuksUI) { - ui = &GomuksUI{ - gmx: gmx, - app: gmx.App(), - matrix: gmx.MatrixContainer(), - debug: gmx.Debug(), - config: gmx.Config(), - views: tview.NewPages(), - } - ui.views.SetChangedFunc(ui.Render) - return -} - -func (ui *GomuksUI) Render() { - ui.app.Draw() -} - -func (ui *GomuksUI) SetView(name string) { - ui.views.SwitchToPage(name) -} - -func (ui *GomuksUI) InitViews() tview.Primitive { - ui.views.AddPage(ViewLogin, ui.NewLoginView(), true, true) - ui.views.AddPage(ViewMain, ui.NewMainView(), true, false) - return ui.views -} - -func (ui *GomuksUI) MainView() *MainView { - return ui.mainView -} diff --git a/ui/debug/debug.go b/ui/debug/debug.go new file mode 100644 index 0000000..c855897 --- /dev/null +++ b/ui/debug/debug.go @@ -0,0 +1,83 @@ +// 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 . + +package debug + +import ( + "fmt" + + "maunium.net/go/tview" +) + +type Printer interface { + Printf(text string, args ...interface{}) + Print(text ...interface{}) +} + +type Pane struct { + *tview.TextView + Height int + num int +} + +var Default Printer + +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, + num: 0, + } +} + +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) +} + +func (db *Pane) Wrap(main tview.Primitive) tview.Primitive { + return tview.NewGrid().SetRows(0, db.Height).SetColumns(0). + AddItem(main, 0, 0, 1, 1, 1, 1, true). + AddItem(db, 1, 0, 1, 1, 1, 1, false) +} + +func Printf(text string, args ...interface{}) { + if Default != nil { + Default.Printf(text, args...) + } +} + +func Print(text ...interface{}) { + if Default != nil { + Default.Print(text...) + } +} diff --git a/ui/types/message.go b/ui/types/message.go new file mode 100644 index 0000000..b69eab2 --- /dev/null +++ b/ui/types/message.go @@ -0,0 +1,85 @@ +// 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 . + +package types + +import ( + "regexp" + "strings" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" +) + +type Message struct { + ID string + Sender string + Text string + Timestamp string + Date string + + Buffer []string + SenderColor tcell.Color +} + +func NewMessage(id, sender, text, timestamp, date string, senderColor tcell.Color) *Message { + return &Message{ + ID: id, + Sender: sender, + Text: text, + Timestamp: timestamp, + Date: date, + SenderColor: senderColor, + } +} + +var ( + boundaryPattern = regexp.MustCompile("([[:punct:]]\\s*|\\s+)") + spacePattern = regexp.MustCompile(`\s+`) +) + +func (message *Message) CalculateBuffer(width int) { + if width < 1 { + return + } + message.Buffer = []string{} + forcedLinebreaks := strings.Split(message.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):] + } + } +} diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 0000000..eab7642 --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,65 @@ +// 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 . + +package ui + +import ( + "github.com/gdamore/tcell" + "maunium.net/go/gomuks/interface" + "maunium.net/go/tview" +) + +type GomuksUI struct { + gmx ifc.Gomuks + app *tview.Application + views *tview.Pages + + mainView *MainView + loginView *tview.Form +} + +func init() { + tview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault + tview.Styles.ContrastBackgroundColor = tcell.ColorDarkGreen +} + +func NewGomuksUI(gmx ifc.Gomuks) (ui *GomuksUI) { + ui = &GomuksUI{ + gmx: gmx, + app: gmx.App(), + views: tview.NewPages(), + } + ui.views.SetChangedFunc(ui.Render) + return +} + +func (ui *GomuksUI) Render() { + ui.app.Draw() +} + +func (ui *GomuksUI) SetView(name ifc.View) { + ui.views.SwitchToPage(string(name)) +} + +func (ui *GomuksUI) InitViews() tview.Primitive { + ui.views.AddPage(string(ifc.ViewLogin), ui.NewLoginView(), true, true) + ui.views.AddPage(string(ifc.ViewMain), ui.NewMainView(), true, false) + return ui.views +} + +func (ui *GomuksUI) MainView() ifc.MainView { + return ui.mainView +} diff --git a/ui/view-login.go b/ui/view-login.go new file mode 100644 index 0000000..2a19d3b --- /dev/null +++ b/ui/view-login.go @@ -0,0 +1,53 @@ +// 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 . + +package ui + +import ( + "maunium.net/go/gomuks/ui/debug" + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/tview" +) + +func (ui *GomuksUI) NewLoginView() tview.Primitive { + hs := ui.gmx.Config().HS + if len(hs) == 0 { + hs = "https://matrix.org" + } + + ui.loginView = tview.NewForm() + ui.loginView. + AddInputField("Homeserver", hs, 30, nil, nil). + AddInputField("Username", ui.gmx.Config().MXID, 30, nil, nil). + AddPasswordField("Password", "", 30, '*', nil). + AddButton("Log in", ui.login). + AddButton("Quit", ui.gmx.Stop). + SetButtonsAlign(tview.AlignCenter). + SetBorder(true).SetTitle("Log in to Matrix") + return widget.Center(45, 11, ui.loginView) +} + +func (ui *GomuksUI) login() { + hs := ui.loginView.GetFormItem(0).(*tview.InputField).GetText() + mxid := ui.loginView.GetFormItem(1).(*tview.InputField).GetText() + password := ui.loginView.GetFormItem(2).(*tview.InputField).GetText() + + debug.Printf("Logging into %s as %s...", hs, mxid) + ui.gmx.Config().HS = hs + mx := ui.gmx.MatrixContainer() + debug.Print("Connect result:", mx.InitClient()) + debug.Print("Login result:", mx.Login(mxid, password)) +} diff --git a/ui/view-main.go b/ui/view-main.go new file mode 100644 index 0000000..8ec482e --- /dev/null +++ b/ui/view-main.go @@ -0,0 +1,387 @@ +// 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 . + +package ui + +import ( + "fmt" + "sort" + "strings" + "time" + "unicode" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" + "maunium.net/go/gomatrix" + "maunium.net/go/gomuks/config" + "maunium.net/go/gomuks/interface" + "maunium.net/go/gomuks/ui/debug" + "maunium.net/go/gomuks/ui/types" + "maunium.net/go/gomuks/ui/widget" + "maunium.net/go/tview" +) + +type MainView struct { + *tview.Grid + + roomList *tview.List + roomView *tview.Pages + rooms map[string]*widget.RoomView + input *widget.AdvancedInputField + currentRoomIndex int + roomIDs []string + + matrix ifc.MatrixContainer + gmx ifc.Gomuks + config *config.Config + parent *GomuksUI +} + +func (view *MainView) addItem(p tview.Primitive, x, y, w, h int) { + view.Grid.AddItem(p, x, y, w, h, 0, 0, false) +} + +func (ui *GomuksUI) NewMainView() tview.Primitive { + mainView := &MainView{ + Grid: tview.NewGrid(), + roomList: tview.NewList(), + roomView: tview.NewPages(), + rooms: make(map[string]*widget.RoomView), + input: widget.NewAdvancedInputField(), + + matrix: ui.gmx.MatrixContainer(), + gmx: ui.gmx, + config: ui.gmx.Config(), + parent: ui, + } + + mainView.SetColumns(30, 1, 0).SetRows(0, 1) + + mainView.roomList. + ShowSecondaryText(false). + SetSelectedBackgroundColor(tcell.ColorDarkGreen). + SetSelectedTextColor(tcell.ColorWhite). + SetBorderPadding(0, 0, 1, 0) + + mainView.input. + SetDoneFunc(mainView.InputDone). + SetChangedFunc(mainView.InputChanged). + SetTabCompleteFunc(mainView.InputTabComplete). + SetFieldBackgroundColor(tcell.ColorDefault). + SetPlaceholder("Send a message..."). + SetPlaceholderExtColor(tcell.ColorGray). + SetInputCapture(mainView.InputCapture) + + mainView.addItem(mainView.roomList, 0, 0, 2, 1) + mainView.addItem(widget.NewBorder(), 0, 1, 2, 1) + mainView.addItem(mainView.roomView, 0, 2, 1, 1) + mainView.AddItem(mainView.input, 1, 2, 1, 1, 0, 0, true) + + ui.mainView = mainView + + return mainView +} + +func (view *MainView) InputChanged(text string) { + if len(text) == 0 { + go view.matrix.SendTyping(view.CurrentRoomID(), false) + } else if text[0] != '/' { + go view.matrix.SendTyping(view.CurrentRoomID(), true) + } +} + +func findWordToTabComplete(text string) string { + output := "" + runes := []rune(text) + for i := len(runes) - 1; i >= 0; i-- { + if unicode.IsSpace(runes[i]) { + break + } + output = string(runes[i]) + output + } + return output +} + +func (view *MainView) InputTabComplete(text string, cursorOffset int) string { + roomView, _ := view.rooms[view.CurrentRoomID()] + if roomView != nil { + str := runewidth.Truncate(text, cursorOffset, "") + word := findWordToTabComplete(str) + userCompletions := roomView.AutocompleteUser(word) + if len(userCompletions) == 1 { + text = str[0:len(str)-len(word)] + userCompletions[0] + text[len(str):] + } else if len(userCompletions) > 1 && len(userCompletions) < 6 { + roomView.SetStatus(fmt.Sprintf("Completions: %s", strings.Join(userCompletions, ", "))) + } + } + return text +} + +func (view *MainView) InputDone(key tcell.Key) { + if key == tcell.KeyEnter { + room, text := view.CurrentRoomID(), view.input.GetText() + if len(text) == 0 { + return + } else if text[0] == '/' { + args := strings.SplitN(text, " ", 2) + command := strings.ToLower(args[0]) + args = args[1:] + go view.HandleCommand(room, command, args) + } else { + go view.matrix.SendMessage(room, text) + } + view.input.SetText("") + } +} + +func (view *MainView) HandleCommand(room, command string, args []string) { + view.gmx.Recover() + debug.Print("Handling command", command, args) + switch command { + case "/quit": + view.gmx.Stop() + case "/clearcache": + view.config.Session.Clear() + view.gmx.Stop() + case "/part": + fallthrough + case "/leave": + debug.Print(view.matrix.LeaveRoom(room)) + case "/join": + if len(args) == 0 { + view.AddServiceMessage(room, "Usage: /join ") + break + } + debug.Print(view.matrix.JoinRoom(args[0])) + default: + view.AddServiceMessage(room, "Unknown command.") + } +} + +func (view *MainView) InputCapture(key *tcell.EventKey) *tcell.EventKey { + k := key.Key() + if key.Modifiers() == tcell.ModCtrl { + if k == tcell.KeyDown { + view.SwitchRoom(view.currentRoomIndex + 1) + view.roomList.SetCurrentItem(view.currentRoomIndex) + } else if k == tcell.KeyUp { + view.SwitchRoom(view.currentRoomIndex - 1) + view.roomList.SetCurrentItem(view.currentRoomIndex) + } else { + return key + } + } else if k == tcell.KeyPgUp || k == tcell.KeyPgDn { + msgView := view.rooms[view.CurrentRoomID()].MessageView() + if k == tcell.KeyPgUp { + msgView.PageUp() + } else { + msgView.PageDown() + } + } else { + return key + } + return nil +} + +func (view *MainView) CurrentRoomID() string { + if len(view.roomIDs) == 0 { + return "" + } + return view.roomIDs[view.currentRoomIndex] +} + +func (view *MainView) SwitchRoom(roomIndex int) { + if roomIndex < 0 { + roomIndex = len(view.roomIDs) - 1 + } + view.currentRoomIndex = roomIndex % len(view.roomIDs) + view.roomView.SwitchToPage(view.CurrentRoomID()) + view.roomList.SetCurrentItem(roomIndex) + view.parent.Render() +} + +func (view *MainView) addRoom(index int, room string) { + roomStore := view.matrix.GetRoom(room) + + view.roomList.AddItem(roomStore.GetTitle(), "", 0, func() { + view.SwitchRoom(index) + }) + if !view.roomView.HasPage(room) { + roomView := widget.NewRoomView(view, roomStore) + view.rooms[room] = roomView + view.roomView.AddPage(room, roomView, true, false) + roomView.UpdateUserList() + view.GetHistory(room) + } +} + +func (view *MainView) GetRoom(id string) *widget.RoomView { + return view.rooms[id] +} + +func (view *MainView) HasRoom(room string) bool { + for _, existingRoom := range view.roomIDs { + if existingRoom == room { + return true + } + } + return false +} + +func (view *MainView) AddRoom(room string) { + if view.HasRoom(room) { + return + } + view.roomIDs = append(view.roomIDs, room) + view.addRoom(len(view.roomIDs)-1, room) +} + +func (view *MainView) RemoveRoom(room string) { + if !view.HasRoom(room) { + return + } + removeIndex := 0 + if view.CurrentRoomID() == room { + removeIndex = view.currentRoomIndex + view.SwitchRoom(view.currentRoomIndex - 1) + } else { + removeIndex = sort.StringSlice(view.roomIDs).Search(room) + } + view.roomList.RemoveItem(removeIndex) + view.roomIDs = append(view.roomIDs[:removeIndex], view.roomIDs[removeIndex+1:]...) + view.roomView.RemovePage(room) + delete(view.rooms, room) + view.Render() +} + +func (view *MainView) SetRooms(rooms []string) { + view.roomIDs = rooms + view.roomList.Clear() + view.roomView.Clear() + view.rooms = make(map[string]*widget.RoomView) + for index, room := range rooms { + view.addRoom(index, room) + } + view.SwitchRoom(0) +} + +func (view *MainView) SetTyping(room string, users []string) { + roomView, ok := view.rooms[room] + if ok { + roomView.SetTyping(users) + view.parent.Render() + } +} + +func (view *MainView) AddServiceMessage(room, message string) { + roomView, ok := view.rooms[room] + if ok { + messageView := roomView.MessageView() + message := messageView.NewMessage("", "*", message, time.Now()) + messageView.AddMessage(message, widget.AppendMessage) + view.parent.Render() + } +} + +func (view *MainView) Render() { + view.parent.Render() +} + +func (view *MainView) GetHistory(room string) { + roomView := view.rooms[room] + history, _, err := view.matrix.GetHistory(roomView.Room.ID, view.config.Session.NextBatch, 50) + if err != nil { + debug.Print("Failed to fetch history for", roomView.Room.ID, err) + return + } + for _, evt := range history { + var room *widget.RoomView + var message *types.Message + if evt.Type == "m.room.message" { + room, message = view.ProcessMessageEvent(&evt) + } else if evt.Type == "m.room.member" { + room, message = view.ProcessMembershipEvent(&evt, false) + } + if room != nil && message != nil { + room.AddMessage(message, widget.PrependMessage) + } + } +} + +func (view *MainView) ProcessMessageEvent(evt *gomatrix.Event) (room *widget.RoomView, message *types.Message) { + room = view.GetRoom(evt.RoomID) + if room != nil { + text, _ := evt.Content["body"].(string) + message = room.NewMessage(evt.ID, evt.Sender, text, unixToTime(evt.Timestamp)) + } + return +} + +func (view *MainView) processOwnMembershipChange(evt *gomatrix.Event) { + membership, _ := evt.Content["membership"].(string) + prevMembership := "leave" + if evt.Unsigned.PrevContent != nil { + prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string) + } + if membership == prevMembership { + return + } + if membership == "join" { + view.AddRoom(evt.RoomID) + } else if membership == "leave" { + view.RemoveRoom(evt.RoomID) + } +} + +func (view *MainView) ProcessMembershipEvent(evt *gomatrix.Event, new bool) (room *widget.RoomView, message *types.Message) { + if new && evt.StateKey != nil && *evt.StateKey == view.config.Session.MXID { + view.processOwnMembershipChange(evt) + } + + room = view.GetRoom(evt.RoomID) + if room != nil { + membership, _ := evt.Content["membership"].(string) + var sender, text string + if membership == "invite" { + sender = "---" + text = fmt.Sprintf("%s invited %s.", evt.Sender, *evt.StateKey) + } else if membership == "join" { + sender = "-->" + text = fmt.Sprintf("%s joined the room.", *evt.StateKey) + } else if membership == "leave" { + sender = "<--" + if evt.Sender != *evt.StateKey { + reason, _ := evt.Content["reason"].(string) + text = fmt.Sprintf("%s kicked %s: %s", evt.Sender, *evt.StateKey, reason) + } else { + text = fmt.Sprintf("%s left the room.", *evt.StateKey) + } + } else { + room = nil + return + } + message = room.NewMessage(evt.ID, sender, text, unixToTime(evt.Timestamp)) + } + return +} + +func unixToTime(unix int64) time.Time { + timestamp := time.Now() + if unix != 0 { + timestamp = time.Unix(unix/1000, unix%1000*1000) + } + return timestamp +} diff --git a/ui/widget/advanced-inputfield.go b/ui/widget/advanced-inputfield.go new file mode 100644 index 0000000..6928c27 --- /dev/null +++ b/ui/widget/advanced-inputfield.go @@ -0,0 +1,445 @@ +// 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 . + +package widget + +import ( + "math" + "regexp" + "strings" + "unicode/utf8" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" + "github.com/zyedidia/clipboard" + "maunium.net/go/tview" +) + +// AdvancedInputField is a multi-line user-editable text area. +// +// Use SetMaskCharacter() to hide input from onlookers (e.g. for password +// input). +type AdvancedInputField struct { + *tview.Box + + // Cursor position + cursorOffset int + viewOffset int + + // The text that was entered. + text string + + // The text to be displayed before the input area. + label string + + // The text to be displayed in the input area when "text" is empty. + placeholder string + + // The label color. + labelColor tcell.Color + + // The background color of the input area. + fieldBackgroundColor tcell.Color + + // The text color of the input area. + fieldTextColor tcell.Color + + // The text color of the placeholder. + placeholderTextColor tcell.Color + + // The screen width of the input area. A value of 0 means extend as much as + // possible. + fieldWidth int + + // A character to mask entered text (useful for password fields). A value of 0 + // disables masking. + maskCharacter rune + + // An optional function which may reject the last character that was entered. + accept func(text string, ch rune) bool + + // An optional function which is called when the input has changed. + changed func(text string) + + // An optional function which is called when the user indicated that they + // are done entering text. The key which was pressed is provided (enter or escape). + done func(tcell.Key) + + // An optional function which is called when the user presses tab. + tabComplete func(text string, cursorOffset int) string +} + +// NewAdvancedInputField returns a new input field. +func NewAdvancedInputField() *AdvancedInputField { + return &AdvancedInputField{ + Box: tview.NewBox(), + labelColor: tview.Styles.SecondaryTextColor, + fieldBackgroundColor: tview.Styles.ContrastBackgroundColor, + fieldTextColor: tview.Styles.PrimaryTextColor, + placeholderTextColor: tview.Styles.ContrastSecondaryTextColor, + } +} + +// SetText sets the current text of the input field. +func (field *AdvancedInputField) SetText(text string) *AdvancedInputField { + field.text = text + if field.changed != nil { + field.changed(text) + } + return field +} + +// GetText returns the current text of the input field. +func (field *AdvancedInputField) GetText() string { + return field.text +} + +// SetLabel sets the text to be displayed before the input area. +func (field *AdvancedInputField) SetLabel(label string) *AdvancedInputField { + field.label = label + return field +} + +// GetLabel returns the text to be displayed before the input area. +func (field *AdvancedInputField) GetLabel() string { + return field.label +} + +// SetPlaceholder sets the text to be displayed when the input text is empty. +func (field *AdvancedInputField) SetPlaceholder(text string) *AdvancedInputField { + field.placeholder = text + return field +} + +// SetLabelColor sets the color of the label. +func (field *AdvancedInputField) SetLabelColor(color tcell.Color) *AdvancedInputField { + field.labelColor = color + return field +} + +// SetFieldBackgroundColor sets the background color of the input area. +func (field *AdvancedInputField) SetFieldBackgroundColor(color tcell.Color) *AdvancedInputField { + field.fieldBackgroundColor = color + return field +} + +// SetFieldTextColor sets the text color of the input area. +func (field *AdvancedInputField) SetFieldTextColor(color tcell.Color) *AdvancedInputField { + field.fieldTextColor = color + return field +} + +// SetPlaceholderExtColor sets the text color of placeholder text. +func (field *AdvancedInputField) SetPlaceholderExtColor(color tcell.Color) *AdvancedInputField { + field.placeholderTextColor = color + return field +} + +// SetFormAttributes sets attributes shared by all form items. +func (field *AdvancedInputField) SetFormAttributes(label string, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { + field.label = label + field.labelColor = labelColor + field.SetBackgroundColor(bgColor) + field.fieldTextColor = fieldTextColor + field.fieldBackgroundColor = fieldBgColor + return field +} + +// SetFieldWidth sets the screen width of the input area. A value of 0 means +// extend as much as possible. +func (field *AdvancedInputField) SetFieldWidth(width int) *AdvancedInputField { + field.fieldWidth = width + return field +} + +// GetFieldWidth returns this primitive's field width. +func (field *AdvancedInputField) GetFieldWidth() int { + return field.fieldWidth +} + +// SetMaskCharacter sets a character that masks user input on a screen. A value +// of 0 disables masking. +func (field *AdvancedInputField) SetMaskCharacter(mask rune) *AdvancedInputField { + field.maskCharacter = mask + return field +} + +// SetAcceptanceFunc sets a handler which may reject the last character that was +// entered (by returning false). +// +// This package defines a number of variables Prefixed with AdvancedInputField which may +// be used for common input (e.g. numbers, maximum text length). +func (field *AdvancedInputField) SetAcceptanceFunc(handler func(textToCheck string, lastChar rune) bool) *AdvancedInputField { + field.accept = handler + return field +} + +// SetChangedFunc sets a handler which is called whenever the text of the input +// field has changed. It receives the current text (after the change). +func (field *AdvancedInputField) SetChangedFunc(handler func(text string)) *AdvancedInputField { + field.changed = handler + return field +} + +// SetDoneFunc sets a handler which is called when the user is done entering +// text. The callback function is provided with the key that was pressed, which +// is one of the following: +// +// - KeyEnter: Done entering text. +// - KeyEscape: Abort text input. +func (field *AdvancedInputField) SetDoneFunc(handler func(key tcell.Key)) *AdvancedInputField { + field.done = handler + return field +} + +func (field *AdvancedInputField) SetTabCompleteFunc(handler func(text string, cursorOffset int) string) *AdvancedInputField { + field.tabComplete = handler + return field +} + +// SetFinishedFunc calls SetDoneFunc(). +func (field *AdvancedInputField) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem { + return field.SetDoneFunc(handler) +} + +// Draw draws this primitive onto the screen. +func (field *AdvancedInputField) Draw(screen tcell.Screen) { + field.Box.Draw(screen) + + // Prepare + x, y, width, height := field.GetInnerRect() + rightLimit := x + width + if height < 1 || rightLimit <= x { + return + } + + // Draw label. + _, drawnWidth := tview.Print(screen, field.label, x, y, rightLimit-x, tview.AlignLeft, field.labelColor) + x += drawnWidth + + // Draw input area. + fieldWidth := field.fieldWidth + if fieldWidth == 0 { + fieldWidth = math.MaxInt32 + } + if rightLimit-x < fieldWidth { + fieldWidth = rightLimit - x + } + fieldStyle := tcell.StyleDefault.Background(field.fieldBackgroundColor) + for index := 0; index < fieldWidth; index++ { + screen.SetContent(x+index, y, ' ', nil, fieldStyle) + } + + text := field.text + if text == "" && field.placeholder != "" { + tview.Print(screen, field.placeholder, x, y, fieldWidth, tview.AlignLeft, field.placeholderTextColor) + } + + // Draw entered text. + if field.maskCharacter > 0 { + text = strings.Repeat(string(field.maskCharacter), utf8.RuneCountInString(field.text)) + } + textWidth := runewidth.StringWidth(text) + if field.cursorOffset >= textWidth { + fieldWidth-- + } + + // Recalculate view offset + if field.cursorOffset < field.viewOffset { + field.viewOffset = field.cursorOffset + } else if field.cursorOffset > field.viewOffset+fieldWidth { + field.viewOffset = field.cursorOffset - fieldWidth + } else if textWidth-field.viewOffset < fieldWidth { + field.viewOffset = textWidth - fieldWidth + } + // Make sure view offset didn't become negative + if field.viewOffset < 0 { + field.viewOffset = 0 + } + + // Draw entered text. + runes := []rune(text) + relPos := 0 + for pos := field.viewOffset; pos <= fieldWidth+field.viewOffset && pos < len(runes); pos++ { + ch := runes[pos] + w := runewidth.RuneWidth(ch) + _, _, style, _ := screen.GetContent(x+relPos, y) + style = style.Foreground(field.fieldTextColor) + for w > 0 { + screen.SetContent(x+relPos, y, ch, nil, style) + relPos++ + w-- + } + } + + // Set cursor. + if field.GetFocusable().HasFocus() { + field.setCursor(screen) + } +} + +func (field *AdvancedInputField) GetCursorOffset() int { + return field.cursorOffset +} + +func (field *AdvancedInputField) SetCursorOffset(offset int) *AdvancedInputField { + if offset < 0 { + offset = 0 + } else { + width := runewidth.StringWidth(field.text) + if offset >= width { + offset = width + } + } + field.cursorOffset = offset + return field +} + +// setCursor sets the cursor position. +func (field *AdvancedInputField) setCursor(screen tcell.Screen) { + x, y, width, _ := field.GetRect() + origX, origY := x, y + rightLimit := x + width + if field.HasBorder() { + x++ + y++ + rightLimit -= 2 + } + fieldWidth := runewidth.StringWidth(field.text) + if field.fieldWidth > 0 && fieldWidth > field.fieldWidth-1 { + fieldWidth = field.fieldWidth - 1 + } + x = x + tview.StringWidth(field.label) + field.cursorOffset - field.viewOffset + if x >= rightLimit { + x = rightLimit - 1 + } else if x < origX { + x = origY + } + screen.ShowCursor(x, y) +} + +var ( + lastWord = regexp.MustCompile(`\S+\s*$`) + firstWord = regexp.MustCompile(`^\s*\S+`) +) + +func SubstringBefore(s string, w int) string { + return runewidth.Truncate(s, w, "") +} + +// InputHandler returns the handler for this primitive. +func (field *AdvancedInputField) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return field.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + currentText := field.text + defer func() { + // Trigger changed events. + if field.text != currentText && field.changed != nil { + field.changed(field.text) + } + // Make sure cursor offset is valid + if field.cursorOffset < 0 { + field.cursorOffset = 0 + } + width := runewidth.StringWidth(field.text) + if field.cursorOffset > width { + field.cursorOffset = width + } + }() + + // Process key event. + switch key := event.Key(); key { + case tcell.KeyRune: // Regular character. + leftPart := SubstringBefore(field.text, field.cursorOffset) + newText := leftPart + string(event.Rune()) + field.text[len(leftPart):] + if field.accept != nil { + if !field.accept(newText, event.Rune()) { + break + } + } + field.text = newText + field.cursorOffset += runewidth.RuneWidth(event.Rune()) + case tcell.KeyCtrlV: + clip, _ := clipboard.ReadAll("clipboard") + leftPart := SubstringBefore(field.text, field.cursorOffset) + field.text = leftPart + clip + field.text[len(leftPart):] + field.cursorOffset += runewidth.StringWidth(clip) + case tcell.KeyLeft: // Move cursor left. + before := SubstringBefore(field.text, field.cursorOffset) + if event.Modifiers() == tcell.ModCtrl { + found := lastWord.FindString(before) + field.cursorOffset -= runewidth.StringWidth(found) + } else if len(before) > 0 { + beforeRunes := []rune(before) + char := beforeRunes[len(beforeRunes)-1] + field.cursorOffset -= runewidth.RuneWidth(char) + } + case tcell.KeyRight: // Move cursor right. + before := SubstringBefore(field.text, field.cursorOffset) + after := field.text[len(before):] + if event.Modifiers() == tcell.ModCtrl { + found := firstWord.FindString(after) + field.cursorOffset += runewidth.StringWidth(found) + } else if len(after) > 0 { + char := []rune(after)[0] + field.cursorOffset += runewidth.RuneWidth(char) + } + case tcell.KeyDelete: // Delete next character. + if field.cursorOffset >= runewidth.StringWidth(field.text) { + break + } + leftPart := SubstringBefore(field.text, field.cursorOffset) + rightPart := field.text[len(leftPart):] + rightPartRunes := []rune(rightPart) + rightPartRunes = rightPartRunes[1:] + rightPart = string(rightPartRunes) + field.text = leftPart + rightPart + case tcell.KeyBackspace, tcell.KeyBackspace2: // Delete last character. + if field.cursorOffset == 0 { + break + } + if key == tcell.KeyBackspace { // Ctrl+backspace + leftPart := SubstringBefore(field.text, field.cursorOffset) + rightPart := field.text[len(leftPart):] + replacement := lastWord.ReplaceAllString(leftPart, "") + field.text = replacement + rightPart + + field.cursorOffset -= runewidth.StringWidth(leftPart) - runewidth.StringWidth(replacement) + } else { // Just backspace + leftPart := SubstringBefore(field.text, field.cursorOffset) + rightPart := field.text[len(leftPart):] + leftPartRunes := []rune(leftPart) + leftPartRunes = leftPartRunes[0 : len(leftPartRunes)-1] + leftPart = string(leftPartRunes) + removedChar := field.text[len(leftPart) : len(field.text)-len(rightPart)] + field.text = leftPart + rightPart + field.cursorOffset -= runewidth.StringWidth(removedChar) + } + case tcell.KeyTab: // Tab-completion + if field.tabComplete != nil { + oldWidth := runewidth.StringWidth(field.text) + field.text = field.tabComplete(field.text, field.cursorOffset) + newWidth := runewidth.StringWidth(field.text) + if oldWidth != newWidth { + field.cursorOffset += newWidth - oldWidth + } + } + case tcell.KeyEnter, tcell.KeyEscape: // We're done. + if field.done != nil { + field.done(key) + } + } + }) +} diff --git a/ui/widget/border.go b/ui/widget/border.go new file mode 100644 index 0000000..a32f4dd --- /dev/null +++ b/ui/widget/border.go @@ -0,0 +1,44 @@ +// 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 . + +package widget + +import ( + "github.com/gdamore/tcell" + "maunium.net/go/tview" +) + +type Border struct { + *tview.Box +} + +func NewBorder() *Border { + return &Border{tview.NewBox()} +} + +func (border *Border) Draw(screen tcell.Screen) { + background := tcell.StyleDefault.Background(border.GetBackgroundColor()).Foreground(border.GetBorderColor()) + x, y, width, height := border.GetRect() + if width == 1 { + for borderY := y; borderY < y+height; borderY++ { + screen.SetContent(x, borderY, tview.GraphicsVertBar, nil, background) + } + } else if height == 1 { + for borderX := x; borderX < x+width; borderX++ { + screen.SetContent(borderX, y, tview.GraphicsHoriBar, nil, background) + } + } +} diff --git a/ui/widget/center.go b/ui/widget/center.go new file mode 100644 index 0000000..41181a2 --- /dev/null +++ b/ui/widget/center.go @@ -0,0 +1,32 @@ +// 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 . + +package widget + +import ( + "maunium.net/go/tview" +) + +func Center(width, height int, p tview.Primitive) tview.Primitive { + return tview.NewFlex(). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(p, height, 1, true). + AddItem(tview.NewBox(), 0, 1, false), width, 1, true). + AddItem(tview.NewBox(), 0, 1, false) +} diff --git a/ui/widget/color.go b/ui/widget/color.go new file mode 100644 index 0000000..874b93d --- /dev/null +++ b/ui/widget/color.go @@ -0,0 +1,60 @@ +// 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 . + +package widget + +import ( + "fmt" + "hash/fnv" + "sort" + + "github.com/gdamore/tcell" +) + +var colorNames []string + +func init() { + colorNames = make([]string, len(tcell.ColorNames)) + i := 0 + for name, _ := range tcell.ColorNames { + colorNames[i] = name + i++ + } + sort.Sort(sort.StringSlice(colorNames)) +} + +func GetHashColorName(s string) string { + switch s { + case "-->": + return "green" + case "<--": + return "red" + case "---": + return "yellow" + default: + h := fnv.New32a() + h.Write([]byte(s)) + return colorNames[int(h.Sum32())%len(colorNames)] + } +} + +func GetHashColor(s string) tcell.Color { + return tcell.ColorNames[GetHashColorName(s)] +} + +func AddHashColor(s string) string { + return fmt.Sprintf("[%s]%s[white]", GetHashColorName(s), s) +} diff --git a/ui/widget/form-text-view.go b/ui/widget/form-text-view.go new file mode 100644 index 0000000..58046e9 --- /dev/null +++ b/ui/widget/form-text-view.go @@ -0,0 +1,44 @@ +// 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 . + +package widget + +import ( + "github.com/gdamore/tcell" + "maunium.net/go/tview" +) + +type FormTextView struct { + *tview.TextView +} + +func (ftv *FormTextView) GetLabel() string { + return "" +} + +func (ftv *FormTextView) SetFormAttributes(label string, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { + return ftv +} + +func (ftv *FormTextView) GetFieldWidth() int { + _, _, w, _ := ftv.TextView.GetRect() + return w +} + +func (ftv *FormTextView) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem { + ftv.SetDoneFunc(handler) + return ftv +} diff --git a/ui/widget/message-view.go b/ui/widget/message-view.go new file mode 100644 index 0000000..3503a5f --- /dev/null +++ b/ui/widget/message-view.go @@ -0,0 +1,293 @@ +// 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 . + +package widget + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell" + "github.com/mattn/go-runewidth" + "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 + Separator rune + + widestSender int + prevWidth int + prevHeight int + prevScrollOffset int + firstDisplayMessage int + lastDisplayMessage int + totalHeight int + + messageIDs map[string]bool + messages []*types.Message +} + +func NewMessageView() *MessageView { + return &MessageView{ + Box: tview.NewBox(), + MaxSenderWidth: 20, + DateFormat: "January _2, 2006", + TimestampFormat: "15:04:05", + TimestampWidth: 8, + Separator: '|', + ScrollOffset: 0, + + messages: make([]*types.Message, 0), + messageIDs: make(map[string]bool), + + widestSender: 5, + prevWidth: -1, + prevHeight: -1, + prevScrollOffset: -1, + firstDisplayMessage: -1, + lastDisplayMessage: -1, + totalHeight: -1, + } +} + +func (view *MessageView) NewMessage(id, sender, text string, timestamp time.Time) *types.Message { + return types.NewMessage(id, sender, text, + timestamp.Format(view.TimestampFormat), + timestamp.Format(view.DateFormat), + GetHashColor(sender)) +} + +func (view *MessageView) recalculateBuffers() { + _, _, width, _ := view.GetInnerRect() + width -= view.TimestampWidth + TimestampSenderGap + view.widestSender + SenderMessageGap + if width != view.prevWidth { + for _, message := range view.messages { + message.CalculateBuffer(width) + } + view.prevWidth = width + } +} + +func (view *MessageView) updateWidestSender(sender string) { + if len(sender) > view.widestSender { + view.widestSender = len(sender) + if view.widestSender > view.MaxSenderWidth { + view.widestSender = view.MaxSenderWidth + } + } +} + +const ( + AppendMessage int = iota + PrependMessage +) + +func (view *MessageView) AddMessage(message *types.Message, direction int) { + _, messageExists := view.messageIDs[message.ID] + if messageExists { + return + } + + 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 += len(message.Buffer) + } + view.messages = append(view.messages, message) + } else if direction == PrependMessage { + view.messages = append([]*types.Message{message}, view.messages...) + } + + view.messageIDs[message.ID] = true + view.recalculateHeight() +} + +func (view *MessageView) recalculateHeight() { + _, _, width, height := view.GetInnerRect() + if height != view.prevHeight || width != view.prevWidth || view.ScrollOffset != view.prevScrollOffset { + view.firstDisplayMessage = -1 + view.lastDisplayMessage = -1 + view.totalHeight = 0 + prevDate := "" + for i := len(view.messages) - 1; i >= 0; i-- { + prevTotalHeight := view.totalHeight + message := view.messages[i] + view.totalHeight += len(message.Buffer) + if message.Date != prevDate { + if len(prevDate) != 0 { + view.totalHeight++ + } + prevDate = message.Date + } + + if view.totalHeight < view.ScrollOffset { + continue + } else if view.firstDisplayMessage == -1 { + view.lastDisplayMessage = i + view.firstDisplayMessage = i + } + + if prevTotalHeight < height+view.ScrollOffset { + view.lastDisplayMessage = i + } + } + view.prevScrollOffset = view.ScrollOffset + } +} + +func (view *MessageView) PageUp() { + _, _, _, height := view.GetInnerRect() + view.ScrollOffset += height / 2 + if view.ScrollOffset > view.totalHeight-height { + view.ScrollOffset = view.totalHeight - height + 5 + } +} + +func (view *MessageView) PageDown() { + _, _, _, height := view.GetInnerRect() + view.ScrollOffset -= height / 2 + if view.ScrollOffset < 0 { + view.ScrollOffset = 0 + } +} + +func (view *MessageView) writeLine(screen tcell.Screen, line string, x, y int, color tcell.Color) { + offsetX := 0 + for _, ch := range line { + chWidth := runewidth.RuneWidth(ch) + if chWidth == 0 { + continue + } + + for localOffset := 0; localOffset < chWidth; localOffset++ { + screen.SetContent(x+offsetX+localOffset, y, ch, nil, tcell.StyleDefault.Foreground(color)) + } + offsetX += chWidth + } +} + +func (view *MessageView) writeLineRight(screen tcell.Screen, line string, x, y, maxWidth int, color tcell.Color) { + offsetX := maxWidth - runewidth.StringWidth(line) + if offsetX < 0 { + offsetX = 0 + } + for _, ch := range line { + chWidth := runewidth.RuneWidth(ch) + if chWidth == 0 { + continue + } + + for localOffset := 0; localOffset < chWidth; localOffset++ { + screen.SetContent(x+offsetX+localOffset, y, ch, nil, tcell.StyleDefault.Foreground(color)) + } + offsetX += chWidth + if offsetX > maxWidth { + break + } + } +} + +const ( + TimestampSenderGap = 1 + SenderSeparatorGap = 1 + SenderMessageGap = 3 +) + +func (view *MessageView) Draw(screen tcell.Screen) { + view.Box.Draw(screen) + + x, y, _, height := view.GetInnerRect() + view.recalculateBuffers() + view.recalculateHeight() + + if view.firstDisplayMessage == -1 || view.lastDisplayMessage == -1 { + view.writeLine(screen, "It's quite empty in here.", x, y+height, tcell.ColorDefault) + return + } + + usernameOffsetX := view.TimestampWidth + TimestampSenderGap + messageOffsetX := usernameOffsetX + view.widestSender + SenderMessageGap + separatorX := x + usernameOffsetX + view.widestSender + SenderSeparatorGap + for separatorY := y; separatorY < y+height; separatorY++ { + screen.SetContent(separatorX, separatorY, view.Separator, nil, tcell.StyleDefault) + } + + writeOffset := 0 + prevDate := "" + prevSender := "" + prevSenderLine := -1 + for i := view.firstDisplayMessage; i >= view.lastDisplayMessage; i-- { + message := view.messages[i] + messageHeight := len(message.Buffer) + + // Show message when the date changes. + if message.Date != prevDate { + if len(prevDate) > 0 { + writeOffset++ + view.writeLine( + screen, fmt.Sprintf("Date changed to %s", prevDate), + x+messageOffsetX, y+height-writeOffset, tcell.ColorGreen) + } + prevDate = message.Date + } + + senderAtLine := y + height - writeOffset - messageHeight + // The message may be only partially on screen, so we need to make sure the sender + // is on screen even when the message is not shown completely. + if senderAtLine < y { + senderAtLine = y + } + + view.writeLine(screen, message.Timestamp, x, senderAtLine, tcell.ColorDefault) + view.writeLineRight(screen, message.Sender, + x+usernameOffsetX, senderAtLine, + view.widestSender, message.SenderColor) + + if message.Sender == prevSender { + // Sender is same as previous. We're looping from bottom to top, and we want the + // sender name only on the topmost message, so clear out the duplicate sender name + // below. + view.writeLineRight(screen, strings.Repeat(" ", view.widestSender), + x+usernameOffsetX, prevSenderLine, + view.widestSender, message.SenderColor) + } + prevSender = message.Sender + prevSenderLine = senderAtLine + + for num, line := range message.Buffer { + offsetY := height - messageHeight - writeOffset + num + // Only render message if it's within the message view. + if offsetY >= 0 { + view.writeLine(screen, line, x+messageOffsetX, y+offsetY, tcell.ColorDefault) + } + } + writeOffset += messageHeight + } +} diff --git a/ui/widget/room-view.go b/ui/widget/room-view.go new file mode 100644 index 0000000..eeab7b2 --- /dev/null +++ b/ui/widget/room-view.go @@ -0,0 +1,152 @@ +// 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 . + +package widget + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell" + rooms "maunium.net/go/gomuks/matrix/room" + "maunium.net/go/gomuks/ui/types" + "maunium.net/go/tview" +) + +type Renderable interface { + Render() +} + +type RoomView struct { + *tview.Box + + topic *tview.TextView + content *MessageView + status *tview.TextView + userList *tview.TextView + Room *rooms.Room + + parent Renderable +} + +func NewRoomView(parent Renderable, room *rooms.Room) *RoomView { + view := &RoomView{ + Box: tview.NewBox(), + topic: tview.NewTextView(), + content: NewMessageView(), + status: tview.NewTextView(), + userList: tview.NewTextView(), + Room: room, + parent: parent, + } + view.topic. + SetText(strings.Replace(room.GetTopic(), "\n", " ", -1)). + SetBackgroundColor(tcell.ColorDarkGreen) + view.status.SetBackgroundColor(tcell.ColorDimGray) + view.userList.SetDynamicColors(true) + return view +} + +func (view *RoomView) Draw(screen tcell.Screen) { + view.Box.Draw(screen) + + x, y, width, height := view.GetRect() + view.topic.SetRect(x, y, width, 1) + view.content.SetRect(x, y+1, width-30, height-2) + view.status.SetRect(x, y+height-1, width, 1) + view.userList.SetRect(x+width-29, y+1, 29, height-2) + + view.topic.Draw(screen) + view.content.Draw(screen) + view.status.Draw(screen) + + borderX := x + width - 30 + background := tcell.StyleDefault.Background(view.GetBackgroundColor()).Foreground(view.GetBorderColor()) + for borderY := y + 1; borderY < y+height-1; borderY++ { + screen.SetContent(borderX, borderY, tview.GraphicsVertBar, nil, background) + } + view.userList.Draw(screen) +} + +func (view *RoomView) SetStatus(status string) { + view.status.SetText(status) +} + +func (view *RoomView) SetTyping(users []string) { + for index, user := range users { + member := view.Room.GetMember(user) + if member != nil { + users[index] = member.DisplayName + } + } + if len(users) == 0 { + view.status.SetText("") + } else if len(users) < 2 { + view.status.SetText("Typing: " + strings.Join(users, " and ")) + } else { + view.status.SetText(fmt.Sprintf( + "Typing: %s and %s", + strings.Join(users[:len(users)-1], ", "), users[len(users)-1])) + } +} + +func (view *RoomView) AutocompleteUser(existingText string) (completions []string) { + for _, user := range view.Room.GetMembers() { + if strings.HasPrefix(user.DisplayName, existingText) { + completions = append(completions, user.DisplayName) + } else if strings.HasPrefix(user.UserID, existingText) { + completions = append(completions, user.UserID) + } + } + return +} + +func (view *RoomView) MessageView() *MessageView { + return view.content +} + +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.WriteRune('\n') + } else if user.Membership == "invite" { + invited.WriteString(AddHashColor(user.DisplayName)) + invited.WriteRune('\n') + } + } + view.userList.Clear() + fmt.Fprintf(view.userList, "%s\n", joined.String()) + if invited.Len() > 0 { + fmt.Fprintf(view.userList, "\nInvited:\n%s", invited.String()) + } +} + +func (view *RoomView) NewMessage(id, sender, text string, timestamp time.Time) *types.Message { + member := view.Room.GetMember(sender) + if member != nil { + sender = member.DisplayName + } + return view.content.NewMessage(id, sender, text, timestamp) +} + +func (view *RoomView) AddMessage(message *types.Message, direction int) { + view.content.AddMessage(message, direction) + view.parent.Render() +} diff --git a/uiutil.go b/uiutil.go deleted file mode 100644 index 0ba37ef..0000000 --- a/uiutil.go +++ /dev/null @@ -1,55 +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 . - -package main - -import ( - "github.com/gdamore/tcell" - "maunium.net/go/tview" -) - -func Center(width, height int, p tview.Primitive) tview.Primitive { - return tview.NewFlex(). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(p, height, 1, true). - AddItem(tview.NewBox(), 0, 1, false), width, 1, true). - AddItem(tview.NewBox(), 0, 1, false) -} - -type FormTextView struct { - *tview.TextView -} - -func (ftv *FormTextView) GetLabel() string { - return "" -} - -func (ftv *FormTextView) SetFormAttributes(label string, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { - return ftv -} - -func (ftv *FormTextView) GetFieldWidth() int { - _, _, w, _ := ftv.TextView.GetRect() - return w -} - -func (ftv *FormTextView) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem { - ftv.SetDoneFunc(handler) - return ftv -} diff --git a/view-login.go b/view-login.go deleted file mode 100644 index 0c18fbc..0000000 --- a/view-login.go +++ /dev/null @@ -1,50 +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 . - -package main - -import ( - "maunium.net/go/tview" -) - -func (ui *GomuksUI) NewLoginView() tview.Primitive { - hs := ui.config.HS - if len(hs) == 0 { - hs = "https://matrix.org" - } - - ui.loginView = tview.NewForm() - ui.loginView. - AddInputField("Homeserver", hs, 30, nil, nil). - AddInputField("Username", ui.config.MXID, 30, nil, nil). - AddPasswordField("Password", "", 30, '*', nil). - AddButton("Log in", ui.login). - AddButton("Quit", ui.gmx.Stop). - SetButtonsAlign(tview.AlignCenter). - SetBorder(true).SetTitle("Log in to Matrix") - return Center(45, 11, ui.loginView) -} - -func (ui *GomuksUI) login() { - hs := ui.loginView.GetFormItem(0).(*tview.InputField).GetText() - mxid := ui.loginView.GetFormItem(1).(*tview.InputField).GetText() - password := ui.loginView.GetFormItem(2).(*tview.InputField).GetText() - - ui.debug.Printf("Logging into %s as %s...", hs, mxid) - ui.config.HS = hs - ui.debug.Print("Connect result:", ui.matrix.InitClient()) - ui.debug.Print("Login result:", ui.matrix.Login(mxid, password)) -} diff --git a/view-main.go b/view-main.go deleted file mode 100644 index 2fd503a..0000000 --- a/view-main.go +++ /dev/null @@ -1,385 +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 . - -package main - -import ( - "fmt" - "sort" - "strings" - "time" - "unicode" - - "github.com/gdamore/tcell" - "github.com/mattn/go-runewidth" - "maunium.net/go/gomatrix" - "maunium.net/go/tview" -) - -type MainView struct { - *tview.Grid - - roomList *tview.List - roomView *tview.Pages - rooms map[string]*RoomView - input *AdvancedInputField - currentRoomIndex int - roomIDs []string - - matrix *MatrixContainer - debug DebugPrinter - gmx Gomuks - config *Config - parent *GomuksUI -} - -func (view *MainView) addItem(p tview.Primitive, x, y, w, h int) { - view.Grid.AddItem(p, x, y, w, h, 0, 0, false) -} - -func (ui *GomuksUI) NewMainView() tview.Primitive { - mainView := &MainView{ - Grid: tview.NewGrid(), - roomList: tview.NewList(), - roomView: tview.NewPages(), - rooms: make(map[string]*RoomView), - input: NewAdvancedInputField(), - - matrix: ui.matrix, - debug: ui.debug, - gmx: ui.gmx, - config: ui.config, - parent: ui, - } - - mainView.SetColumns(30, 1, 0).SetRows(0, 1) - - mainView.roomList. - ShowSecondaryText(false). - SetSelectedBackgroundColor(tcell.ColorDarkGreen). - SetSelectedTextColor(tcell.ColorWhite). - SetBorderPadding(0, 0, 1, 0) - - mainView.input. - SetDoneFunc(mainView.InputDone). - SetChangedFunc(mainView.InputChanged). - SetTabCompleteFunc(mainView.InputTabComplete). - SetFieldBackgroundColor(tcell.ColorDefault). - SetPlaceholder("Send a message..."). - SetPlaceholderExtColor(tcell.ColorGray). - SetInputCapture(mainView.InputCapture) - - mainView.addItem(mainView.roomList, 0, 0, 2, 1) - mainView.addItem(NewBorder(), 0, 1, 2, 1) - mainView.addItem(mainView.roomView, 0, 2, 1, 1) - mainView.AddItem(mainView.input, 1, 2, 1, 1, 0, 0, true) - - ui.mainView = mainView - - return mainView -} - -func (view *MainView) InputChanged(text string) { - if len(text) == 0 { - go view.matrix.SendTyping(view.CurrentRoomID(), false) - } else if text[0] != '/' { - go view.matrix.SendTyping(view.CurrentRoomID(), true) - } -} - -func findWordToTabComplete(text string) string { - output := "" - runes := []rune(text) - for i := len(runes) - 1; i >= 0; i-- { - if unicode.IsSpace(runes[i]) { - break - } - output = string(runes[i]) + output - } - return output -} - -func (view *MainView) InputTabComplete(text string, cursorOffset int) string { - roomView, _ := view.rooms[view.CurrentRoomID()] - if roomView != nil { - str := runewidth.Truncate(text, cursorOffset, "") - word := findWordToTabComplete(str) - userCompletions := roomView.AutocompleteUser(word) - if len(userCompletions) == 1 { - text = str[0:len(str)-len(word)] + userCompletions[0] + text[len(str):] - } else if len(userCompletions) > 1 && len(userCompletions) < 6 { - roomView.status.Clear() - fmt.Fprintf(roomView.status, "Completions: %s", strings.Join(userCompletions, ", ")) - } - } - return text -} - -func (view *MainView) InputDone(key tcell.Key) { - if key == tcell.KeyEnter { - room, text := view.CurrentRoomID(), view.input.GetText() - if len(text) == 0 { - return - } else if text[0] == '/' { - args := strings.SplitN(text, " ", 2) - command := strings.ToLower(args[0]) - args = args[1:] - go view.HandleCommand(room, command, args) - } else { - go view.matrix.SendMessage(room, text) - } - view.input.SetText("") - } -} - -func (view *MainView) HandleCommand(room, command string, args []string) { - view.gmx.Recover() - view.debug.Print("Handling command", command, args) - switch command { - case "/quit": - view.gmx.Stop() - case "/clearcache": - view.config.Session.Clear() - view.gmx.Stop() - case "/part": - fallthrough - case "/leave": - view.matrix.client.LeaveRoom(room) - case "/join": - if len(args) == 0 { - view.AddServiceMessage(room, "Usage: /join ") - break - } - view.debug.Print(view.matrix.JoinRoom(args[0])) - default: - view.AddServiceMessage(room, "Unknown command.") - } -} - -func (view *MainView) InputCapture(key *tcell.EventKey) *tcell.EventKey { - k := key.Key() - if key.Modifiers() == tcell.ModCtrl { - if k == tcell.KeyDown { - view.SwitchRoom(view.currentRoomIndex + 1) - view.roomList.SetCurrentItem(view.currentRoomIndex) - } else if k == tcell.KeyUp { - view.SwitchRoom(view.currentRoomIndex - 1) - view.roomList.SetCurrentItem(view.currentRoomIndex) - } else { - return key - } - } else if k == tcell.KeyPgUp || k == tcell.KeyPgDn { - msgView := view.rooms[view.CurrentRoomID()].MessageView() - if k == tcell.KeyPgUp { - msgView.PageUp() - } else { - msgView.PageDown() - } - } else { - return key - } - return nil -} - -func (view *MainView) CurrentRoomID() string { - if len(view.roomIDs) == 0 { - return "" - } - return view.roomIDs[view.currentRoomIndex] -} - -func (view *MainView) SwitchRoom(roomIndex int) { - if roomIndex < 0 { - roomIndex = len(view.roomIDs) - 1 - } - view.currentRoomIndex = roomIndex % len(view.roomIDs) - view.roomView.SwitchToPage(view.CurrentRoomID()) - view.roomList.SetCurrentItem(roomIndex) - view.parent.Render() -} - -func (view *MainView) addRoom(index int, room string) { - roomStore := view.matrix.GetRoom(room) - - view.roomList.AddItem(roomStore.GetTitle(), "", 0, func() { - view.SwitchRoom(index) - }) - if !view.roomView.HasPage(room) { - roomView := NewRoomView(view, roomStore) - view.rooms[room] = roomView - view.roomView.AddPage(room, roomView, true, false) - roomView.UpdateUserList() - view.GetHistory(room) - } -} - -func (view *MainView) GetRoom(id string) *RoomView { - return view.rooms[id] -} - -func (view *MainView) HasRoom(room string) bool { - for _, existingRoom := range view.roomIDs { - if existingRoom == room { - return true - } - } - return false -} - -func (view *MainView) AddRoom(room string) { - if view.HasRoom(room) { - return - } - view.roomIDs = append(view.roomIDs, room) - view.addRoom(len(view.roomIDs)-1, room) -} - -func (view *MainView) RemoveRoom(room string) { - if !view.HasRoom(room) { - return - } - removeIndex := 0 - if view.CurrentRoomID() == room { - removeIndex = view.currentRoomIndex - view.SwitchRoom(view.currentRoomIndex - 1) - } else { - removeIndex = sort.StringSlice(view.roomIDs).Search(room) - } - view.roomList.RemoveItem(removeIndex) - view.roomIDs = append(view.roomIDs[:removeIndex], view.roomIDs[removeIndex+1:]...) - view.roomView.RemovePage(room) - delete(view.rooms, room) - view.Render() -} - -func (view *MainView) SetRoomList(rooms []string) { - view.roomIDs = rooms - view.roomList.Clear() - view.roomView.Clear() - view.rooms = make(map[string]*RoomView) - for index, room := range rooms { - view.addRoom(index, room) - } - view.SwitchRoom(0) -} - -func (view *MainView) SetTyping(room string, users []string) { - roomView, ok := view.rooms[room] - if ok { - roomView.SetTyping(users) - view.parent.Render() - } -} - -func (view *MainView) AddServiceMessage(room, message string) { - roomView, ok := view.rooms[room] - if ok { - messageView := roomView.MessageView() - message := messageView.NewMessage("", "*", message, time.Now()) - messageView.AddMessage(message, AppendMessage) - view.parent.Render() - } -} - -func (view *MainView) Render() { - view.parent.Render() -} - -func (view *MainView) GetHistory(room string) { - roomView := view.rooms[room] - history, _, err := view.matrix.GetHistory(roomView.room.ID, view.config.Session.NextBatch, 50) - if err != nil { - view.debug.Print("Failed to fetch history for", roomView.room.ID, err) - return - } - for _, evt := range history { - var room *RoomView - var message *Message - if evt.Type == "m.room.message" { - room, message = view.ProcessMessageEvent(&evt) - } else if evt.Type == "m.room.member" { - room, message = view.ProcessMembershipEvent(&evt, false) - } - if room != nil && message != nil { - room.AddMessage(message, PrependMessage) - } - } -} - -func (view *MainView) ProcessMessageEvent(evt *gomatrix.Event) (room *RoomView, message *Message) { - room = view.GetRoom(evt.RoomID) - if room != nil { - text := evt.Content["body"].(string) - message = room.NewMessage(evt.ID, evt.Sender, text, unixToTime(evt.Timestamp)) - } - return -} - -func (view *MainView) processOwnMembershipChange(evt *gomatrix.Event) { - membership, _ := evt.Content["membership"].(string) - prevMembership := "leave" - if evt.Unsigned.PrevContent != nil { - prevMembership, _ = evt.Unsigned.PrevContent["membership"].(string) - } - if membership == prevMembership { - return - } - if membership == "join" { - view.AddRoom(evt.RoomID) - } else if membership == "leave" { - view.RemoveRoom(evt.RoomID) - } -} - -func (view *MainView) ProcessMembershipEvent(evt *gomatrix.Event, new bool) (room *RoomView, message *Message) { - if new && evt.StateKey != nil && *evt.StateKey == view.config.Session.MXID { - view.processOwnMembershipChange(evt) - } - - room = view.GetRoom(evt.RoomID) - if room != nil { - membership, _ := evt.Content["membership"].(string) - var sender, text string - if membership == "invite" { - sender = "---" - text = fmt.Sprintf("%s invited %s.", evt.Sender, *evt.StateKey) - } else if membership == "join" { - sender = "-->" - text = fmt.Sprintf("%s joined the room.", *evt.StateKey) - } else if membership == "leave" { - sender = "<--" - if evt.Sender != *evt.StateKey { - reason, _ := evt.Content["reason"].(string) - text = fmt.Sprintf("%s kicked %s: %s", evt.Sender, *evt.StateKey, reason) - } else { - text = fmt.Sprintf("%s left the room.", *evt.StateKey) - } - } else { - room = nil - return - } - message = room.NewMessage(evt.ID, sender, text, unixToTime(evt.Timestamp)) - } - return -} - -func unixToTime(unix int64) time.Time { - timestamp := time.Now() - if unix != 0 { - timestamp = time.Unix(unix/1000, unix%1000*1000) - } - return timestamp -} -- cgit v1.2.3-70-g09d2