diff --git a/.gitignore b/.gitignore index 4be82e0..099f48d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ bin examples.json +dist +cov.cov +coverage.html diff --git a/README.md b/README.md index e368c11..28d53eb 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ bin/siggo * `k` - Scroll Up * `J` - Next Contact * `K` - Previous Contact +* `a` - Attach file (sent with next message) * `i` - Insert Mode * `CTRL+L` - Clear input field * `I` - Compose (opens $EDITOR and lets you make a fancy message) @@ -107,7 +108,6 @@ Here is a list of things that are currently broken. Here is a list of features I'd like to add soonish. * Better Attachments Support - * Sending attachments * Opening attachments (besides the most recent) * gui configuration * colors and border styles diff --git a/model/model.go b/model/model.go index 6e4c25e..e598d42 100644 --- a/model/model.go +++ b/model/model.go @@ -117,6 +117,17 @@ func (m *Message) String(color string) string { return data } +// AddAttachments currently only is used to track attachments we sent to other people, so that +// they show up in the GUI. +func (m *Message) AddAttachments(paths []string) { + if m.Attachments == nil { + m.Attachments = make([]*signal.Attachment, 0) + } + for _, path := range paths { + m.Attachments = append(m.Attachments, &signal.Attachment{Filename: path}) + } +} + // Coversation is a contact and its associated messages type Conversation struct { Contact *Contact @@ -125,8 +136,9 @@ type Conversation struct { HasNewMessage bool // hasNewData tracks whether new data has been added // since the last save to disk - color string - hasNewData bool + color string + hasNewData bool + stagedAttachments []string } // String renders the conversation to a single string @@ -173,14 +185,24 @@ func (c *Conversation) LastMessage() *Message { return nil } -// PopMessage just removes and returns the last message, if it exists -func (c *Conversation) PopMessage() { - nMessage := len(c.MessageOrder) - if nMessage > 0 { - lastMsg := c.MessageOrder[nMessage-1] - c.MessageOrder = c.MessageOrder[:nMessage-1] - delete(c.Messages, lastMsg) +// StageAttachment attaches a file to be sent in the next message +func (c *Conversation) AddAttachment(path string) error { + if _, err := os.Stat(path); err != nil { + // no file there... + return err } + c.stagedAttachments = append(c.stagedAttachments, path) + return nil +} + +// ClearAttachments removes any staged attachments +func (c *Conversation) ClearAttachments() { + c.stagedAttachments = []string{} +} + +// NumAttachments returns the number of staged attachments +func (c *Conversation) NumAttachments() int { + return len(c.stagedAttachments) } // CaughtUp iterates back through the messages of the conversation marking the un-read ones @@ -256,12 +278,14 @@ func NewConversation(contact *Contact) *Conversation { Messages: make(map[int64]*Message), MessageOrder: make([]int64, 0), HasNewMessage: false, + + stagedAttachments: make([]string, 0), } } type SignalAPI interface { Send(string, string) (int64, error) - SendDbus(string, string) (int64, error) + SendDbus(string, string, ...string) (int64, error) Receive() error ReceiveForever() OnReceived(signal.ReceivedCallback) @@ -290,6 +314,7 @@ func (s *Siggo) Send(msg string, contact *Contact) error { IsDelivered: false, IsRead: false, FromSelf: true, + Attachments: make([]*signal.Attachment, 0), } conv, ok := s.conversations[contact] if !ok { @@ -297,7 +322,7 @@ func (s *Siggo) Send(msg string, contact *Contact) error { conv = s.newConversation(contact) } // finally send the message - ID, err := s.signal.SendDbus(contact.Number, msg) + ID, err := s.signal.SendDbus(contact.Number, msg, conv.stagedAttachments...) if err != nil { message.Content = fmt.Sprintf("FAILED TO SEND: %s ERROR: %v", message.Content, err) s.NewInfo(conv) @@ -306,6 +331,8 @@ func (s *Siggo) Send(msg string, contact *Contact) error { // use the official timestamp on success message.Timestamp = ID conv.CaughtUp() + message.AddAttachments(conv.stagedAttachments) + conv.ClearAttachments() conv.AddMessage(message) s.NewInfo(conv) log.Infof("successfully sent message %s with timestamp: %d", message.Content, message.Timestamp) diff --git a/signal/message.go b/signal/message.go index e253c0b..fc5b620 100644 --- a/signal/message.go +++ b/signal/message.go @@ -60,6 +60,11 @@ type Attachment struct { // Path returns the full path to an attachment file func (a *Attachment) Path() (string, error) { + if a.ID == "" { + // TODO: save our own copy of the attachment with our own ID + // for now, just return the path where we attached it + return a.Filename, nil + } folder, err := GetSignalFolder() if err != nil { return "", err diff --git a/signal/mock.go b/signal/mock.go index d24d159..e11a4a7 100644 --- a/signal/mock.go +++ b/signal/mock.go @@ -57,7 +57,7 @@ func (ms *MockSignal) Send(dest, msg string) (int64, error) { return timestamp, nil } -func (ms *MockSignal) SendDbus(dest, msg string) (int64, error) { +func (ms *MockSignal) SendDbus(dest, msg string, attachments ...string) (int64, error) { return ms.Send(dest, msg) } diff --git a/signal/signal.go b/signal/signal.go index 21cf3ee..119d3a5 100644 --- a/signal/signal.go +++ b/signal/signal.go @@ -235,11 +235,17 @@ func (s *Signal) Send(dest, msg string) (int64, error) { } // SendDbus does the same thing as Send but it goes through a running daemon. -func (s *Signal) SendDbus(dest, msg string) (int64, error) { +func (s *Signal) SendDbus(dest, msg string, attachments ...string) (int64, error) { if !strings.HasPrefix(dest, "+") { dest = fmt.Sprintf("+%s", dest) } - cmd := exec.Command("signal-cli", "--dbus", "send", dest, "-m", msg) + args := []string{"--dbus", "send", dest, "-m", msg} + if len(attachments) > 0 { + // how do I do this in one line? + args = append(args, "-a") + args = append(args, attachments...) + } + cmd := exec.Command("signal-cli", args...) out, err := cmd.Output() if err != nil { s.publishError(err) diff --git a/widgets/widgets.go b/widgets/widgets.go index 9aed20c..f8dcec4 100644 --- a/widgets/widgets.go +++ b/widgets/widgets.go @@ -5,6 +5,8 @@ import ( "io/ioutil" "os" "os/exec" + "os/user" + "path/filepath" "regexp" "strings" "time" @@ -45,6 +47,7 @@ type ChatWindow struct { contactsPanel *ContactListPanel conversationPanel *ConversationPanel searchPanel tview.Primitive + commandPanel tview.Primitive statusBar *StatusBar app *tview.Application normalKeybinds func(*tcell.EventKey) *tcell.EventKey @@ -153,7 +156,7 @@ func (c *ChatWindow) YankLastLink() { } // OpenLastLink opens the last link that is finds in the conversation -// TODO: solution for browsing/opening any attachment +// TODO: solution for browsing/opening any link func (c *ChatWindow) OpenLastLink() { c.NormalMode() links := c.getLinks() @@ -213,6 +216,24 @@ func (c *ChatWindow) HideSearch() { c.app.SetFocus(c) } +// ShowAttachInput opens a commandPanel to choose a file to attach +func (c *ChatWindow) ShowAttachInput() { + log.Debug("SHOWING CONTACT SEARCH") + p := NewAttachInput(c) + c.commandPanel = p + c.SetRows(0, 3, 1) + c.AddItem(p, 2, 0, 1, 2, 0, 0, false) + c.app.SetFocus(p) +} + +// HideCommandInput hides any current CommandInput panel +func (c *ChatWindow) HideCommandInput() { + log.Debug("HIDING COMMAND INPUT") + c.RemoveItem(c.commandPanel) + c.SetRows(0, 3) + c.app.SetFocus(c) +} + // ShowStatusBar shows the bottom status bar func (c *ChatWindow) ShowStatusBar() { c.SetRows(0, 3, 1) @@ -260,6 +281,7 @@ func (c *ChatWindow) SetCurrentContact(contact *model.Contact) error { } c.conversationPanel.Update(conv) conv.CaughtUp() + c.sendPanel.Update() c.conversationPanel.ScrollToEnd() return nil } @@ -380,16 +402,39 @@ func (s *SendPanel) Send() { go s.siggo.Send(msg, contact) log.Infof("sent message: %s to contact: %s", msg, contact) s.SetText("") + s.SetLabel("") +} + +func (s *SendPanel) Clear() { + s.SetText("") + conv, err := s.parent.currentConversation() + if err != nil { + return + } + conv.ClearAttachments() + s.SetLabel("") } func (s *SendPanel) Defocus() { s.parent.NormalMode() } +func (s *SendPanel) Update() { + conv, err := s.parent.currentConversation() + if err != nil { + return + } + nAttachments := conv.NumAttachments() + if nAttachments > 0 { + s.SetLabel(fmt.Sprintf("📎(%d): ", nAttachments)) + } else { + s.SetLabel("") + } +} + // emojify is a custom input change handler that provides emoji support func (s *SendPanel) emojify(input string) { if strings.HasSuffix(input, ":") { - //log.Printf("emojify: %s", input) emojified := emoji.Sprint(input) if emojified != input { s.SetText(emojified) @@ -420,7 +465,7 @@ func NewSendPanel(parent *ChatWindow, siggo *model.Siggo) *SendPanel { case tcell.KeyCtrlQ: s.parent.Quit() case tcell.KeyCtrlL: - s.SetText("") + s.Clear() return nil } return event @@ -601,6 +646,57 @@ func NewSearchInput(parent *SearchPanel) *SearchInput { return si } +// CommandInput is an input field that appears at the bottom of the window and allows for various +// commands +type CommandInput struct { + *tview.InputField + parent *ChatWindow +} + +// AttachInput is a command input that selects an attachment and attaches it to the current +// conversation to be sent in the next message. +func NewAttachInput(parent *ChatWindow) *CommandInput { + ci := &CommandInput{ + InputField: tview.NewInputField(), + parent: parent, + } + ci.SetLabel("📎: ") + ci.SetText("~/") + ci.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor) + ci.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // Setup keys + log.Debugf("Key Event : %v mods: %v rune: %v", event.Key(), event.Modifiers(), event.Rune()) + switch event.Key() { + case tcell.KeyESC: + ci.parent.HideCommandInput() + return nil + case tcell.KeyTAB: + ci.SetText(CompletePath(ci.GetText())) + return nil + case tcell.KeyEnter: + path := ci.GetText() + ci.parent.HideCommandInput() + if path == "" { + return nil + } + conv, err := ci.parent.currentConversation() + if err != nil { + ci.parent.SetErrorStatus(fmt.Errorf("couldn't find conversation: %v", err)) + return nil + } + err = conv.AddAttachment(path) + if err != nil { + ci.parent.SetErrorStatus(fmt.Errorf("failed to attach: %s - %v", path, err)) + return nil + } + ci.parent.sendPanel.Update() + return nil + } + return event + }) + return ci +} + type StatusBar struct { *tview.TextView parent *ChatWindow @@ -659,6 +755,9 @@ func NewChatWindow(siggo *model.Siggo, app *tview.Application) *ChatWindow { case 111: // o w.OpenMode() return nil + case 97: // o + w.ShowAttachInput() + return nil } // pass some events on to the conversation panel case tcell.KeyCtrlQ: @@ -794,3 +893,52 @@ func FancyCompose() (string, error) { } return string(b), nil } + +// CompletePath autocompletes a path stub +func CompletePath(path string) string { + if path == "" { + return "" + } + if path[0] == '~' { + usr, err := user.Current() + if err != nil { + return "" + } + path = usr.HomeDir + path[1:] + } + matches, err := filepath.Glob(path + "*") + if err != nil || matches == nil || len(matches) == 0 { + return path + } + if len(matches) == 1 { + path = matches[0] + } else if !strings.HasSuffix(path, "/") { + path = GetSharedPrefix(matches...) + } + stat, err := os.Stat(path) + if err != nil { + return path + } + if stat.IsDir() { + if !strings.HasSuffix(path, "/") { + return path + "/" + } + } + return path +} + +// GetSharedPrefix finds the prefix shared by any number of strings +// Is there a more efficient way to do this? +func GetSharedPrefix(s ...string) string { + var out strings.Builder + for i := 0; i < len(s[0]); i++ { + c := s[0][i] + for _, str := range s { + if str[i] != c { + return out.String() + } + } + out.WriteByte(c) + } + return out.String() +}