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..
|
||||
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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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] == '/' {
|
||||
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 = "> "
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in New Issue