Merge branch 'threads-v2'

* threads-v2:
  Fix cursor position in threads pane
  Fix actionChangeThread to render Threads
  Make first channel item in thread selected channel
  Only redraw grid when threads are present
  Fix columns
  Remove debugging statements
  Add reply to thread functionality
  Make columns render correctly
  Test with loading replies in chat window
  Fix visible cursor
  Start with different threads setup
This commit is contained in:
erroneousboat 2019-09-14 10:47:12 +02:00
commit 40ca615ffc
7 changed files with 307 additions and 28 deletions

View File

@ -108,13 +108,13 @@ type Channels struct {
}
// CreateChannels is the constructor for the Channels component
func CreateChannelsComponent(inputHeight int) *Channels {
func CreateChannelsComponent(height int) *Channels {
channels := &Channels{
List: termui.NewList(),
}
channels.List.BorderLabel = "Channels"
channels.List.Height = termui.TermHeight() - inputHeight
channels.List.Height = height
channels.SelectedChannel = 0
channels.Offset = 0
@ -148,17 +148,16 @@ func (c *Channels) Buffer() termui.Buffer {
// Append ellipsis when overflows
cells = termui.DTrimTxCls(cells, c.List.InnerWidth())
x := 0
x := c.List.InnerBounds().Min.X
for _, cell := range cells {
width := cell.Width()
buf.Set(c.List.InnerBounds().Min.X+x, y, cell)
x += width
buf.Set(x, y, cell)
x += cell.Width()
}
// When not at the end of the pane fill it up empty characters
for x < c.List.InnerBounds().Max.X-1 {
for x < c.List.InnerBounds().Max.X {
if y == c.CursorPosition {
buf.Set(x+1, y,
buf.Set(x, y,
termui.Cell{
Ch: ' ',
Fg: c.List.ItemBgColor,
@ -167,7 +166,7 @@ func (c *Channels) Buffer() termui.Buffer {
)
} else {
buf.Set(
x+1, y,
x, y,
termui.Cell{
Ch: ' ',
Fg: c.List.ItemFgColor,
@ -236,6 +235,11 @@ func (c *Channels) SetSelectedChannel(index int) {
c.SelectedChannel = index
}
// Get SelectedChannel returns the ChannelItem that is currently selected
func (c *Channels) GetSelectedChannel() ChannelItem {
return c.ChannelItems[c.SelectedChannel]
}
// MoveCursorUp will decrease the SelectedChannel by 1
func (c *Channels) MoveCursorUp() {
if c.SelectedChannel > 0 {

26
components/threads.go Normal file
View File

@ -0,0 +1,26 @@
package components
import (
"github.com/erroneousboat/termui"
)
type Threads struct {
*Channels
}
func CreateThreadsComponent(height int) *Threads {
threads := &Threads{
Channels: &Channels{
List: termui.NewList(),
},
}
threads.List.BorderLabel = "Threads"
threads.List.Height = height
threads.SelectedChannel = 0
threads.Offset = 0
threads.CursorPosition = threads.List.InnerBounds().Min.Y
return threads
}

View File

@ -22,6 +22,7 @@ type Config struct {
Emoji bool `json:"emoji"`
SidebarWidth int `json:"sidebar_width"`
MainWidth int `json:"-"`
ThreadsWidth int `json:"threads_width"`
KeyMap map[string]keyMapping `json:"key_map"`
Theme Theme `json:"theme"`
}
@ -90,6 +91,7 @@ func getDefaultConfig() Config {
return Config{
SidebarWidth: 1,
MainWidth: 11,
ThreadsWidth: 1,
Notify: "",
Emoji: false,
KeyMap: map[string]keyMapping{
@ -100,6 +102,8 @@ func getDefaultConfig() Config {
"j": "channel-down",
"g": "channel-top",
"G": "channel-bottom",
"K": "thread-up",
"J": "thread-down",
"<previous>": "chat-up",
"C-b": "chat-up",
"C-u": "chat-up",

View File

@ -19,6 +19,9 @@ const (
CommandMode = "command"
InsertMode = "insert"
SearchMode = "search"
ChatFocus = iota
ThreadFocus
)
type AppContext struct {
@ -31,6 +34,7 @@ type AppContext struct {
Config *config.Config
Debug bool
Mode string
Focus int
Notify *notificator.Notificator
}

View File

@ -45,6 +45,8 @@ var actionMap = map[string]func(*context.AppContext){
"channel-search-next": actionSearchNextChannels,
"channel-search-prev": actionSearchPrevChannels,
"channel-jump": actionJumpChannels,
"thread-up": actionMoveCursorUpThreads,
"thread-down": actionMoveCursorDownThreads,
"chat-up": actionScrollUpChat,
"chat-down": actionScrollDownChat,
"help": actionHelp,
@ -205,6 +207,65 @@ func actionResizeEvent(ctx *context.AppContext, ev termbox.Event) {
termui.Render(termui.Body)
}
func actionRedrawGrid(ctx *context.AppContext, threads bool, debug bool) {
termui.Clear()
termui.Body = termui.NewGrid()
termui.Body.X = 0
termui.Body.Y = 0
termui.Body.BgColor = termui.ThemeAttr("bg")
termui.Body.Width = termui.TermWidth()
columns := []*termui.Row{
termui.NewCol(ctx.Config.SidebarWidth, 0, ctx.View.Channels),
}
if threads && debug {
columns = append(
columns,
[]*termui.Row{
termui.NewCol(ctx.Config.MainWidth-ctx.Config.ThreadsWidth-3, 0, ctx.View.Chat),
termui.NewCol(ctx.Config.ThreadsWidth, 0, ctx.View.Threads),
termui.NewCol(3, 0, ctx.View.Debug),
}...,
)
} else if threads {
columns = append(
columns,
[]*termui.Row{
termui.NewCol(ctx.Config.MainWidth-ctx.Config.ThreadsWidth, 0, ctx.View.Chat),
termui.NewCol(ctx.Config.ThreadsWidth, 0, ctx.View.Threads),
}...,
)
} else if debug {
columns = append(
columns,
[]*termui.Row{
termui.NewCol(ctx.Config.MainWidth-5, 0, ctx.View.Chat),
termui.NewCol(ctx.Config.MainWidth-6, 0, ctx.View.Debug),
}...,
)
} else {
columns = append(
columns,
[]*termui.Row{
termui.NewCol(ctx.Config.MainWidth, 0, ctx.View.Chat),
}...,
)
}
termui.Body.AddRows(
termui.NewRow(columns...),
termui.NewRow(
termui.NewCol(ctx.Config.SidebarWidth, 0, ctx.View.Mode),
termui.NewCol(ctx.Config.MainWidth, 0, ctx.View.Input),
),
)
termui.Body.Align()
termui.Render(termui.Body)
}
func actionInput(view *views.View, key rune) {
view.Input.Insert(key)
termui.Render(view.Input)
@ -265,14 +326,30 @@ func actionSend(ctx *context.AppContext) {
// Send message
if !isCmd {
err := ctx.Service.SendMessage(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
message,
)
if err != nil {
ctx.View.Debug.Println(
err.Error(),
if ctx.Focus == context.ChatFocus {
err := ctx.Service.SendMessage(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
message,
)
if err != nil {
ctx.View.Debug.Println(
err.Error(),
)
}
}
if ctx.Focus == context.ThreadFocus {
err := ctx.Service.SendReply(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
ctx.View.Threads.ChannelItems[ctx.View.Threads.SelectedChannel].ID,
message,
)
if err != nil {
ctx.View.Debug.Println(
err.Error(),
)
}
}
}
@ -332,7 +409,7 @@ func actionSearchMode(ctx *context.AppContext) {
}
func actionGetMessages(ctx *context.AppContext) {
msgs, err := ctx.Service.GetMessages(
msgs, _, err := ctx.Service.GetMessages(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
ctx.View.Chat.GetMaxItems(),
)
@ -418,7 +495,7 @@ func actionChangeChannel(ctx *context.AppContext) {
// Get messages of the SelectedChannel, and get the count of messages
// that fit into the Chat component
msgs, err := ctx.Service.GetMessages(
msgs, threads, err := ctx.Service.GetMessages(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
ctx.View.Chat.GetMaxItems(),
)
@ -431,6 +508,23 @@ func actionChangeChannel(ctx *context.AppContext) {
// Set messages for the channel
ctx.View.Chat.SetMessages(msgs)
// Set the threads identifiers in the threads pane
var haveThreads bool
if len(threads) > 0 {
haveThreads = true
// Make the first thread the current Channel
ctx.View.Threads.SetChannels(
append(
[]components.ChannelItem{ctx.View.Channels.GetSelectedChannel()},
threads...,
),
)
// Reset position of SelectedChannel
ctx.View.Threads.MoveCursorTop()
}
// Set channel name for the Chat pane
ctx.View.Chat.SetBorderLabel(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].GetChannelName(),
@ -443,10 +537,102 @@ func actionChangeChannel(ctx *context.AppContext) {
ctx.View.Channels.MarkAsRead(ctx.View.Channels.SelectedChannel)
}
// Redraw grid, necessary when threads and/or debug is set. We will redraw
// the grid when there are threads, or we just came from a thread and went
// to a channel without threads. Hence the clearing of ChannelItems of
// Threads.
if haveThreads {
actionRedrawGrid(ctx, haveThreads, ctx.Debug)
} else if !haveThreads && len(ctx.View.Threads.ChannelItems) > 0 {
ctx.View.Threads.SetChannels([]components.ChannelItem{})
actionRedrawGrid(ctx, haveThreads, ctx.Debug)
} else {
termui.Render(ctx.View.Channels)
termui.Render(ctx.View.Chat)
}
// Set focus, necessary to know when replying to thread or chat
ctx.Focus = context.ChatFocus
}
func actionChangeThread(ctx *context.AppContext) {
// Clear messages from Chat pane
ctx.View.Chat.ClearMessages()
// The first channel in the Thread list is current Channel. Set context
// Focus and messages accordingly.
var err error
msgs := []components.Message{}
if ctx.View.Threads.SelectedChannel == 0 {
ctx.Focus = context.ChatFocus
msgs, _, err = ctx.Service.GetMessages(
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
ctx.View.Chat.GetMaxItems(),
)
if err != nil {
termbox.Close()
log.Println(err)
os.Exit(0)
}
} else {
ctx.Focus = context.ThreadFocus
msgs, err = ctx.Service.GetMessageByID(
ctx.View.Threads.ChannelItems[ctx.View.Threads.SelectedChannel].ID,
ctx.View.Channels.ChannelItems[ctx.View.Channels.SelectedChannel].ID,
)
if err != nil {
termbox.Close()
log.Println(err)
os.Exit(0)
}
}
// Set messages for the channel
ctx.View.Chat.SetMessages(msgs)
// Set focus, necessary to know when replying to thread or chat
termui.Render(ctx.View.Channels)
termui.Render(ctx.View.Threads)
termui.Render(ctx.View.Chat)
}
func actionMoveCursorUpThreads(ctx *context.AppContext) {
go func() {
if scrollTimer != nil {
scrollTimer.Stop()
}
ctx.View.Threads.MoveCursorUp()
termui.Render(ctx.View.Threads)
scrollTimer = time.NewTimer(time.Second / 4)
<-scrollTimer.C
// Only actually change channel when the timer expires
actionChangeThread(ctx)
}()
}
func actionMoveCursorDownThreads(ctx *context.AppContext) {
go func() {
if scrollTimer != nil {
scrollTimer.Stop()
}
ctx.View.Threads.MoveCursorDown()
termui.Render(ctx.View.Threads)
scrollTimer = time.NewTimer(time.Second / 4)
<-scrollTimer.C
// Only actually change thread when the timer expires
actionChangeThread(ctx)
}()
}
// actionNewMessage will set the new message indicator for a channel, and
// if configured will also display a desktop notification
func actionNewMessage(ctx *context.AppContext, ev *slack.MessageEvent) {

View File

@ -406,8 +406,9 @@ func (s *SlackService) SendCommand(channelID string, message string) (bool, erro
}
// GetMessages will get messages for a channel, group or im channel delimited
// by a count.
func (s *SlackService) GetMessages(channelID string, count int) ([]components.Message, error) {
// by a count. It will return the messages, the thread identifiers
// (as ChannelItem), and and error.
func (s *SlackService) GetMessages(channelID string, count int) ([]components.Message, []components.ChannelItem, error) {
// https://godoc.org/github.com/nlopes/slack#GetConversationHistoryParameters
historyParams := slack.GetConversationHistoryParameters{
@ -418,14 +419,27 @@ func (s *SlackService) GetMessages(channelID string, count int) ([]components.Me
history, err := s.Client.GetConversationHistory(&historyParams)
if err != nil {
return nil, err
return nil, nil, err
}
// Construct the messages
var messages []components.Message
var threads []components.ChannelItem
for _, message := range history.Messages {
msg := s.CreateMessage(message, channelID)
messages = append(messages, msg)
// FIXME: create boolean isThread
if msg.Thread != "" {
threads = append(threads, components.ChannelItem{
ID: msg.ID,
Name: msg.Thread,
Type: components.ChannelTypeGroup,
StylePrefix: s.Config.Theme.Channel.Prefix,
StyleIcon: s.Config.Theme.Channel.Icon,
StyleText: s.Config.Theme.Channel.Text,
})
}
}
// Reverse the order of the messages, we want the newest in
@ -435,7 +449,38 @@ func (s *SlackService) GetMessages(channelID string, count int) ([]components.Me
messagesReversed = append(messagesReversed, messages[i])
}
return messagesReversed, nil
return messagesReversed, threads, nil
}
// CreateMessageByID will construct an array of components.Message with only
// 1 message, using the message ID (Timestamp).
//
// For the choice of history parameters see:
// https://api.slack.com/messaging/retrieving
func (s *SlackService) GetMessageByID(messageID string, channelID string) ([]components.Message, error) {
var msgs []components.Message
// https://godoc.org/github.com/nlopes/slack#GetConversationHistoryParameters
historyParams := slack.GetConversationHistoryParameters{
ChannelID: channelID,
Limit: 1,
Inclusive: true,
Latest: messageID,
}
history, err := s.Client.GetConversationHistory(&historyParams)
if err != nil {
return msgs, err
}
// We break because we're only asking for 1 message
for _, message := range history.Messages {
msgs = append(msgs, s.CreateMessage(message, channelID))
break
}
return msgs, nil
}
// CreateMessage will create a string formatted message that can be rendered
@ -545,7 +590,7 @@ func (s *SlackService) CreateMessage(message slack.Message, channelID string) co
msg.Thread = fmt.Sprintf("%s ", threadID)
// Create the message replies from the thread
replies := s.CreateMessageFromReplies(message, channelID)
replies := s.CreateMessageFromReplies(message.ThreadTimestamp, channelID)
for _, reply := range replies {
msg.Messages[reply.ID] = reply
}
@ -563,13 +608,13 @@ func (s *SlackService) CreateMessage(message slack.Message, channelID string) co
// https://api.slack.com/methods/conversations.replies
// https://godoc.org/github.com/nlopes/slack#Client.GetConversationReplies
// https://godoc.org/github.com/nlopes/slack#GetConversationRepliesParameters
func (s *SlackService) CreateMessageFromReplies(message slack.Message, channelID string) []components.Message {
func (s *SlackService) CreateMessageFromReplies(messageID string, channelID string) []components.Message {
msgs := make([]slack.Message, 0)
initReplies, _, initCur, err := s.Client.GetConversationReplies(
&slack.GetConversationRepliesParameters{
ChannelID: channelID,
Timestamp: message.ThreadTimestamp,
Timestamp: messageID,
Limit: 200,
},
)
@ -583,7 +628,7 @@ func (s *SlackService) CreateMessageFromReplies(message slack.Message, channelID
for nextCur != "" {
conversationReplies, _, cursor, err := s.Client.GetConversationReplies(&slack.GetConversationRepliesParameters{
ChannelID: channelID,
Timestamp: message.ThreadTimestamp,
Timestamp: messageID,
Cursor: nextCur,
Limit: 200,
})

View File

@ -13,6 +13,7 @@ type View struct {
Input *components.Input
Chat *components.Chat
Channels *components.Channels
Threads *components.Threads
Mode *components.Mode
Debug *components.Debug
}
@ -22,7 +23,8 @@ func CreateView(config *config.Config, svc *service.SlackService) (*View, error)
input := components.CreateInputComponent()
// Channels: create the component
channels := components.CreateChannelsComponent(input.Par.Height)
sideBarHeight := termui.TermHeight() - input.Par.Height
channels := components.CreateChannelsComponent(sideBarHeight)
// Channels: fill the component
slackChans, err := svc.GetChannels()
@ -33,11 +35,14 @@ func CreateView(config *config.Config, svc *service.SlackService) (*View, error)
// Channels: set channels in component
channels.SetChannels(slackChans)
// Threads: create component
threads := components.CreateThreadsComponent(sideBarHeight)
// Chat: create the component
chat := components.CreateChatComponent(input.Par.Height)
// Chat: fill the component
msgs, err := svc.GetMessages(
msgs, thr, err := svc.GetMessages(
channels.ChannelItems[channels.SelectedChannel].ID,
chat.GetMaxItems(),
)
@ -52,6 +57,9 @@ func CreateView(config *config.Config, svc *service.SlackService) (*View, error)
channels.ChannelItems[channels.SelectedChannel].GetChannelName(),
)
// Threads: set threads in component
threads.SetChannels(thr)
// Debug: create the component
debug := components.CreateDebugComponent(input.Par.Height)
@ -62,6 +70,7 @@ func CreateView(config *config.Config, svc *service.SlackService) (*View, error)
Config: config,
Input: input,
Channels: channels,
Threads: threads,
Chat: chat,
Mode: mode,
Debug: debug,
@ -75,6 +84,7 @@ func (v *View) Refresh() {
v.Input,
v.Chat,
v.Channels,
v.Threads,
v.Mode,
)
}