cli: Keep track of conversations and prettify output
This commit is contained in:
parent
a22de02531
commit
8664873b5b
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -64,12 +66,18 @@ 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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
*/
|
||||||
|
}
|
32
cli/ui.go
32
cli/ui.go
|
@ -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 = "> "
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in New Issue