cli: Keep track of conversations and prettify output

This commit is contained in:
John Brooks 2016-10-22 17:52:26 -07:00
parent a22de02531
commit 8664873b5b
5 changed files with 260 additions and 53 deletions

View File

@ -28,7 +28,7 @@ type Client struct {
// XXX need to handle backend connection loss/reconnection.. // XXX need to handle backend connection loss/reconnection..
func (c *Client) Initialize() error { func (c *Client) Initialize() error {
c.Contacts = NewContactList() c.Contacts = NewContactList(c)
c.monitorsChannel = make(chan interface{}, 10) c.monitorsChannel = make(chan interface{}, 10)
c.blockChannel = make(chan struct{}) c.blockChannel = make(chan struct{})
c.unblockChannel = 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) { func (c *Client) onConversationEvent(event *ricochet.ConversationEvent) {
// XXX Ignoring updates, errors, etc
if event.Type != ricochet.ConversationEvent_RECEIVE && if event.Type != ricochet.ConversationEvent_RECEIVE &&
event.Type != ricochet.ConversationEvent_SEND && event.Type != ricochet.ConversationEvent_SEND &&
event.Type != ricochet.ConversationEvent_POPULATE { event.Type != ricochet.ConversationEvent_POPULATE {
@ -268,24 +269,8 @@ func (c *Client) onConversationEvent(event *ricochet.ConversationEvent) {
return return
} }
if event.Type != ricochet.ConversationEvent_POPULATE { remoteContact.Conversation.AddMessage(message,
c.Ui.PrintMessage(remoteContact, message.Sender.IsSelf, message.Text) event.Type == ricochet.ConversationEvent_POPULATE)
}
// 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)
}
}()
}
} }
func (c *Client) NetworkControlStatus() ricochet.TorControlStatus { func (c *Client) NetworkControlStatus() ricochet.TorControlStatus {

View File

@ -7,11 +7,13 @@ import (
) )
type ContactList struct { type ContactList struct {
Client *Client
Contacts map[int32]*Contact Contacts map[int32]*Contact
} }
func NewContactList() *ContactList { func NewContactList(client *Client) *ContactList {
return &ContactList{ return &ContactList{
Client: client,
Contacts: make(map[int32]*Contact), 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) 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 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) 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 cl.Contacts[data.Id] = contact
return contact, nil return contact, nil
} }
@ -63,13 +65,19 @@ func (cl *ContactList) ByIdAndAddress(id int32, address string) *Contact {
} }
type Contact struct { type Contact struct {
Data *ricochet.Contact Data *ricochet.Contact
Conversation *Conversation
} }
func initContact(data *ricochet.Contact) *Contact { func initContact(client *Client, data *ricochet.Contact) *Contact {
return &Contact{ c := &Contact{
Data: data, Data: data,
} }
c.Conversation = &Conversation{
Client: client,
Contact: c,
}
return c
} }
func (c *Contact) Updated(newData *ricochet.Contact) error { func (c *Contact) Updated(newData *ricochet.Contact) error {

236
cli/conversation.go Normal file
View File

@ -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)
*/
}

View File

@ -44,7 +44,7 @@ func (ui *UI) Execute(line string) error {
if len(words[0]) > 0 && words[0][0] == '/' { if len(words[0]) > 0 && words[0][0] == '/' {
words[0] = words[0][1:] words[0] = words[0][1:]
} else { } else {
ui.SendMessage(line) ui.CurrentContact.Conversation.SendMessage(line)
return nil return nil
} }
} }
@ -137,32 +137,6 @@ func (ui *UI) PrintStatus() {
// unread messages // 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() { func (ui *UI) ListContacts() {
byStatus := make(map[ricochet.Contact_Status][]*Contact) byStatus := make(map[ricochet.Contact_Status][]*Contact)
for _, contact := range ui.Client.Contacts.Contacts { for _, contact := range ui.Client.Contacts.Contacts {
@ -190,10 +164,12 @@ func (ui *UI) SetCurrentContact(contact *Contact) {
ui.CurrentContact = contact ui.CurrentContact = contact
if ui.CurrentContact != nil { if ui.CurrentContact != nil {
config := *ui.Input.Config 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 config.UniqueEditLine = true
ui.Input.SetConfig(&config) ui.Input.SetConfig(&config)
fmt.Printf("--- %s (%s) ---\n", contact.Data.Nickname, strings.ToLower(contact.Data.Status.String())) fmt.Printf("--- %s (%s) ---\n", contact.Data.Nickname, strings.ToLower(contact.Data.Status.String()))
contact.Conversation.PrintContext()
contact.Conversation.MarkAsRead()
} else { } else {
config := *ui.Input.Config config := *ui.Input.Config
config.Prompt = "> " config.Prompt = "> "

View File

@ -26,6 +26,8 @@ service RicochetCore {
rpc StopNetwork (StopNetworkRequest) returns (NetworkStatus); rpc StopNetwork (StopNetworkRequest) returns (NetworkStatus);
// XXX Config (tor, etc) // XXX Config (tor, etc)
// XXX Protobuf supports maps now. That could also be useful for contact
// update and such...
// XXX Service status // XXX Service status
rpc GetIdentity (IdentityRequest) returns (Identity); rpc GetIdentity (IdentityRequest) returns (Identity);