From 8664873b5b1a994c378c85f19489edf339d89931 Mon Sep 17 00:00:00 2001 From: John Brooks Date: Sat, 22 Oct 2016 17:52:26 -0700 Subject: [PATCH] cli: Keep track of conversations and prettify output --- cli/client.go | 23 +---- cli/contact.go | 20 ++-- cli/conversation.go | 236 ++++++++++++++++++++++++++++++++++++++++++++ cli/ui.go | 32 +----- rpc/core.proto | 2 + 5 files changed, 260 insertions(+), 53 deletions(-) create mode 100644 cli/conversation.go diff --git a/cli/client.go b/cli/client.go index 1b9291e..f3c5858 100644 --- a/cli/client.go +++ b/cli/client.go @@ -28,7 +28,7 @@ type Client struct { // XXX need to handle backend connection loss/reconnection.. func (c *Client) Initialize() error { - c.Contacts = NewContactList() + c.Contacts = NewContactList(c) c.monitorsChannel = make(chan interface{}, 10) c.blockChannel = make(chan struct{}) c.unblockChannel = make(chan struct{}) @@ -236,6 +236,7 @@ func (c *Client) onContactEvent(event *ricochet.ContactEvent) { } func (c *Client) onConversationEvent(event *ricochet.ConversationEvent) { + // XXX Ignoring updates, errors, etc if event.Type != ricochet.ConversationEvent_RECEIVE && event.Type != ricochet.ConversationEvent_SEND && event.Type != ricochet.ConversationEvent_POPULATE { @@ -268,24 +269,8 @@ func (c *Client) onConversationEvent(event *ricochet.ConversationEvent) { return } - if event.Type != ricochet.ConversationEvent_POPULATE { - c.Ui.PrintMessage(remoteContact, message.Sender.IsSelf, message.Text) - } - - // XXX Shouldn't mark until displayed - if !message.Sender.IsSelf { - backend := c.Backend - message := message - go func() { - _, err := backend.MarkConversationRead(context.Background(), &ricochet.MarkConversationReadRequest{ - Entity: message.Sender, - LastRecvIdentifier: message.Identifier, - }) - if err != nil { - log.Printf("Mark conversation read failed: %v", err) - } - }() - } + remoteContact.Conversation.AddMessage(message, + event.Type == ricochet.ConversationEvent_POPULATE) } func (c *Client) NetworkControlStatus() ricochet.TorControlStatus { diff --git a/cli/contact.go b/cli/contact.go index 4b4af53..996a7fe 100644 --- a/cli/contact.go +++ b/cli/contact.go @@ -7,11 +7,13 @@ import ( ) type ContactList struct { + Client *Client Contacts map[int32]*Contact } -func NewContactList() *ContactList { +func NewContactList(client *Client) *ContactList { return &ContactList{ + Client: client, Contacts: make(map[int32]*Contact), } } @@ -21,7 +23,7 @@ func (cl *ContactList) Populate(data *ricochet.Contact) error { return fmt.Errorf("Duplicate contact ID %d in populate", data.Id) } - cl.Contacts[data.Id] = initContact(data) + cl.Contacts[data.Id] = initContact(cl.Client, data) return nil } @@ -30,7 +32,7 @@ func (cl *ContactList) Added(data *ricochet.Contact) (*Contact, error) { return nil, fmt.Errorf("Duplicate contact ID %d in add", data.Id) } - contact := initContact(data) + contact := initContact(cl.Client, data) cl.Contacts[data.Id] = contact return contact, nil } @@ -63,13 +65,19 @@ func (cl *ContactList) ByIdAndAddress(id int32, address string) *Contact { } type Contact struct { - Data *ricochet.Contact + Data *ricochet.Contact + Conversation *Conversation } -func initContact(data *ricochet.Contact) *Contact { - return &Contact{ +func initContact(client *Client, data *ricochet.Contact) *Contact { + c := &Contact{ Data: data, } + c.Conversation = &Conversation{ + Client: client, + Contact: c, + } + return c } func (c *Contact) Updated(newData *ricochet.Contact) error { diff --git a/cli/conversation.go b/cli/conversation.go new file mode 100644 index 0000000..56e335f --- /dev/null +++ b/cli/conversation.go @@ -0,0 +1,236 @@ +package main + +import ( + "errors" + "fmt" + "github.com/ricochet-im/ricochet-go/rpc" + "golang.org/x/net/context" + "log" + "time" +) + +const ( + maxMessageTextLength = 2000 + // Number of messages before the first unread message shown when + // opening a conversation + backlogContextNum = 3 + // Maximum number of messages to keep in the backlog. Unread messages + // will never be discarded to keep this limit, and at least + // backlogContextNum messages are kept before the first unread message. + backlogSoftLimit = 100 + // Hard limit for the maximum numer of messages to keep in the backlog + backlogHardLimit = 200 +) + +type Conversation struct { + Client *Client + Contact *Contact + + messages []*ricochet.Message +} + +// Send an outbound message to the contact and add that message into the +// conversation backlog. Blocking API call. +func (c *Conversation) SendMessage(text string) error { + msg, err := c.Client.Backend.SendMessage(context.Background(), &ricochet.Message{ + Sender: &ricochet.Entity{IsSelf: true}, + Recipient: &ricochet.Entity{ + ContactId: c.Contact.Data.Id, + Address: c.Contact.Data.Address, + }, + Text: text, + }) + if err != nil { + fmt.Printf("send message error: %v\n", err) + return err + } + + if err := c.validateMessage(msg); err != nil { + log.Printf("Conversation sent message does not validate: %v", err) + } + c.messages = append(c.messages, msg) + c.trimBacklog() + c.printMessage(msg) + + return nil +} + +// Add a message to the conversation. The message can be inbound or +// outbound. This is called for message events from the backend, and should +// not be used when sending messages. If 'populating' is true, this message +// is part of the initial sync of the history from the backend. +func (c *Conversation) AddMessage(msg *ricochet.Message, populating bool) { + if err := c.validateMessage(msg); err != nil { + log.Printf("Rejected conversation message: %v", err) + return + } + + c.messages = append(c.messages, msg) + c.trimBacklog() + if !populating { + // XXX Need to do mark-as-read when displaying received messages in + // an active conversation. + // XXX Also, need to limit prints to the active conversation. + // XXX Quite possibly, more of the active conversation logic belongs here. + c.printMessage(msg) + } +} + +// XXX +func (c *Conversation) AddStatusMessage(text string, backlog bool) { +} + +// Mark all unread messages in this conversation as read on the backend. +func (c *Conversation) MarkAsRead() error { + // Get the identifier of the last received message + var lastRecvMsg *ricochet.Message + +findMessageId: + for i := len(c.messages) - 1; i >= 0; i-- { + switch c.messages[i].Status { + case ricochet.Message_UNREAD: + lastRecvMsg = c.messages[i] + break findMessageId + case ricochet.Message_READ: + break findMessageId + } + } + + if lastRecvMsg != nil { + return c.MarkAsReadBefore(lastRecvMsg) + } + return nil +} + +func (c *Conversation) MarkAsReadBefore(message *ricochet.Message) error { + if err := c.validateMessage(message); err != nil { + return err + } else if message.Sender.IsSelf { + return errors.New("Outbound messages cannot be marked as read") + } + + // XXX This probably means it's impossible to mark messages as read + // if the sender uses 0 identifiers. We really should not use actual + // protocol identifiers in RPC API. + _, err := c.Client.Backend.MarkConversationRead(context.Background(), + &ricochet.MarkConversationReadRequest{ + Entity: message.Sender, + LastRecvIdentifier: message.Identifier, + }) + if err != nil { + log.Printf("Mark conversation read failed: %v", err) + } + return err +} + +func (c *Conversation) PrintContext() { + // Print starting from backlogContextNum messages before the first unread + start := len(c.messages) - backlogContextNum + for i, message := range c.messages { + if message.Status == ricochet.Message_UNREAD { + start = i - backlogContextNum + break + } + } + + if start < 0 { + start = 0 + } + + for i := start; i < len(c.messages); i++ { + c.printMessage(c.messages[i]) + } +} + +func (c *Conversation) trimBacklog() { + if len(c.messages) > backlogHardLimit { + c.messages = c.messages[len(c.messages)-backlogHardLimit:] + } + if len(c.messages) <= backlogSoftLimit { + return + } + + // Find the index of the oldest unread message + var keepIndex int + for i, message := range c.messages { + if message.Status == ricochet.Message_UNREAD { + // Keep backlogContextNum messages before the first unread one + keepIndex = i - backlogContextNum + if keepIndex < 0 { + keepIndex = 0 + } + break + } else if len(c.messages)-i <= backlogSoftLimit { + // Remove all messages before this one to reduce to the limit + keepIndex = i + break + } + } + + c.messages = c.messages[keepIndex:] +} + +// Validate that a message object is well-formed, sane, and belongs +// to this conversation. +func (c *Conversation) validateMessage(msg *ricochet.Message) error { + if msg.Sender == nil || msg.Recipient == nil { + return fmt.Errorf("Message entities are incomplete: %v %v", msg.Sender, msg.Recipient) + } + + var localEntity *ricochet.Entity + var remoteEntity *ricochet.Entity + if msg.Sender.IsSelf { + localEntity = msg.Sender + remoteEntity = msg.Recipient + } else { + localEntity = msg.Recipient + remoteEntity = msg.Sender + } + + if !localEntity.IsSelf || localEntity.ContactId != 0 || + (len(localEntity.Address) > 0 && localEntity.Address != c.Client.Identity.Address) { + return fmt.Errorf("Invalid self entity on message: %v", localEntity) + } + + if remoteEntity.IsSelf || remoteEntity.ContactId != c.Contact.Data.Id || + remoteEntity.Address != c.Contact.Data.Address { + return fmt.Errorf("Invalid remote entity on message: %v", remoteEntity) + } + + // XXX timestamp + // XXX identifier + + if msg.Status == ricochet.Message_NULL { + return fmt.Errorf("Message has null status: %v", msg) + } + + // XXX more sanity checks on message text? + if len(msg.Text) == 0 || len(msg.Text) > maxMessageTextLength { + return errors.New("Message text is unacceptable") + } + + return nil +} + +func (c *Conversation) printMessage(msg *ricochet.Message) { + // XXX actual timestamp + ts := "\x1b[90m" + time.Now().Format("15:04") + "\x1b[39m" + + var direction string + if msg.Sender.IsSelf { + direction = "\x1b[34m<<\x1b[39m" + } else { + direction = "\x1b[31m>>\x1b[39m" + } + + // XXX shell escaping + fmt.Fprintf(Ui.Input.Stdout(), "\r%s | %s %s %s\n", + ts, + c.Contact.Data.Nickname, + direction, + msg.Text) + + /* + fmt.Fprintf(ui.Input.Stdout(), "\r\x1b[31m( \x1b[39mNew message from \x1b[31m%s\x1b[39m -- \x1b[1m/%d\x1b[0m to view \x1b[31m)\x1b[39m\n", contact.Data.Nickname, contact.Data.Id) + */ +} diff --git a/cli/ui.go b/cli/ui.go index 2aa539b..6b94a4d 100644 --- a/cli/ui.go +++ b/cli/ui.go @@ -44,7 +44,7 @@ func (ui *UI) Execute(line string) error { if len(words[0]) > 0 && words[0][0] == '/' { words[0] = words[0][1:] } else { - ui.SendMessage(line) + ui.CurrentContact.Conversation.SendMessage(line) return nil } } @@ -137,32 +137,6 @@ func (ui *UI) PrintStatus() { // unread messages } -func (ui *UI) PrintMessage(contact *Contact, outbound bool, text string) { - if contact == ui.CurrentContact { - if outbound { - fmt.Fprintf(ui.Input.Stdout(), "\r%s > %s\n", contact.Data.Nickname, text) - } else { - fmt.Fprintf(ui.Input.Stdout(), "\r%s < %s\n", contact.Data.Nickname, text) - } - } else if !outbound { - fmt.Fprintf(ui.Input.Stdout(), "\r---- %s < %s\n", contact.Data.Nickname, text) - } -} - -func (ui *UI) SendMessage(text string) { - _, err := ui.Client.Backend.SendMessage(context.Background(), &ricochet.Message{ - Sender: &ricochet.Entity{IsSelf: true}, - Recipient: &ricochet.Entity{ - ContactId: ui.CurrentContact.Data.Id, - Address: ui.CurrentContact.Data.Address, - }, - Text: text, - }) - if err != nil { - fmt.Printf("send message error: %v\n", err) - } -} - func (ui *UI) ListContacts() { byStatus := make(map[ricochet.Contact_Status][]*Contact) for _, contact := range ui.Client.Contacts.Contacts { @@ -190,10 +164,12 @@ func (ui *UI) SetCurrentContact(contact *Contact) { ui.CurrentContact = contact if ui.CurrentContact != nil { config := *ui.Input.Config - config.Prompt = fmt.Sprintf("%s > ", contact.Data.Nickname) + config.Prompt = fmt.Sprintf("\x1b[90m00:00\x1b[39m | %s \x1b[34m<<\x1b[39m ", contact.Data.Nickname) config.UniqueEditLine = true ui.Input.SetConfig(&config) fmt.Printf("--- %s (%s) ---\n", contact.Data.Nickname, strings.ToLower(contact.Data.Status.String())) + contact.Conversation.PrintContext() + contact.Conversation.MarkAsRead() } else { config := *ui.Input.Config config.Prompt = "> " diff --git a/rpc/core.proto b/rpc/core.proto index 9c722d3..ff21ed4 100644 --- a/rpc/core.proto +++ b/rpc/core.proto @@ -26,6 +26,8 @@ service RicochetCore { rpc StopNetwork (StopNetworkRequest) returns (NetworkStatus); // XXX Config (tor, etc) + // XXX Protobuf supports maps now. That could also be useful for contact + // update and such... // XXX Service status rpc GetIdentity (IdentityRequest) returns (Identity);