diff --git a/config.go b/config.go index 3510e4a..f8696a4 100644 --- a/config.go +++ b/config.go @@ -29,14 +29,23 @@ type Config struct { MXID string `yaml:"mxid"` HS string `yaml:"homeserver"` - dir string `yaml:"-"` - Session *Session `yaml:"-"` + dir string `yaml:"-"` + gmx Gomuks `yaml:"-"` + debug DebugPrinter `yaml:"-"` + Session *Session `yaml:"-"` } -func (config *Config) Load(dir string) { - config.dir = dir - os.MkdirAll(dir, 0700) - configPath := filepath.Join(dir, "config.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) { @@ -55,16 +64,17 @@ func (config *Config) Load(dir string) { } func (config *Config) Save() { + os.MkdirAll(config.dir, 0700) data, err := yaml.Marshal(&config) if err != nil { - debug.Print("Failed to marshal config") + 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 { - debug.Print("Failed to write config to", path) + config.debug.Print("Failed to write config to", path) panic(err) } } diff --git a/debug.go b/debug.go index 2533d5c..2498f9d 100644 --- a/debug.go +++ b/debug.go @@ -22,35 +22,54 @@ import ( "github.com/rivo/tview" ) +const DebugPaneHeight = 40 + +type DebugPrinter interface { + Printf(text string, args ...interface{}) + Print(text ...interface{}) +} + type DebugPane struct { - text string 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.num++ - db.Write(fmt.Sprintf("[%d] %s\n", db.num, fmt.Sprintf(text, args...))) + db.Write(fmt.Sprintf(text, args...)) } func (db *DebugPane) Print(text ...interface{}) { - db.num++ - db.Write(fmt.Sprintf("[%d] %s", db.num, fmt.Sprintln(text...))) + db.Write(fmt.Sprint(text...)) } func (db *DebugPane) Write(text string) { if db.pane != nil { - db.text += text - db.pane.SetText(db.text) + db.num++ + fmt.Fprintf(db.pane, "[%d] %s\n", db.num, text) } } -func (db *DebugPane) Wrap(main *tview.Pages) tview.Primitive { - db.pane = tview.NewTextView() - db.pane.SetBorder(true).SetTitle("Debug output") - db.text += "[0] Debug pane initialized\n" - db.pane.SetText(db.text) - return tview.NewGrid().SetRows(0, 20).SetColumns(0). +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/gomuks.go b/gomuks.go index 8ffc2e9..ac7d534 100644 --- a/gomuks.go +++ b/gomuks.go @@ -20,94 +20,84 @@ import ( "os" "path/filepath" - "github.com/gdamore/tcell" + "github.com/matrix-org/gomatrix" "github.com/rivo/tview" ) -var matrix = new(MatrixContainer) -var config = new(Config) -var debug = new(DebugPane) +type Gomuks interface { + Debug() DebugPrinter + Matrix() *gomatrix.Client + MatrixContainer() *MatrixContainer + App() *tview.Application + UI() *GomuksUI + Config() *Config +} -func main() { +type gomuks struct { + app *tview.Application + ui *GomuksUI + matrix *MatrixContainer + debug *DebugPane + config *Config +} + +func NewGomuks(debug bool) *gomuks { configDir := filepath.Join(os.Getenv("HOME"), ".config/gomuks") - os.MkdirAll(configDir, 0700) - config.Load(configDir) - - views := tview.NewPages() - InitUI(views) - - main := debug.Wrap(views) - - if len(config.MXID) > 0 { - config.LoadSession(config.MXID) + gmx := &gomuks{ + app: tview.NewApplication(), } - matrix.Init(config) + gmx.debug = NewDebugPane(gmx) + gmx.config = NewConfig(gmx, configDir) + gmx.ui = NewGomuksUI(gmx) + gmx.matrix = NewMatrixContainer(gmx) + gmx.ui.matrix = gmx.matrix - if err := tview.NewApplication().SetRoot(main, true).Run(); err != nil { + gmx.config.Load() + if len(gmx.config.MXID) > 0 { + gmx.config.LoadSession(gmx.config.MXID) + } + + gmx.matrix.InitClient() + + main := gmx.ui.InitViews() + if debug { + main = gmx.debug.Wrap(main) + } + gmx.app.SetRoot(main, true) + + return gmx +} + +func (gmx *gomuks) Start() { + if err := gmx.app.Run(); err != nil { panic(err) } } -func InitUI(views *tview.Pages) { - views.AddPage("login", InitLoginUI(), true, true) +func (gmx *gomuks) Debug() DebugPrinter { + return gmx.debug } -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) +func (gmx *gomuks) Matrix() *gomatrix.Client { + return gmx.matrix.client } -type FormTextView struct { - *tview.TextView +func (gmx *gomuks) MatrixContainer() *MatrixContainer { + return gmx.matrix } -func (ftv *FormTextView) GetLabel() string { - return "" +func (gmx *gomuks) App() *tview.Application { + return gmx.app } -func (ftv *FormTextView) SetFormAttributes(label string, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem { - return ftv +func (gmx *gomuks) Config() *Config { + return gmx.config } -func (ftv *FormTextView) GetFieldWidth() int { - _, _, w, _ := ftv.TextView.GetRect() - return w +func (gmx *gomuks) UI() *GomuksUI { + return gmx.ui } -func (ftv *FormTextView) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem { - ftv.SetDoneFunc(handler) - return ftv -} - -func login(form *tview.Form) func() { - return func() { - hs := form.GetFormItem(0).(*tview.InputField).GetText() - mxid := form.GetFormItem(1).(*tview.InputField).GetText() - password := form.GetFormItem(2).(*tview.InputField).GetText() - debug.Printf("%s %s %s", hs, mxid, password) - config.HS = hs - debug.Print(matrix.Init(config)) - debug.Print(matrix.Login(mxid, password)) - } -} - -func InitLoginUI() tview.Primitive { - form := tview.NewForm().SetButtonsAlign(tview.AlignCenter) - hs := config.HS - if len(hs) == 0 { - hs = "https://matrix.org" - } - form. - AddInputField("Homeserver", hs, 30, nil, nil). - AddInputField("Username", config.MXID, 30, nil, nil). - AddPasswordField("Password", "", 30, '*', nil). - AddButton("Log in", login(form)) - form.SetBorder(true).SetTitle("Log in to Matrix") - return Center(45, 13, form) +func main() { + NewGomuks(true).Start() } diff --git a/matrix.go b/matrix.go index b559f17..17147be 100644 --- a/matrix.go +++ b/matrix.go @@ -23,27 +23,36 @@ import ( ) type MatrixContainer struct { - lient *gomatrix.Client + client *gomatrix.Client + gmx Gomuks + ui *GomuksUI + debug DebugPrinter config *Config running bool stop chan bool } -func (c *MatrixContainer) Initialized() bool { - return c.lient != nil -} - -func (c *MatrixContainer) Init(config *Config) error { - c.config = config - - if c.lient != nil { - c.lient.StopSync() +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 @@ -51,7 +60,7 @@ func (c *MatrixContainer) Init(config *Config) error { } var err error - c.lient, err = gomatrix.NewClient(c.config.HS, mxid, accessToken) + c.client, err = gomatrix.NewClient(c.config.HS, mxid, accessToken) if err != nil { return err } @@ -64,8 +73,12 @@ func (c *MatrixContainer) Init(config *Config) error { return nil } +func (c *MatrixContainer) Initialized() bool { + return c.client != nil +} + func (c *MatrixContainer) Login(user, password string) error { - resp, err := c.lient.Login(&gomatrix.ReqLogin{ + resp, err := c.client.Login(&gomatrix.ReqLogin{ Type: "m.login.password", User: user, Password: password, @@ -73,7 +86,7 @@ func (c *MatrixContainer) Login(user, password string) error { if err != nil { return err } - c.lient.SetCredentials(resp.UserID, resp.AccessToken) + c.client.SetCredentials(resp.UserID, resp.AccessToken) c.config.MXID = resp.UserID c.config.Save() @@ -88,31 +101,50 @@ func (c *MatrixContainer) Login(user, password string) error { func (c *MatrixContainer) Stop() { c.stop <- true - c.lient.StopSync() + c.client.StopSync() +} + +func (c *MatrixContainer) UpdateRoomList() { + rooms, err := c.client.JoinedRooms() + if err != nil { + c.debug.Print(err) + } + + c.ui.SetRoomList(rooms.JoinedRooms) } func (c *MatrixContainer) Start() { - debug.Print("Starting sync...") + c.debug.Print("Starting sync...") c.running = true - c.lient.Store = c.config.Session + c.ui.SetView(ViewMain) + c.client.Store = c.config.Session - syncer := c.lient.Syncer.(*gomatrix.DefaultSyncer) + c.UpdateRoomList() + + syncer := c.client.Syncer.(*gomatrix.DefaultSyncer) syncer.OnEventType("m.room.message", c.HandleMessage) for { select { case <-c.stop: - debug.Print("Stopping sync...") + c.debug.Print("Stopping sync...") c.running = false return default: - if err := c.lient.Sync(); err != nil { - debug.Print("Sync() errored", err) + 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) { - debug.Print("Message received") + message, _ := evt.Content["body"].(string) + c.ui.Append(evt.RoomID, evt.Sender, message) +} + +func (c *MatrixContainer) SendMessage(roomID, message string) { + c.client.SendText(roomID, message) } diff --git a/session.go b/session.go index 8f0e5d8..77e4bfa 100644 --- a/session.go +++ b/session.go @@ -31,6 +31,8 @@ type Session struct { NextBatch string FilterID string Rooms map[string]*gomatrix.Room + + debug DebugPrinter `json:"-"` } func (config *Config) LoadSession(mxid string) { @@ -43,19 +45,20 @@ func (config *Config) NewSession(mxid string) *Session { MXID: mxid, path: filepath.Join(config.dir, mxid+".session"), Rooms: make(map[string]*gomatrix.Room), + debug: config.debug, } } func (s *Session) Load() { data, err := ioutil.ReadFile(s.path) if err != nil { - debug.Print("Failed to read session from", s.path) + s.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) + s.debug.Print("Failed to parse session at", s.path) panic(err) } } @@ -63,13 +66,13 @@ func (s *Session) Load() { func (s *Session) Save() { data, err := json.Marshal(s) if err != nil { - debug.Print("Failed to marshal session of", s.MXID) + s.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) + s.debug.Print("Failed to write session to", s.path) panic(err) } } diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..99c64eb --- /dev/null +++ b/ui.go @@ -0,0 +1,71 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2018 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package main + +import ( + "github.com/rivo/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 *tview.Grid + mainViewRoomList *tview.List + mainViewRoomView *tview.Pages + mainViewInput *tview.InputField + mainViewRooms map[string]*RoomView + currentRoomIndex int + roomList []string +} + +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.MakeLoginUI(), true, true) + ui.views.AddPage(ViewMain, ui.MakeMainUI(), true, false) + return ui.views +} diff --git a/uiutil.go b/uiutil.go new file mode 100644 index 0000000..1b29ebf --- /dev/null +++ b/uiutil.go @@ -0,0 +1,55 @@ +// 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" + "github.com/rivo/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 new file mode 100644 index 0000000..78981a9 --- /dev/null +++ b/view-login.go @@ -0,0 +1,49 @@ +// 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/rivo/tview" +) + +func (ui *GomuksUI) MakeLoginUI() tview.Primitive { + form := tview.NewForm().SetButtonsAlign(tview.AlignCenter) + hs := ui.config.HS + if len(hs) == 0 { + hs = "https://matrix.org" + } + form. + AddInputField("Homeserver", hs, 30, nil, nil). + AddInputField("Username", ui.config.MXID, 30, nil, nil). + AddPasswordField("Password", "", 30, '*', nil). + AddButton("Log in", ui.login(form)) + form.SetBorder(true).SetTitle("Log in to Matrix") + return Center(45, 13, form) +} + +func (ui *GomuksUI) login(form *tview.Form) func() { + return func() { + hs := form.GetFormItem(0).(*tview.InputField).GetText() + mxid := form.GetFormItem(1).(*tview.InputField).GetText() + password := form.GetFormItem(2).(*tview.InputField).GetText() + + ui.debug.Printf("Logging into %s as %s...", hs, mxid) + ui.config.HS = hs + ui.debug.Print(ui.matrix.InitClient()) + ui.debug.Print(ui.matrix.Login(mxid, password)) + } +} diff --git a/view-main.go b/view-main.go new file mode 100644 index 0000000..acacddb --- /dev/null +++ b/view-main.go @@ -0,0 +1,164 @@ +// 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" + + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +type RoomView struct { + *tview.Grid + + sender, message *tview.TextView +} + +func NewRoomView() *RoomView { + view := &RoomView{ + tview.NewGrid(), + tview.NewTextView(), + tview.NewTextView(), + } + view.SetColumns(30, 0).SetRows(0) + + view.sender.SetTextAlign(tview.AlignRight) + view.sender.SetScrollable(true) + view.message.SetScrollable(true) + + view.AddItem(view.sender, 0, 0, 1, 1, 1, 1, false) + view.AddItem(view.message, 0, 1, 1, 1, 1, 1, false) + + return view +} + +func (ui *GomuksUI) MakeMainUI() tview.Primitive { + ui.mainView = tview.NewGrid().SetColumns(40, 0).SetRows(0, 2) + + ui.mainViewRoomList = tview.NewList().ShowSecondaryText(false) + ui.mainViewRoomList.SetBorderPadding(1, 1, 1, 1) + ui.mainView.AddItem(ui.mainViewRoomList, 0, 0, 2, 1, 2, 1, false) + + ui.mainViewRoomView = tview.NewPages() + ui.mainViewRoomView.SetChangedFunc(ui.Render) + ui.mainView.AddItem(ui.mainViewRoomView, 0, 1, 1, 1, 1, 1, false) + + ui.mainViewInput = tview.NewInputField() + ui.mainViewInput.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEnter { + room, text := ui.currentRoom(), ui.mainViewInput.GetText() + if len(text) == 0 { + return + } else if text[0] == '/' { + args := strings.SplitN(text, " ", 2) + command := strings.ToLower(args[0]) + args = args[1:] + ui.HandleCommand(room, command, args) + } else { + ui.matrix.SendMessage(room, text) + } + ui.mainViewInput.SetText("") + } + }) + ui.mainView.AddItem(ui.mainViewInput, 1, 1, 1, 1, 1, 1, true) + + ui.debug.Print(ui.mainViewInput.SetInputCapture(ui.MainUIKeyHandler)) + + ui.mainViewRooms = make(map[string]*RoomView) + + return ui.mainView +} + +func (ui *GomuksUI) HandleCommand(room, command string, args []string) { + switch command { + case "quit": + ui.matrix.Stop() + ui.app.Stop() + case "part": + case "leave": + ui.matrix.client.LeaveRoom(room) + case "join": + if len(args) == 0 { + ui.Append(room, "*", "Usage: /join ") + } + mxid := args[0] + server := mxid[strings.Index(mxid, ":")+1:] + ui.matrix.client.JoinRoom(mxid, server, nil) + } +} + +func (ui *GomuksUI) MainUIKeyHandler(key *tcell.EventKey) *tcell.EventKey { + ui.debug.Print(key) + if key.Modifiers() == tcell.ModCtrl { + if key.Key() == tcell.KeyDown { + ui.SwitchRoom(ui.currentRoomIndex + 1) + ui.mainViewRoomList.SetCurrentItem(ui.currentRoomIndex) + } else if key.Key() == tcell.KeyUp { + ui.SwitchRoom(ui.currentRoomIndex - 1) + ui.mainViewRoomList.SetCurrentItem(ui.currentRoomIndex) + } + } else if key.Key() == tcell.KeyPgUp || key.Key() == tcell.KeyPgDn { + ui.mainViewRooms[ui.currentRoom()].sender.InputHandler()(key, nil) + ui.mainViewRooms[ui.currentRoom()].message.InputHandler()(key, nil) + } else { + return key + } + return nil +} + +func (ui *GomuksUI) SetRoomList(rooms []string) { + ui.roomList = rooms + ui.mainViewRoomList.Clear() + for index, room := range rooms { + localRoomIndex := index + ui.mainViewRoomList.AddItem(room, "", 0, func() { + ui.SwitchRoom(localRoomIndex) + }) + if !ui.mainViewRoomView.HasPage(room) { + roomView := NewRoomView() + ui.mainViewRooms[room] = roomView + ui.mainViewRoomView.AddPage(room, roomView, true, false) + } + } + ui.SwitchRoom(0) +} + +func (ui *GomuksUI) currentRoom() string { + if len(ui.roomList) == 0 { + return "" + } + return ui.roomList[ui.currentRoomIndex] +} + +func (ui *GomuksUI) SwitchRoom(roomIndex int) { + if roomIndex < 0 { + roomIndex = len(ui.roomList) - 1 + } + ui.currentRoomIndex = roomIndex % len(ui.roomList) + ui.mainViewRoomView.SwitchToPage(ui.roomList[ui.currentRoomIndex]) +} + +func (ui *GomuksUI) Append(room, sender, message string) { + roomView, ok := ui.mainViewRooms[room] + if ok { + fmt.Fprintf(roomView.sender, sender) + fmt.Fprintf(roomView.message, sender) + ui.Render() + } +}