Initial Commit
This commit is contained in:
parent
65c6adcb0e
commit
d934b91188
2
.gitignore
vendored
2
.gitignore
vendored
@ -24,3 +24,5 @@ _testmain.go
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# The binary
|
||||
sluice
|
||||
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016 J.P.H. Bruins Slot
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
139
README.md
139
README.md
@ -1,3 +1,140 @@
|
||||
# sluice
|
||||
|
||||
A terminal Slack client
|
||||
A [Slack](https://slack.com) client for your terminal.
|
||||
|
||||
![Screenshot](/screenshot.png?raw=true)
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
#### Binary installation
|
||||
|
||||
[Download](https://github.com/erroneousboat/slack-term/releases) a
|
||||
compatible binary for your system. For convenience place `slack-term` in a
|
||||
directory where you can access it from the command line. Usually this is
|
||||
`/usr/local/bin`.
|
||||
|
||||
```bash
|
||||
$ mv slack-term /usr/local/bin
|
||||
```
|
||||
|
||||
#### Via Go
|
||||
|
||||
If you want you can also get `slack-term` via Go:
|
||||
|
||||
```bash
|
||||
$ go get -u github.com/erroneousboat/slack-term
|
||||
```
|
||||
|
||||
Setup
|
||||
-----
|
||||
|
||||
1. Get a slack token, click [here](https://api.slack.com/docs/oauth-test-tokens)
|
||||
|
||||
2. Create a `slack-term.json` file, place it in your home directory. Below is
|
||||
an an example file, you can leave out the `OPTIONAL` parts, you are only
|
||||
required to specify a `slack_token`. Remember that your file should be
|
||||
a valid json file so don't forget to remove the comments.
|
||||
|
||||
```javascript
|
||||
{
|
||||
"slack_token": "yourslacktokenhere",
|
||||
|
||||
// OPTIONAL: add the following to use light theme, default is dark
|
||||
"theme": "light",
|
||||
|
||||
// OPTIONAL: set the width of the sidebar (between 1 and 11), default is 1
|
||||
"sidebar_width": 3,
|
||||
|
||||
// OPTIONAL: define custom key mappings, defaults are:
|
||||
"key_map": {
|
||||
"command": {
|
||||
"i": "mode-insert",
|
||||
"k": "channel-up",
|
||||
"j": "channel-down",
|
||||
"g": "channel-top",
|
||||
"G": "channel-bottom",
|
||||
"<previous>": "chat-up",
|
||||
"C-b": "chat-up",
|
||||
"C-u": "chat-up",
|
||||
"<next>": "chat-down",
|
||||
"C-f": "chat-down",
|
||||
"C-d": "chat-down",
|
||||
"q": "quit",
|
||||
"<f1>": "help"
|
||||
},
|
||||
"insert": {
|
||||
"<left>": "cursor-left",
|
||||
"<right>": "cursor-right",
|
||||
"<enter>": "send",
|
||||
"<escape>": "mode-command",
|
||||
"<backspace>": "backspace",
|
||||
"C-8": "backspace",
|
||||
"<delete>": "delete",
|
||||
"<space>": "space"
|
||||
},
|
||||
"search": {
|
||||
"<left>": "cursor-left",
|
||||
"<right>": "cursor-right",
|
||||
"<escape>": "clear-input",
|
||||
"<enter>": "clear-input",
|
||||
"<backspace>": "backspace",
|
||||
"C-8": "backspace",
|
||||
"<delete>": "delete",
|
||||
"<space>": "space"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
When everything is setup correctly you can run `slack-term` with the following
|
||||
command:
|
||||
|
||||
```bash
|
||||
$ slack-term
|
||||
```
|
||||
|
||||
You can also specify the location of the config file, this will give you
|
||||
the possibility to run several instances of `slack-term` with different
|
||||
accounts.
|
||||
|
||||
```bash
|
||||
$ slack-term -config [path-to-config-file]
|
||||
```
|
||||
|
||||
Default Key Mapping
|
||||
-------------------
|
||||
|
||||
Below are the default key-mapping for `slack-term`, you can change them
|
||||
in your `slack-term.json` file.
|
||||
|
||||
| mode | key | action |
|
||||
|---------|-----------|----------------------------|
|
||||
| command | `i` | insert mode |
|
||||
| command | `/` | search mode |
|
||||
| command | `k` | move channel cursor up |
|
||||
| command | `j` | move channel cursor down |
|
||||
| command | `g` | move channel cursor top |
|
||||
| command | `G` | move channel cursor bottom |
|
||||
| command | `pg-up` | scroll chat pane up |
|
||||
| command | `ctrl-b` | scroll chat pane up |
|
||||
| command | `ctrl-u` | scroll chat pane up |
|
||||
| command | `pg-down` | scroll chat pane down |
|
||||
| command | `ctrl-f` | scroll chat pane down |
|
||||
| command | `ctrl-d` | scroll chat pane down |
|
||||
| command | `q` | quit |
|
||||
| command | `f1` | help |
|
||||
| insert | `left` | move input cursor left |
|
||||
| insert | `right` | move input cursor right |
|
||||
| insert | `enter` | send message |
|
||||
| insert | `esc` | command mode |
|
||||
| search | `esc` | command mode |
|
||||
| search | `enter` | command mode |
|
||||
|
||||
|
||||
|
||||
Originally forked from erroneousboat's slack-term:
|
||||
https://github.com/erroneousboat/slack-term
|
||||
|
352
channels.go
Normal file
352
channels.go
Normal file
@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gizak/termui"
|
||||
)
|
||||
|
||||
const (
|
||||
IconOnline = "●"
|
||||
IconOffline = "○"
|
||||
IconChannel = "#"
|
||||
IconGroup = "☰"
|
||||
IconIM = "●"
|
||||
IconNotification = "🞷"
|
||||
|
||||
PresenceAway = "away"
|
||||
PresenceActive = "active"
|
||||
)
|
||||
|
||||
// Channels is the definition of a Channels component
|
||||
type Channels struct {
|
||||
List *termui.List
|
||||
SelectedChannel int // index of which channel is selected from the List
|
||||
Offset int // from what offset are channels rendered
|
||||
CursorPosition int // the y position of the 'cursor'
|
||||
}
|
||||
|
||||
// CreateChannels is the constructor for the Channels component
|
||||
func CreateChannels(svc *SlackService, inputHeight int) *Channels {
|
||||
channels := &Channels{
|
||||
List: termui.NewList(),
|
||||
}
|
||||
|
||||
channels.List.BorderLabel = "Channels"
|
||||
channels.List.Height = termui.TermHeight() - inputHeight
|
||||
|
||||
channels.SelectedChannel = 0
|
||||
channels.Offset = 0
|
||||
channels.CursorPosition = channels.List.InnerBounds().Min.Y
|
||||
|
||||
channels.GetChannels(svc)
|
||||
channels.SetPresenceForIMChannels(svc)
|
||||
|
||||
return channels
|
||||
}
|
||||
|
||||
// Buffer implements interface termui.Bufferer
|
||||
func (c *Channels) Buffer() termui.Buffer {
|
||||
buf := c.List.Buffer()
|
||||
|
||||
for i, item := range c.List.Items[c.Offset:] {
|
||||
|
||||
y := c.List.InnerBounds().Min.Y + i
|
||||
|
||||
if y > c.List.InnerBounds().Max.Y-1 {
|
||||
break
|
||||
}
|
||||
|
||||
var cells []termui.Cell
|
||||
if y == c.CursorPosition {
|
||||
cells = termui.DefaultTxBuilder.Build(
|
||||
item, c.List.ItemBgColor, c.List.ItemFgColor)
|
||||
} else {
|
||||
cells = termui.DefaultTxBuilder.Build(
|
||||
item, c.List.ItemFgColor, c.List.ItemBgColor)
|
||||
}
|
||||
|
||||
cells = termui.DTrimTxCls(cells, c.List.InnerWidth())
|
||||
|
||||
x := 0
|
||||
for _, cell := range cells {
|
||||
width := cell.Width()
|
||||
buf.Set(c.List.InnerBounds().Min.X+x, y, cell)
|
||||
x += width
|
||||
}
|
||||
|
||||
// When not at the end of the pane fill it up empty characters
|
||||
for x < c.List.InnerBounds().Max.X-1 {
|
||||
if y == c.CursorPosition {
|
||||
buf.Set(x+1, y,
|
||||
termui.Cell{
|
||||
Ch: ' ',
|
||||
Fg: c.List.ItemBgColor,
|
||||
Bg: c.List.ItemFgColor,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
buf.Set(
|
||||
x+1, y,
|
||||
termui.Cell{
|
||||
Ch: ' ',
|
||||
Fg: c.List.ItemFgColor,
|
||||
Bg: c.List.ItemBgColor,
|
||||
},
|
||||
)
|
||||
}
|
||||
x++
|
||||
}
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// GetHeight implements interface termui.GridBufferer
|
||||
func (c *Channels) GetHeight() int {
|
||||
return c.List.Block.GetHeight()
|
||||
}
|
||||
|
||||
// SetWidth implements interface termui.GridBufferer
|
||||
func (c *Channels) SetWidth(w int) {
|
||||
c.List.SetWidth(w)
|
||||
}
|
||||
|
||||
// SetX implements interface termui.GridBufferer
|
||||
func (c *Channels) SetX(x int) {
|
||||
c.List.SetX(x)
|
||||
}
|
||||
|
||||
// SetY implements interface termui.GridBufferer
|
||||
func (c *Channels) SetY(y int) {
|
||||
c.List.SetY(y)
|
||||
}
|
||||
|
||||
// GetChannels will get all available channels from the SlackService
|
||||
func (c *Channels) GetChannels(svc *SlackService) {
|
||||
for _, slackChan := range svc.GetChannels() {
|
||||
label := setChannelLabel(slackChan, false)
|
||||
c.List.Items = append(c.List.Items, label)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// SetPresenceForIMChannels this will set the correct icon for
|
||||
// IM channels for when they're online of away
|
||||
func (c *Channels) SetPresenceForIMChannels(svc *SlackService) {
|
||||
for _, slackChan := range svc.GetChannels() {
|
||||
if slackChan.Type == ChannelTypeIM {
|
||||
presence, err := svc.GetUserPresence(slackChan.UserID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
c.SetPresence(svc, slackChan.UserID, presence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetSelectedChannel sets the SelectedChannel given the index
|
||||
func (c *Channels) SetSelectedChannel(index int) {
|
||||
c.SelectedChannel = index
|
||||
}
|
||||
|
||||
// MoveCursorUp will decrease the SelectedChannel by 1
|
||||
func (c *Channels) MoveCursorUp() {
|
||||
if c.SelectedChannel > 0 {
|
||||
c.SetSelectedChannel(c.SelectedChannel - 1)
|
||||
c.ScrollUp()
|
||||
c.ClearNewMessageIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// MoveCursorDown will increase the SelectedChannel by 1
|
||||
func (c *Channels) MoveCursorDown() {
|
||||
if c.SelectedChannel < len(c.List.Items)-1 {
|
||||
c.SetSelectedChannel(c.SelectedChannel + 1)
|
||||
c.ScrollDown()
|
||||
c.ClearNewMessageIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// MoveCursorTop will move the cursor to the top of the channels
|
||||
func (c *Channels) MoveCursorTop() {
|
||||
c.SetSelectedChannel(0)
|
||||
c.CursorPosition = c.List.InnerBounds().Min.Y
|
||||
c.Offset = 0
|
||||
}
|
||||
|
||||
// MoveCursorBottom will move the cursor to the bottom of the channels
|
||||
func (c *Channels) MoveCursorBottom() {
|
||||
c.SetSelectedChannel(len(c.List.Items) - 1)
|
||||
|
||||
offset := len(c.List.Items) - (c.List.InnerBounds().Max.Y - 1)
|
||||
|
||||
if offset < 0 {
|
||||
c.Offset = 0
|
||||
c.CursorPosition = c.SelectedChannel + 1
|
||||
} else {
|
||||
c.Offset = offset
|
||||
c.CursorPosition = c.List.InnerBounds().Max.Y - 1
|
||||
}
|
||||
}
|
||||
|
||||
// ScrollUp enables us to scroll through the channel list when it overflows
|
||||
func (c *Channels) ScrollUp() {
|
||||
// Is cursor at the top of the channel view?
|
||||
if c.CursorPosition == c.List.InnerBounds().Min.Y {
|
||||
if c.Offset > 0 {
|
||||
c.Offset--
|
||||
}
|
||||
} else {
|
||||
c.CursorPosition--
|
||||
}
|
||||
}
|
||||
|
||||
// ScrollDown enables us to scroll through the channel list when it overflows
|
||||
func (c *Channels) ScrollDown() {
|
||||
// Is the cursor at the bottom of the channel view?
|
||||
if c.CursorPosition == c.List.InnerBounds().Max.Y-1 {
|
||||
if c.Offset < len(c.List.Items)-1 {
|
||||
c.Offset++
|
||||
}
|
||||
} else {
|
||||
c.CursorPosition++
|
||||
}
|
||||
}
|
||||
|
||||
// Search will search through the channels to find a channel,
|
||||
// when a match has been found the selected channel will then
|
||||
// be the channel that has been found
|
||||
func (c *Channels) Search(term string) {
|
||||
for i, item := range c.List.Items {
|
||||
if strings.Contains(item, term) {
|
||||
|
||||
// The new position
|
||||
newPos := i
|
||||
|
||||
// Is the new position in range of the current view?
|
||||
minRange := c.Offset
|
||||
maxRange := c.Offset + (c.List.InnerBounds().Max.Y - 2)
|
||||
|
||||
if newPos < minRange {
|
||||
// newPos is above, we need to scroll up.
|
||||
c.SetSelectedChannel(i)
|
||||
|
||||
// How much do we need to scroll to get it into range?
|
||||
c.Offset = c.Offset - (minRange - newPos)
|
||||
} else if newPos > maxRange {
|
||||
// newPos is below, we need to scroll down
|
||||
c.SetSelectedChannel(i)
|
||||
|
||||
// How much do we need to scroll to get it into range?
|
||||
c.Offset = c.Offset + (newPos - maxRange)
|
||||
} else {
|
||||
// newPos is inside range
|
||||
c.SetSelectedChannel(i)
|
||||
}
|
||||
|
||||
// Set cursor to correct position
|
||||
c.CursorPosition = (newPos - c.Offset) + 1
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotification will be called when a new message arrives and will
|
||||
// render an notification icon in front of the channel name
|
||||
func (c *Channels) SetNotification(svc *SlackService, channelID string) {
|
||||
// Get the correct Channel from svc.Channels
|
||||
var index int
|
||||
for i, channel := range svc.Channels {
|
||||
if channelID == channel.ID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(c.List.Items[index], IconNotification) {
|
||||
// The order of svc.Channels relates to the order of
|
||||
// List.Items, index will be the index of the channel
|
||||
c.List.Items[index] = fmt.Sprintf(
|
||||
"%s %s", IconNotification, strings.TrimSpace(c.List.Items[index]),
|
||||
)
|
||||
}
|
||||
|
||||
// Play terminal bell sound
|
||||
fmt.Print("\a")
|
||||
}
|
||||
|
||||
// ClearNewMessageIndicator will remove the notification icon in front of
|
||||
// a channel that received a new message. This will happen as one will
|
||||
// move up or down the cursor for Channels
|
||||
func (c *Channels) ClearNewMessageIndicator() {
|
||||
channelName := strings.Split(
|
||||
c.List.Items[c.SelectedChannel],
|
||||
fmt.Sprintf("%s ", IconNotification),
|
||||
)
|
||||
|
||||
if len(channelName) > 1 {
|
||||
c.List.Items[c.SelectedChannel] = fmt.Sprintf(" %s", channelName[1])
|
||||
} else {
|
||||
c.List.Items[c.SelectedChannel] = channelName[0]
|
||||
}
|
||||
}
|
||||
|
||||
// SetReadMark will send the ReadMark event on the service
|
||||
func (c *Channels) SetReadMark(svc *SlackService) {
|
||||
svc.SetChannelReadMark(svc.SlackChannels[c.SelectedChannel])
|
||||
}
|
||||
|
||||
// SetPresence will set the correct icon for a IM Channel
|
||||
func (c *Channels) SetPresence(svc *SlackService, userID string, presence string) {
|
||||
// Get the correct Channel from svc.Channels
|
||||
var index int
|
||||
for i, channel := range svc.Channels {
|
||||
if userID == channel.UserID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch presence {
|
||||
case PresenceActive:
|
||||
c.List.Items[index] = strings.Replace(
|
||||
c.List.Items[index], IconOffline, IconOnline, 1,
|
||||
)
|
||||
case PresenceAway:
|
||||
c.List.Items[index] = strings.Replace(
|
||||
c.List.Items[index], IconOnline, IconOffline, 1,
|
||||
)
|
||||
default:
|
||||
c.List.Items[index] = strings.Replace(
|
||||
c.List.Items[index], IconOnline, IconOffline, 1,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// setChannelLabel will set the label of the channel, meaning, how it
|
||||
// is displayed on screen. Based on the type, different icons are
|
||||
// shown, as well as an optional notification icon.
|
||||
func setChannelLabel(channel Channel, notification bool) string {
|
||||
var prefix string
|
||||
if notification {
|
||||
prefix = IconNotification
|
||||
} else {
|
||||
prefix = " "
|
||||
}
|
||||
|
||||
var label string
|
||||
switch channel.Type {
|
||||
case ChannelTypeChannel:
|
||||
label = fmt.Sprintf("%s %s %s", prefix, IconChannel, channel.Name)
|
||||
case ChannelTypeGroup:
|
||||
label = fmt.Sprintf("%s %s %s", prefix, IconGroup, channel.Name)
|
||||
case ChannelTypeIM:
|
||||
label = fmt.Sprintf("%s %s %s", prefix, IconIM, channel.Name)
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
250
chat.go
Normal file
250
chat.go
Normal file
@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gizak/termui"
|
||||
)
|
||||
|
||||
// Chat is the definition of a Chat component
|
||||
type Chat struct {
|
||||
List *termui.List
|
||||
Offset int
|
||||
}
|
||||
|
||||
// CreateChat is the constructor for the Chat struct
|
||||
func CreateChat(svc *SlackService, inputHeight int, selectedSlackChannel interface{}, selectedChannel Channel) *Chat {
|
||||
chat := &Chat{
|
||||
List: termui.NewList(),
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
chat.List.Height = termui.TermHeight() - inputHeight
|
||||
chat.List.Overflow = "wrap"
|
||||
|
||||
chat.GetMessages(svc, selectedSlackChannel)
|
||||
chat.SetBorderLabel(selectedChannel)
|
||||
|
||||
return chat
|
||||
}
|
||||
|
||||
// Buffer implements interface termui.Bufferer
|
||||
func (c *Chat) Buffer() termui.Buffer {
|
||||
// Build cells, after every item put a newline
|
||||
cells := termui.DefaultTxBuilder.Build(
|
||||
strings.Join(c.List.Items, "\n"),
|
||||
c.List.ItemFgColor, c.List.ItemBgColor,
|
||||
)
|
||||
|
||||
// We will create an array of Line structs, this allows us
|
||||
// to more easily render the items in a list. We will range
|
||||
// over the cells we've created and create a Line within
|
||||
// the bounds of the Chat pane
|
||||
type Line struct {
|
||||
cells []termui.Cell
|
||||
}
|
||||
|
||||
lines := []Line{}
|
||||
line := Line{}
|
||||
|
||||
x := 0
|
||||
for _, cell := range cells {
|
||||
|
||||
if cell.Ch == '\n' {
|
||||
lines = append(lines, line)
|
||||
line = Line{}
|
||||
x = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if x+cell.Width() > c.List.InnerBounds().Dx() {
|
||||
lines = append(lines, line)
|
||||
line = Line{}
|
||||
x = 0
|
||||
}
|
||||
|
||||
line.cells = append(line.cells, cell)
|
||||
x++
|
||||
}
|
||||
lines = append(lines, line)
|
||||
|
||||
// We will print lines bottom up, it will loop over the lines
|
||||
// backwards and for every line it'll set the cell in that line.
|
||||
// Offset is the number which allows us to begin printing the
|
||||
// line above the last line.
|
||||
buf := c.List.Buffer()
|
||||
linesHeight := len(lines)
|
||||
paneMinY := c.List.InnerBounds().Min.Y
|
||||
paneMaxY := c.List.InnerBounds().Max.Y
|
||||
|
||||
currentY := paneMaxY - 1
|
||||
for i := (linesHeight - 1) - c.Offset; i >= 0; i-- {
|
||||
if currentY < paneMinY {
|
||||
break
|
||||
}
|
||||
|
||||
x := c.List.InnerBounds().Min.X
|
||||
for _, cell := range lines[i].cells {
|
||||
buf.Set(x, currentY, cell)
|
||||
x += cell.Width()
|
||||
}
|
||||
|
||||
// When we're not at the end of the pane, fill it up
|
||||
// with empty characters
|
||||
for x < c.List.InnerBounds().Max.X {
|
||||
buf.Set(
|
||||
x, currentY,
|
||||
termui.Cell{
|
||||
Ch: ' ',
|
||||
Fg: c.List.ItemFgColor,
|
||||
Bg: c.List.ItemBgColor,
|
||||
},
|
||||
)
|
||||
x++
|
||||
}
|
||||
currentY--
|
||||
}
|
||||
|
||||
// If the space above currentY is empty we need to fill
|
||||
// it up with blank lines, otherwise the List object will
|
||||
// render the items top down, and the result will mix.
|
||||
for currentY >= paneMinY {
|
||||
x := c.List.InnerBounds().Min.X
|
||||
for x < c.List.InnerBounds().Max.X {
|
||||
buf.Set(
|
||||
x, currentY,
|
||||
termui.Cell{
|
||||
Ch: ' ',
|
||||
Fg: c.List.ItemFgColor,
|
||||
Bg: c.List.ItemBgColor,
|
||||
},
|
||||
)
|
||||
x++
|
||||
}
|
||||
currentY--
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// GetHeight implements interface termui.GridBufferer
|
||||
func (c *Chat) GetHeight() int {
|
||||
return c.List.Block.GetHeight()
|
||||
}
|
||||
|
||||
// SetWidth implements interface termui.GridBufferer
|
||||
func (c *Chat) SetWidth(w int) {
|
||||
c.List.SetWidth(w)
|
||||
}
|
||||
|
||||
// SetX implements interface termui.GridBufferer
|
||||
func (c *Chat) SetX(x int) {
|
||||
c.List.SetX(x)
|
||||
}
|
||||
|
||||
// SetY implements interface termui.GridBufferer
|
||||
func (c *Chat) SetY(y int) {
|
||||
c.List.SetY(y)
|
||||
}
|
||||
|
||||
// GetMessages will get an array of strings for a specific channel which will
|
||||
// contain messages in turn all these messages will be added to List.Items
|
||||
func (c *Chat) GetMessages(svc *SlackService, channel interface{}) {
|
||||
// Get the count of message that fit in the pane
|
||||
count := c.List.InnerBounds().Max.Y - c.List.InnerBounds().Min.Y
|
||||
messages := svc.GetMessages(channel, count)
|
||||
|
||||
for _, message := range messages {
|
||||
c.AddMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
// AddMessage adds a single message to List.Items
|
||||
func (c *Chat) AddMessage(message string) {
|
||||
c.List.Items = append(c.List.Items, html.UnescapeString(message))
|
||||
}
|
||||
|
||||
// ClearMessages clear the List.Items
|
||||
func (c *Chat) ClearMessages() {
|
||||
c.List.Items = []string{}
|
||||
}
|
||||
|
||||
// ScrollUp will render the chat messages based on the Offset of the Chat
|
||||
// pane.
|
||||
//
|
||||
// Offset is 0 when scrolled down. (we loop backwards over the array, so we
|
||||
// start with rendering last item in the list at the maximum y of the Chat
|
||||
// pane). Increasing the Offset will thus result in substracting the offset
|
||||
// from the len(Chat.List.Items).
|
||||
func (c *Chat) ScrollUp() {
|
||||
c.Offset = c.Offset + 10
|
||||
|
||||
// Protect overscrolling
|
||||
if c.Offset > len(c.List.Items)-1 {
|
||||
c.Offset = len(c.List.Items) - 1
|
||||
}
|
||||
}
|
||||
|
||||
// ScrollDown will render the chat messages based on the Offset of the Chat
|
||||
// pane.
|
||||
//
|
||||
// Offset is 0 when scrolled down. (we loop backwards over the array, so we
|
||||
// start with rendering last item in the list at the maximum y of the Chat
|
||||
// pane). Increasing the Offset will thus result in substracting the offset
|
||||
// from the len(Chat.List.Items).
|
||||
func (c *Chat) ScrollDown() {
|
||||
c.Offset = c.Offset - 10
|
||||
|
||||
// Protect overscrolling
|
||||
if c.Offset < 0 {
|
||||
c.Offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
// SetBorderLabel will set Label of the Chat pane to the specified string
|
||||
func (c *Chat) SetBorderLabel(channel Channel) {
|
||||
var channelName string
|
||||
if channel.Topic != "" {
|
||||
channelName = fmt.Sprintf("%s - %s",
|
||||
channel.Name,
|
||||
channel.Topic,
|
||||
)
|
||||
} else {
|
||||
channelName = channel.Name
|
||||
}
|
||||
c.List.BorderLabel = channelName
|
||||
}
|
||||
|
||||
// Help shows the usage and key bindings in the chat pane
|
||||
func (c *Chat) Help(cfg *Config) {
|
||||
help := []string{
|
||||
"slack-term - slack client for your terminal",
|
||||
"",
|
||||
"USAGE:",
|
||||
" slack-term -config [path-to-config]",
|
||||
"",
|
||||
"KEY BINDINGS:",
|
||||
"",
|
||||
}
|
||||
|
||||
for mode, mapping := range cfg.KeyMap {
|
||||
help = append(help, fmt.Sprintf(" %s", strings.ToUpper(mode)))
|
||||
help = append(help, "")
|
||||
|
||||
var keys []string
|
||||
for k := range mapping {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
help = append(help, fmt.Sprintf(" %-12s%-15s", k, mapping[k]))
|
||||
}
|
||||
help = append(help, "")
|
||||
}
|
||||
|
||||
c.List.Items = help
|
||||
}
|
99
config.go
Normal file
99
config.go
Normal file
@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/gizak/termui"
|
||||
)
|
||||
|
||||
// Config is the definition of a Config struct
|
||||
type Config struct {
|
||||
SlackToken string `json:"slack_token"`
|
||||
Theme string `json:"theme"`
|
||||
SidebarWidth int `json:"sidebar_width"`
|
||||
MainWidth int `json:"-"`
|
||||
KeyMap map[string]keyMapping `json:"key_map"`
|
||||
}
|
||||
|
||||
type keyMapping map[string]string
|
||||
|
||||
// NewConfig loads the config file and returns a Config struct
|
||||
func NewConfig(filepath string) (*Config, error) {
|
||||
cfg := Config{
|
||||
Theme: "dark",
|
||||
SidebarWidth: 1,
|
||||
MainWidth: 11,
|
||||
KeyMap: map[string]keyMapping{
|
||||
"command": {
|
||||
"i": "mode-insert",
|
||||
"/": "mode-search",
|
||||
"k": "channel-up",
|
||||
"j": "channel-down",
|
||||
"g": "channel-top",
|
||||
"G": "channel-bottom",
|
||||
"<previous>": "chat-up",
|
||||
"C-b": "chat-up",
|
||||
"C-u": "chat-up",
|
||||
"<next>": "chat-down",
|
||||
"C-f": "chat-down",
|
||||
"C-d": "chat-down",
|
||||
"q": "quit",
|
||||
"<f1>": "help",
|
||||
},
|
||||
"insert": {
|
||||
"<left>": "cursor-left",
|
||||
"<right>": "cursor-right",
|
||||
"<enter>": "send",
|
||||
"<escape>": "mode-command",
|
||||
"<backspace>": "backspace",
|
||||
"C-8": "backspace",
|
||||
"<delete>": "delete",
|
||||
"<space>": "space",
|
||||
},
|
||||
"search": {
|
||||
"<left>": "cursor-left",
|
||||
"<right>": "cursor-right",
|
||||
"<escape>": "clear-input",
|
||||
"<enter>": "clear-input",
|
||||
"<backspace>": "backspace",
|
||||
"C-8": "backspace",
|
||||
"<delete>": "delete",
|
||||
"<space>": "space",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
file, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(file).Decode(&cfg); err != nil {
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
if cfg.SlackToken == "" {
|
||||
return &cfg, errors.New("couldn't find 'slack_token' parameter")
|
||||
}
|
||||
|
||||
if cfg.SidebarWidth < 1 || cfg.SidebarWidth > 11 {
|
||||
return &cfg, errors.New("please specify the 'sidebar_width' between 1 and 11")
|
||||
}
|
||||
|
||||
cfg.MainWidth = 12 - cfg.SidebarWidth
|
||||
|
||||
if cfg.Theme == "light" {
|
||||
termui.ColorMap = map[string]termui.Attribute{
|
||||
"fg": termui.ColorBlack,
|
||||
"bg": termui.ColorWhite,
|
||||
"border.fg": termui.ColorBlack,
|
||||
"label.fg": termui.ColorBlue,
|
||||
"par.fg": termui.ColorYellow,
|
||||
"par.label.bg": termui.ColorWhite,
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
45
context.go
Normal file
45
context.go
Normal file
@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gizak/termui"
|
||||
termbox "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
const (
|
||||
CommandMode = "command"
|
||||
InsertMode = "insert"
|
||||
SearchMode = "search"
|
||||
)
|
||||
|
||||
type AppContext struct {
|
||||
EventQueue chan termbox.Event
|
||||
Service *SlackService
|
||||
Body *termui.Grid
|
||||
View *View
|
||||
Config *Config
|
||||
Mode string
|
||||
}
|
||||
|
||||
// CreateAppContext creates an application context which can be passed
|
||||
// and referenced througout the application
|
||||
func CreateAppContext(flgConfig string) (*AppContext, error) {
|
||||
// Load config
|
||||
config, err := NewConfig(flgConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create Service
|
||||
svc := NewSlackService(config.SlackToken)
|
||||
|
||||
// Create ChatView
|
||||
view := CreateChatView(svc)
|
||||
|
||||
return &AppContext{
|
||||
EventQueue: make(chan termbox.Event, 20),
|
||||
Service: svc,
|
||||
View: view,
|
||||
Config: config,
|
||||
Mode: CommandMode,
|
||||
}, nil
|
||||
}
|
400
event.go
Normal file
400
event.go
Normal file
@ -0,0 +1,400 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gizak/termui"
|
||||
"github.com/nlopes/slack"
|
||||
termbox "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
var timer *time.Timer
|
||||
|
||||
// actionMap binds specific action names to the function counterparts,
|
||||
// these action names can then be used to bind them to specific keys
|
||||
// in the Config.
|
||||
var actionMap = map[string]func(*AppContext){
|
||||
"space": actionSpace,
|
||||
"backspace": actionBackSpace,
|
||||
"delete": actionDelete,
|
||||
"cursor-right": actionMoveCursorRight,
|
||||
"cursor-left": actionMoveCursorLeft,
|
||||
"send": actionSend,
|
||||
"quit": actionQuit,
|
||||
"mode-insert": actionInsertMode,
|
||||
"mode-command": actionCommandMode,
|
||||
"mode-search": actionSearchMode,
|
||||
"clear-input": actionClearInput,
|
||||
"channel-up": actionMoveCursorUpChannels,
|
||||
"channel-down": actionMoveCursorDownChannels,
|
||||
"channel-top": actionMoveCursorTopChannels,
|
||||
"channel-bottom": actionMoveCursorBottomChannels,
|
||||
"chat-up": actionScrollUpChat,
|
||||
"chat-down": actionScrollDownChat,
|
||||
"help": actionHelp,
|
||||
}
|
||||
|
||||
func RegisterEventHandlers(ctx *AppContext) {
|
||||
eventHandler(ctx)
|
||||
messageHandler(ctx)
|
||||
}
|
||||
|
||||
func eventHandler(ctx *AppContext) {
|
||||
go func() {
|
||||
for {
|
||||
ctx.EventQueue <- termbox.PollEvent()
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
ev := <-ctx.EventQueue
|
||||
handleTermboxEvents(ctx, ev)
|
||||
handleMoreTermboxEvents(ctx, ev)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func handleTermboxEvents(ctx *AppContext, ev termbox.Event) bool {
|
||||
switch ev.Type {
|
||||
case termbox.EventKey:
|
||||
actionKeyEvent(ctx, ev)
|
||||
case termbox.EventResize:
|
||||
actionResizeEvent(ctx, ev)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func handleMoreTermboxEvents(ctx *AppContext, ev termbox.Event) bool {
|
||||
for {
|
||||
select {
|
||||
case ev := <-ctx.EventQueue:
|
||||
ok := handleTermboxEvents(ctx, ev)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func messageHandler(ctx *AppContext) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case msg := <-ctx.Service.RTM.IncomingEvents:
|
||||
switch ev := msg.Data.(type) {
|
||||
case *slack.MessageEvent:
|
||||
// Construct message
|
||||
msg := ctx.Service.CreateMessageFromMessageEvent(ev)
|
||||
|
||||
// Add message to the selected channel
|
||||
if ev.Channel == ctx.Service.Channels[ctx.View.Channels.SelectedChannel].ID {
|
||||
|
||||
// reverse order of messages, mainly done
|
||||
// when attachments are added to message
|
||||
for i := len(msg) - 1; i >= 0; i-- {
|
||||
ctx.View.Chat.AddMessage(msg[i])
|
||||
}
|
||||
|
||||
termui.Render(ctx.View.Chat)
|
||||
|
||||
// TODO: set Chat.Offset to 0, to automatically scroll
|
||||
// down?
|
||||
}
|
||||
|
||||
// Set new message indicator for channel, I'm leaving
|
||||
// this here because I also want to be notified when
|
||||
// I'm currently in a channel but not in the terminal
|
||||
// window (tmux). But only create a notification when
|
||||
// it comes from someone else but the current user.
|
||||
if ev.User != ctx.Service.CurrentUserID {
|
||||
actionNewMessage(ctx, ev.Channel)
|
||||
}
|
||||
case *slack.PresenceChangeEvent:
|
||||
actionSetPresence(ctx, ev.User, ev.Presence)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func actionKeyEvent(ctx *AppContext, ev termbox.Event) {
|
||||
|
||||
keyStr := getKeyString(ev)
|
||||
|
||||
// Get the action name (actionStr) from the key that
|
||||
// has been pressed. If this is found try to uncover
|
||||
// the associated function with this key and execute
|
||||
// it.
|
||||
actionStr, ok := ctx.Config.KeyMap[ctx.Mode][keyStr]
|
||||
if ok {
|
||||
action, ok := actionMap[actionStr]
|
||||
if ok {
|
||||
action(ctx)
|
||||
}
|
||||
} else {
|
||||
if ctx.Mode == InsertMode && ev.Ch != 0 {
|
||||
actionInput(ctx.View, ev.Ch)
|
||||
} else if ctx.Mode == SearchMode && ev.Ch != 0 {
|
||||
actionSearch(ctx, ev.Ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func actionResizeEvent(ctx *AppContext, ev termbox.Event) {
|
||||
termui.Body.Width = termui.TermWidth()
|
||||
termui.Body.Align()
|
||||
termui.Render(termui.Body)
|
||||
}
|
||||
|
||||
func actionInput(view *View, key rune) {
|
||||
view.Input.Insert(key)
|
||||
termui.Render(view.Input)
|
||||
}
|
||||
|
||||
func actionClearInput(ctx *AppContext) {
|
||||
// Clear input
|
||||
ctx.View.Input.Clear()
|
||||
ctx.View.Refresh()
|
||||
|
||||
// Set command mode
|
||||
actionCommandMode(ctx)
|
||||
}
|
||||
|
||||
func actionSpace(ctx *AppContext) {
|
||||
actionInput(ctx.View, ' ')
|
||||
}
|
||||
|
||||
func actionBackSpace(ctx *AppContext) {
|
||||
ctx.View.Input.Backspace()
|
||||
termui.Render(ctx.View.Input)
|
||||
}
|
||||
|
||||
func actionDelete(ctx *AppContext) {
|
||||
ctx.View.Input.Delete()
|
||||
termui.Render(ctx.View.Input)
|
||||
}
|
||||
|
||||
func actionMoveCursorRight(ctx *AppContext) {
|
||||
ctx.View.Input.MoveCursorRight()
|
||||
termui.Render(ctx.View.Input)
|
||||
}
|
||||
|
||||
func actionMoveCursorLeft(ctx *AppContext) {
|
||||
ctx.View.Input.MoveCursorLeft()
|
||||
termui.Render(ctx.View.Input)
|
||||
}
|
||||
|
||||
func actionSend(ctx *AppContext) {
|
||||
if !ctx.View.Input.IsEmpty() {
|
||||
|
||||
// Clear message before sending, to combat
|
||||
// quick succession of actionSend
|
||||
message := ctx.View.Input.GetText()
|
||||
ctx.View.Input.Clear()
|
||||
ctx.View.Refresh()
|
||||
|
||||
ctx.View.Input.SendMessage(
|
||||
ctx.Service,
|
||||
ctx.Service.Channels[ctx.View.Channels.SelectedChannel].ID,
|
||||
message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func actionSearch(ctx *AppContext, key rune) {
|
||||
go func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
|
||||
actionInput(ctx.View, key)
|
||||
|
||||
timer = time.NewTimer(time.Second / 4)
|
||||
<-timer.C
|
||||
|
||||
term := ctx.View.Input.GetText()
|
||||
ctx.View.Channels.Search(term)
|
||||
actionChangeChannel(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// actionQuit will exit the program by using os.Exit, this is
|
||||
// done because we are using a custom termui EvtStream. Which
|
||||
// we won't be able to call termui.StopLoop() on. See main.go
|
||||
// for the customEvtStream and why this is done.
|
||||
func actionQuit(ctx *AppContext) {
|
||||
termbox.Close()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func actionInsertMode(ctx *AppContext) {
|
||||
ctx.Mode = InsertMode
|
||||
ctx.View.Mode.Par.Text = "INSERT"
|
||||
termui.Render(ctx.View.Mode)
|
||||
}
|
||||
|
||||
func actionCommandMode(ctx *AppContext) {
|
||||
ctx.Mode = CommandMode
|
||||
ctx.View.Mode.Par.Text = "NORMAL"
|
||||
termui.Render(ctx.View.Mode)
|
||||
}
|
||||
|
||||
func actionSearchMode(ctx *AppContext) {
|
||||
ctx.Mode = SearchMode
|
||||
ctx.View.Mode.Par.Text = "SEARCH"
|
||||
termui.Render(ctx.View.Mode)
|
||||
}
|
||||
|
||||
func actionGetMessages(ctx *AppContext) {
|
||||
ctx.View.Chat.GetMessages(
|
||||
ctx.Service,
|
||||
ctx.Service.Channels[ctx.View.Channels.SelectedChannel],
|
||||
)
|
||||
|
||||
termui.Render(ctx.View.Chat)
|
||||
}
|
||||
|
||||
func actionMoveCursorUpChannels(ctx *AppContext) {
|
||||
go func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
|
||||
ctx.View.Channels.MoveCursorUp()
|
||||
termui.Render(ctx.View.Channels)
|
||||
|
||||
timer = time.NewTimer(time.Second / 4)
|
||||
<-timer.C
|
||||
|
||||
actionChangeChannel(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func actionMoveCursorDownChannels(ctx *AppContext) {
|
||||
go func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
|
||||
ctx.View.Channels.MoveCursorDown()
|
||||
termui.Render(ctx.View.Channels)
|
||||
|
||||
timer = time.NewTimer(time.Second / 4)
|
||||
<-timer.C
|
||||
|
||||
actionChangeChannel(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func actionMoveCursorTopChannels(ctx *AppContext) {
|
||||
ctx.View.Channels.MoveCursorTop()
|
||||
actionChangeChannel(ctx)
|
||||
}
|
||||
|
||||
func actionMoveCursorBottomChannels(ctx *AppContext) {
|
||||
ctx.View.Channels.MoveCursorBottom()
|
||||
actionChangeChannel(ctx)
|
||||
}
|
||||
|
||||
func actionChangeChannel(ctx *AppContext) {
|
||||
// Clear messages from Chat pane
|
||||
ctx.View.Chat.ClearMessages()
|
||||
|
||||
// Get message for the new channel
|
||||
ctx.View.Chat.GetMessages(
|
||||
ctx.Service,
|
||||
ctx.Service.SlackChannels[ctx.View.Channels.SelectedChannel],
|
||||
)
|
||||
|
||||
// Set channel name for the Chat pane
|
||||
ctx.View.Chat.SetBorderLabel(
|
||||
ctx.Service.Channels[ctx.View.Channels.SelectedChannel],
|
||||
)
|
||||
|
||||
// Set read mark
|
||||
ctx.View.Channels.SetReadMark(ctx.Service)
|
||||
|
||||
termui.Render(ctx.View.Channels)
|
||||
termui.Render(ctx.View.Chat)
|
||||
}
|
||||
|
||||
func actionNewMessage(ctx *AppContext, channelID string) {
|
||||
ctx.View.Channels.SetNotification(ctx.Service, channelID)
|
||||
termui.Render(ctx.View.Channels)
|
||||
}
|
||||
|
||||
func actionSetPresence(ctx *AppContext, channelID string, presence string) {
|
||||
ctx.View.Channels.SetPresence(ctx.Service, channelID, presence)
|
||||
termui.Render(ctx.View.Channels)
|
||||
}
|
||||
|
||||
func actionScrollUpChat(ctx *AppContext) {
|
||||
ctx.View.Chat.ScrollUp()
|
||||
termui.Render(ctx.View.Chat)
|
||||
}
|
||||
|
||||
func actionScrollDownChat(ctx *AppContext) {
|
||||
ctx.View.Chat.ScrollDown()
|
||||
termui.Render(ctx.View.Chat)
|
||||
}
|
||||
|
||||
func actionHelp(ctx *AppContext) {
|
||||
ctx.View.Chat.Help(ctx.Config)
|
||||
termui.Render(ctx.View.Chat)
|
||||
}
|
||||
|
||||
// GetKeyString will return a string that resembles the key event from
|
||||
// termbox. This is blatanly copied from termui because it is an unexported
|
||||
// function.
|
||||
//
|
||||
// See:
|
||||
// - https://github.com/gizak/termui/blob/a7e3aeef4cdf9fa2edb723b1541cb69b7bb089ea/events.go#L31-L72
|
||||
// - https://github.com/nsf/termbox-go/blob/master/api_common.go
|
||||
func getKeyString(e termbox.Event) string {
|
||||
var ek string
|
||||
|
||||
k := string(e.Ch)
|
||||
pre := ""
|
||||
mod := ""
|
||||
|
||||
if e.Mod == termbox.ModAlt {
|
||||
mod = "M-"
|
||||
}
|
||||
if e.Ch == 0 {
|
||||
if e.Key > 0xFFFF-12 {
|
||||
k = "<f" + strconv.Itoa(0xFFFF-int(e.Key)+1) + ">"
|
||||
} else if e.Key > 0xFFFF-25 {
|
||||
ks := []string{"<insert>", "<delete>", "<home>", "<end>", "<previous>", "<next>", "<up>", "<down>", "<left>", "<right>"}
|
||||
k = ks[0xFFFF-int(e.Key)-12]
|
||||
}
|
||||
|
||||
if e.Key <= 0x7F {
|
||||
pre = "C-"
|
||||
k = string('a' - 1 + int(e.Key))
|
||||
kmap := map[termbox.Key][2]string{
|
||||
termbox.KeyCtrlSpace: {"C-", "<space>"},
|
||||
termbox.KeyBackspace: {"", "<backspace>"},
|
||||
termbox.KeyTab: {"", "<tab>"},
|
||||
termbox.KeyEnter: {"", "<enter>"},
|
||||
termbox.KeyEsc: {"", "<escape>"},
|
||||
termbox.KeyCtrlBackslash: {"C-", "\\"},
|
||||
termbox.KeyCtrlSlash: {"C-", "/"},
|
||||
termbox.KeySpace: {"", "<space>"},
|
||||
termbox.KeyCtrl8: {"C-", "8"},
|
||||
}
|
||||
if sk, ok := kmap[e.Key]; ok {
|
||||
pre = sk[0]
|
||||
k = sk[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ek = pre + mod + k
|
||||
return ek
|
||||
}
|
135
input.go
Normal file
135
input.go
Normal file
@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gizak/termui"
|
||||
)
|
||||
|
||||
// Input is the definition of an Input component
|
||||
type Input struct {
|
||||
Par *termui.Par
|
||||
Text []rune
|
||||
CursorPosition int
|
||||
}
|
||||
|
||||
// CreateInput is the constructor of the Input struct
|
||||
func CreateInput() *Input {
|
||||
input := &Input{
|
||||
Par: termui.NewPar(""),
|
||||
Text: make([]rune, 0),
|
||||
CursorPosition: 0,
|
||||
}
|
||||
|
||||
input.Par.Height = 3
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
// Buffer implements interface termui.Bufferer
|
||||
func (i *Input) Buffer() termui.Buffer {
|
||||
buf := i.Par.Buffer()
|
||||
|
||||
// Set visible cursor
|
||||
char := buf.At(i.Par.InnerX()+i.CursorPosition, i.Par.Block.InnerY())
|
||||
buf.Set(
|
||||
i.Par.InnerX()+i.CursorPosition,
|
||||
i.Par.Block.InnerY(),
|
||||
termui.Cell{
|
||||
Ch: char.Ch,
|
||||
Fg: i.Par.TextBgColor,
|
||||
Bg: i.Par.TextFgColor,
|
||||
},
|
||||
)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// GetHeight implements interface termui.GridBufferer
|
||||
func (i *Input) GetHeight() int {
|
||||
return i.Par.Block.GetHeight()
|
||||
}
|
||||
|
||||
// SetWidth implements interface termui.GridBufferer
|
||||
func (i *Input) SetWidth(w int) {
|
||||
i.Par.SetWidth(w)
|
||||
}
|
||||
|
||||
// SetX implements interface termui.GridBufferer
|
||||
func (i *Input) SetX(x int) {
|
||||
i.Par.SetX(x)
|
||||
}
|
||||
|
||||
// SetY implements interface termui.GridBufferer
|
||||
func (i *Input) SetY(y int) {
|
||||
i.Par.SetY(y)
|
||||
}
|
||||
|
||||
// SendMessage send the input text through the SlackService
|
||||
func (i *Input) SendMessage(svc *SlackService, channel string, message string) {
|
||||
svc.SendMessage(channel, message)
|
||||
}
|
||||
|
||||
// Insert will insert a given key at the place of the current CursorPosition
|
||||
func (i *Input) Insert(key rune) {
|
||||
if len(i.Text) < i.Par.InnerBounds().Dx()-1 {
|
||||
|
||||
left := make([]rune, len(i.Text[0:i.CursorPosition]))
|
||||
copy(left, i.Text[0:i.CursorPosition])
|
||||
left = append(left, key)
|
||||
|
||||
i.Text = append(left, i.Text[i.CursorPosition:]...)
|
||||
|
||||
i.Par.Text = string(i.Text)
|
||||
i.MoveCursorRight()
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace will remove a character in front of the CursorPosition
|
||||
func (i *Input) Backspace() {
|
||||
if i.CursorPosition > 0 {
|
||||
i.Text = append(i.Text[0:i.CursorPosition-1], i.Text[i.CursorPosition:]...)
|
||||
i.Par.Text = string(i.Text)
|
||||
i.MoveCursorLeft()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete will remove a character at the CursorPosition
|
||||
func (i *Input) Delete() {
|
||||
if i.CursorPosition < len(i.Text) {
|
||||
i.Text = append(i.Text[0:i.CursorPosition], i.Text[i.CursorPosition+1:]...)
|
||||
i.Par.Text = string(i.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// MoveCursorRight will increase the current CursorPosition with 1
|
||||
func (i *Input) MoveCursorRight() {
|
||||
if i.CursorPosition < len(i.Text) {
|
||||
i.CursorPosition++
|
||||
}
|
||||
}
|
||||
|
||||
// MoveCursorLeft will decrease the current CursorPosition with 1
|
||||
func (i *Input) MoveCursorLeft() {
|
||||
if i.CursorPosition > 0 {
|
||||
i.CursorPosition--
|
||||
}
|
||||
}
|
||||
|
||||
// IsEmpty will return true when the input is empty
|
||||
func (i *Input) IsEmpty() bool {
|
||||
if i.Par.Text == "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear will empty the input and move the cursor to the start position
|
||||
func (i *Input) Clear() {
|
||||
i.Text = make([]rune, 0)
|
||||
i.Par.Text = ""
|
||||
i.CursorPosition = 0
|
||||
}
|
||||
|
||||
// GetText returns the text currently in the input
|
||||
func (i *Input) GetText() string {
|
||||
return i.Par.Text
|
||||
}
|
104
main.go
Normal file
104
main.go
Normal file
@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
|
||||
"github.com/gizak/termui"
|
||||
termbox "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
const (
|
||||
VERSION = "v0.2.3"
|
||||
USAGE = `NAME:
|
||||
slack-term - slack client for your terminal
|
||||
|
||||
USAGE:
|
||||
slack-term -config [path-to-config]
|
||||
|
||||
VERSION:
|
||||
%s
|
||||
|
||||
GLOBAL OPTIONS:
|
||||
--help, -h
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
flgConfig string
|
||||
flgUsage bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Get home dir for config file default
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Parse flags
|
||||
flag.StringVar(
|
||||
&flgConfig,
|
||||
"config",
|
||||
path.Join(usr.HomeDir, "slack-term.json"),
|
||||
"location of config file",
|
||||
)
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Printf(USAGE, VERSION)
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Start terminal user interface
|
||||
err := termui.Init()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer termui.Close()
|
||||
|
||||
// Create custom event stream for termui because
|
||||
// termui's one has data race conditions with its
|
||||
// event handling. We're circumventing it here until
|
||||
// it has been fixed.
|
||||
customEvtStream := &termui.EvtStream{
|
||||
Handlers: make(map[string]func(termui.Event)),
|
||||
}
|
||||
termui.DefaultEvtStream = customEvtStream
|
||||
|
||||
// Create context
|
||||
ctx, err := CreateAppContext(flgConfig)
|
||||
if err != nil {
|
||||
termbox.Close()
|
||||
log.Println(err)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Setup body
|
||||
termui.Body.AddRows(
|
||||
termui.NewRow(
|
||||
termui.NewCol(ctx.Config.SidebarWidth, 0, ctx.View.Channels),
|
||||
termui.NewCol(ctx.Config.MainWidth, 0, ctx.View.Chat),
|
||||
),
|
||||
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)
|
||||
|
||||
// Set body in context
|
||||
ctx.Body = termui.Body
|
||||
|
||||
// Register handlers
|
||||
RegisterEventHandlers(ctx)
|
||||
|
||||
termui.Loop()
|
||||
}
|
82
mode.go
Normal file
82
mode.go
Normal file
@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import "github.com/gizak/termui"
|
||||
|
||||
// Mode is the definition of Mode component
|
||||
type Mode struct {
|
||||
Par *termui.Par
|
||||
}
|
||||
|
||||
// CreateMode is the constructor of the Mode struct
|
||||
func CreateMode() *Mode {
|
||||
mode := &Mode{
|
||||
Par: termui.NewPar("NORMAL"),
|
||||
}
|
||||
|
||||
mode.Par.Height = 3
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
// Buffer implements interface termui.Bufferer
|
||||
func (m *Mode) Buffer() termui.Buffer {
|
||||
buf := m.Par.Buffer()
|
||||
|
||||
// Center text
|
||||
space := m.Par.InnerWidth()
|
||||
word := len(m.Par.Text)
|
||||
|
||||
midSpace := space / 2
|
||||
midWord := word / 2
|
||||
|
||||
start := midSpace - midWord
|
||||
|
||||
cells := termui.DefaultTxBuilder.Build(
|
||||
m.Par.Text, m.Par.TextFgColor, m.Par.TextBgColor)
|
||||
|
||||
i, j := 0, 0
|
||||
x := m.Par.InnerBounds().Min.X
|
||||
for x < m.Par.InnerBounds().Max.X {
|
||||
if i < start {
|
||||
buf.Set(
|
||||
x, m.Par.InnerY(),
|
||||
termui.Cell{
|
||||
Ch: ' ',
|
||||
Fg: m.Par.TextFgColor,
|
||||
Bg: m.Par.TextBgColor,
|
||||
},
|
||||
)
|
||||
x++
|
||||
i++
|
||||
} else {
|
||||
if j < len(cells) {
|
||||
buf.Set(x, m.Par.InnerY(), cells[j])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
x++
|
||||
}
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// GetHeight implements interface termui.GridBufferer
|
||||
func (m *Mode) GetHeight() int {
|
||||
return m.Par.Block.GetHeight()
|
||||
}
|
||||
|
||||
// SetWidth implements interface termui.GridBufferer
|
||||
func (m *Mode) SetWidth(w int) {
|
||||
m.Par.SetWidth(w)
|
||||
}
|
||||
|
||||
// SetX implements interface termui.GridBufferer
|
||||
func (m *Mode) SetX(x int) {
|
||||
m.Par.SetX(x)
|
||||
}
|
||||
|
||||
// SetY implements interface termui.GridBufferer
|
||||
func (m *Mode) SetY(y int) {
|
||||
m.Par.SetY(y)
|
||||
}
|
469
slack.go
Normal file
469
slack.go
Normal file
@ -0,0 +1,469 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nlopes/slack"
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelTypeChannel = "channel"
|
||||
ChannelTypeGroup = "group"
|
||||
ChannelTypeIM = "im"
|
||||
)
|
||||
|
||||
type SlackService struct {
|
||||
Client *slack.Client
|
||||
RTM *slack.RTM
|
||||
SlackChannels []interface{}
|
||||
Channels []Channel
|
||||
UserCache map[string]string
|
||||
CurrentUserID string
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
ID string
|
||||
Name string
|
||||
Topic string
|
||||
Type string
|
||||
UserID string
|
||||
}
|
||||
|
||||
// NewSlackService is the constructor for the SlackService and will initialize
|
||||
// the RTM and a Client
|
||||
func NewSlackService(token string) *SlackService {
|
||||
svc := &SlackService{
|
||||
Client: slack.New(token),
|
||||
UserCache: make(map[string]string),
|
||||
}
|
||||
|
||||
// Get user associated with token, mainly
|
||||
// used to identify user when new messages
|
||||
// arrives
|
||||
authTest, err := svc.Client.AuthTest()
|
||||
if err != nil {
|
||||
log.Fatal("ERROR: not able to authorize client, check your connection and/or slack-token")
|
||||
}
|
||||
svc.CurrentUserID = authTest.UserID
|
||||
|
||||
// Create RTM
|
||||
svc.RTM = svc.Client.NewRTM()
|
||||
go svc.RTM.ManageConnection()
|
||||
|
||||
// Creation of user cache this speeds up
|
||||
// the uncovering of usernames of messages
|
||||
users, _ := svc.Client.GetUsers()
|
||||
for _, user := range users {
|
||||
// only add non-deleted users
|
||||
if !user.Deleted {
|
||||
svc.UserCache[user.ID] = user.Name
|
||||
}
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// GetChannels will retrieve all available channels, groups, and im channels.
|
||||
// Because the channels are of different types, we will append them to
|
||||
// an []interface as well as to a []Channel which will give us easy access
|
||||
// to the id and name of the Channel.
|
||||
func (s *SlackService) GetChannels() []Channel {
|
||||
var chans []Channel
|
||||
|
||||
// Channel
|
||||
slackChans, err := s.Client.GetChannels(true)
|
||||
if err != nil {
|
||||
chans = append(chans, Channel{})
|
||||
}
|
||||
for _, chn := range slackChans {
|
||||
s.SlackChannels = append(s.SlackChannels, chn)
|
||||
chans = append(
|
||||
chans, Channel{
|
||||
ID: chn.ID,
|
||||
Name: chn.Name,
|
||||
Topic: chn.Topic.Value,
|
||||
Type: ChannelTypeChannel,
|
||||
UserID: "",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Groups
|
||||
slackGroups, err := s.Client.GetGroups(true)
|
||||
if err != nil {
|
||||
chans = append(chans, Channel{})
|
||||
}
|
||||
for _, grp := range slackGroups {
|
||||
s.SlackChannels = append(s.SlackChannels, grp)
|
||||
chans = append(
|
||||
chans, Channel{
|
||||
ID: grp.ID,
|
||||
Name: grp.Name,
|
||||
Topic: grp.Topic.Value,
|
||||
Type: ChannelTypeGroup,
|
||||
UserID: "",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// IM
|
||||
slackIM, err := s.Client.GetIMChannels()
|
||||
if err != nil {
|
||||
chans = append(chans, Channel{})
|
||||
}
|
||||
for _, im := range slackIM {
|
||||
|
||||
// Uncover name, when we can't uncover name for
|
||||
// IM channel this is then probably a deleted
|
||||
// user, because we won't add deleted users
|
||||
// to the UserCache, so we skip it
|
||||
name, ok := s.UserCache[im.User]
|
||||
|
||||
if ok {
|
||||
chans = append(
|
||||
chans,
|
||||
Channel{
|
||||
ID: im.ID,
|
||||
Name: name,
|
||||
Topic: "",
|
||||
Type: ChannelTypeIM,
|
||||
UserID: im.User,
|
||||
},
|
||||
)
|
||||
s.SlackChannels = append(s.SlackChannels, im)
|
||||
}
|
||||
}
|
||||
|
||||
s.Channels = chans
|
||||
|
||||
return chans
|
||||
}
|
||||
|
||||
// GetUserPresence will get the presence of a specific user
|
||||
func (s *SlackService) GetUserPresence(userID string) (string, error) {
|
||||
presence, err := s.Client.GetUserPresence(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return presence.Presence, nil
|
||||
}
|
||||
|
||||
// SetChannelReadMark will set the read mark for a channel, group, and im
|
||||
// channel based on the current time.
|
||||
func (s *SlackService) SetChannelReadMark(channel interface{}) {
|
||||
switch channel := channel.(type) {
|
||||
case slack.Channel:
|
||||
s.Client.SetChannelReadMark(
|
||||
channel.ID, fmt.Sprintf("%f",
|
||||
float64(time.Now().Unix())),
|
||||
)
|
||||
case slack.Group:
|
||||
s.Client.SetGroupReadMark(
|
||||
channel.ID, fmt.Sprintf("%f",
|
||||
float64(time.Now().Unix())),
|
||||
)
|
||||
case slack.IM:
|
||||
s.Client.MarkIMChannel(
|
||||
channel.ID, fmt.Sprintf("%f",
|
||||
float64(time.Now().Unix())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage will send a message to a particular channel
|
||||
func (s *SlackService) SendMessage(channel string, message string) {
|
||||
// https://godoc.org/github.com/nlopes/slack#PostMessageParameters
|
||||
postParams := slack.PostMessageParameters{
|
||||
AsUser: true,
|
||||
}
|
||||
|
||||
// https://godoc.org/github.com/nlopes/slack#Client.PostMessage
|
||||
s.Client.PostMessage(channel, message, postParams)
|
||||
}
|
||||
|
||||
// GetMessages will get messages for a channel, group or im channel delimited
|
||||
// by a count.
|
||||
func (s *SlackService) GetMessages(channel interface{}, count int) []string {
|
||||
// https://api.slack.com/methods/channels.history
|
||||
historyParams := slack.HistoryParameters{
|
||||
Count: count,
|
||||
Inclusive: false,
|
||||
Unreads: false,
|
||||
}
|
||||
|
||||
// https://godoc.org/github.com/nlopes/slack#History
|
||||
history := new(slack.History)
|
||||
var err error
|
||||
switch chnType := channel.(type) {
|
||||
case slack.Channel:
|
||||
history, err = s.Client.GetChannelHistory(chnType.ID, historyParams)
|
||||
if err != nil {
|
||||
log.Fatal(err) // FIXME
|
||||
}
|
||||
case slack.Group:
|
||||
history, err = s.Client.GetGroupHistory(chnType.ID, historyParams)
|
||||
if err != nil {
|
||||
log.Fatal(err) // FIXME
|
||||
}
|
||||
case slack.IM:
|
||||
history, err = s.Client.GetIMHistory(chnType.ID, historyParams)
|
||||
if err != nil {
|
||||
log.Fatal(err) // FIXME
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the messages
|
||||
var messages []string
|
||||
for _, message := range history.Messages {
|
||||
msg := s.CreateMessage(message)
|
||||
messages = append(messages, msg...)
|
||||
}
|
||||
|
||||
// Reverse the order of the messages, we want the newest in
|
||||
// the last place
|
||||
var messagesReversed []string
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
messagesReversed = append(messagesReversed, messages[i])
|
||||
}
|
||||
|
||||
return messagesReversed
|
||||
}
|
||||
|
||||
// CreateMessage will create a string formatted message that can be rendered
|
||||
// in the Chat pane.
|
||||
//
|
||||
// [23:59] <erroneousboat> Hello world!
|
||||
//
|
||||
// This returns an array of string because we will try to uncover attachments
|
||||
// associated with messages.
|
||||
func (s *SlackService) CreateMessage(message slack.Message) []string {
|
||||
var msgs []string
|
||||
var name string
|
||||
|
||||
// Get username from cache
|
||||
name, ok := s.UserCache[message.User]
|
||||
|
||||
// Name not in cache
|
||||
if !ok {
|
||||
if message.BotID != "" {
|
||||
// Name not found, perhaps a bot, use Username
|
||||
name, ok = s.UserCache[message.BotID]
|
||||
if !ok {
|
||||
// Not found in cache, add it
|
||||
name = message.Username
|
||||
s.UserCache[message.BotID] = message.Username
|
||||
}
|
||||
} else {
|
||||
// Not a bot, not in cache, get user info
|
||||
user, err := s.Client.GetUserInfo(message.User)
|
||||
if err != nil {
|
||||
name = "unknown"
|
||||
s.UserCache[message.User] = name
|
||||
} else {
|
||||
name = user.Name
|
||||
s.UserCache[message.User] = user.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
|
||||
// When there are attachments append them
|
||||
if len(message.Attachments) > 0 {
|
||||
msgs = append(msgs, createMessageFromAttachments(message.Attachments)...)
|
||||
}
|
||||
|
||||
// Parse time
|
||||
floatTime, err := strconv.ParseFloat(message.Timestamp, 64)
|
||||
if err != nil {
|
||||
floatTime = 0.0
|
||||
}
|
||||
intTime := int64(floatTime)
|
||||
|
||||
// Format message
|
||||
msg := fmt.Sprintf(
|
||||
"[%s] <%s> %s",
|
||||
time.Unix(intTime, 0).Format("15:04"),
|
||||
name,
|
||||
parseMessage(s, message.Text),
|
||||
)
|
||||
|
||||
msgs = append(msgs, msg)
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
func (s *SlackService) CreateMessageFromMessageEvent(message *slack.MessageEvent) []string {
|
||||
|
||||
var msgs []string
|
||||
var name string
|
||||
|
||||
// Append (edited) when an edited message is received
|
||||
if message.SubType == "message_changed" {
|
||||
message = &slack.MessageEvent{Msg: *message.SubMessage}
|
||||
message.Text = fmt.Sprintf("%s (edited)", message.Text)
|
||||
}
|
||||
|
||||
// Get username from cache
|
||||
name, ok := s.UserCache[message.User]
|
||||
|
||||
// Name not in cache
|
||||
if !ok {
|
||||
if message.BotID != "" {
|
||||
// Name not found, perhaps a bot, use Username
|
||||
name, ok = s.UserCache[message.BotID]
|
||||
if !ok {
|
||||
// Not found in cache, add it
|
||||
name = message.Username
|
||||
s.UserCache[message.BotID] = message.Username
|
||||
}
|
||||
} else {
|
||||
// Not a bot, not in cache, get user info
|
||||
user, err := s.Client.GetUserInfo(message.User)
|
||||
if err != nil {
|
||||
name = "unknown"
|
||||
s.UserCache[message.User] = name
|
||||
} else {
|
||||
name = user.Name
|
||||
s.UserCache[message.User] = user.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
|
||||
// When there are attachments append them
|
||||
if len(message.Attachments) > 0 {
|
||||
msgs = append(msgs, createMessageFromAttachments(message.Attachments)...)
|
||||
}
|
||||
|
||||
// Parse time
|
||||
floatTime, err := strconv.ParseFloat(message.Timestamp, 64)
|
||||
if err != nil {
|
||||
floatTime = 0.0
|
||||
}
|
||||
intTime := int64(floatTime)
|
||||
|
||||
// Format message
|
||||
msg := fmt.Sprintf(
|
||||
"[%s] <%s> %s",
|
||||
time.Unix(intTime, 0).Format("15:04"),
|
||||
name,
|
||||
parseMessage(s, message.Text),
|
||||
)
|
||||
|
||||
msgs = append(msgs, msg)
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
// parseMessage will parse a message string and find and replace:
|
||||
// - emoji's
|
||||
// - mentions
|
||||
func parseMessage(s *SlackService, msg string) string {
|
||||
// NOTE: Commented out because rendering of the emoji's
|
||||
// creates artifacts from the last view because of
|
||||
// double width emoji's
|
||||
// msg = parseEmoji(msg)
|
||||
|
||||
msg = parseMentions(s, msg)
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// parseMentions will try to find mention placeholders in the message
|
||||
// string and replace them with the correct username with and @ symbol
|
||||
//
|
||||
// Mentions have the following format:
|
||||
// <@U12345|erroneousboat>
|
||||
// <@U12345>
|
||||
func parseMentions(s *SlackService, msg string) string {
|
||||
r := regexp.MustCompile(`\<@(\w+\|*\w+)\>`)
|
||||
rs := r.FindStringSubmatch(msg)
|
||||
if len(rs) < 1 {
|
||||
return msg
|
||||
}
|
||||
|
||||
return r.ReplaceAllStringFunc(
|
||||
msg, func(str string) string {
|
||||
var userID string
|
||||
split := strings.Split(rs[1], "|")
|
||||
if len(split) > 0 {
|
||||
userID = split[0]
|
||||
} else {
|
||||
userID = rs[1]
|
||||
}
|
||||
|
||||
name, ok := s.UserCache[userID]
|
||||
if !ok {
|
||||
user, err := s.Client.GetUserInfo(userID)
|
||||
if err != nil {
|
||||
name = "unknown"
|
||||
s.UserCache[userID] = name
|
||||
} else {
|
||||
name = user.Name
|
||||
s.UserCache[userID] = user.Name
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
|
||||
return "@" + name
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// parseEmoji will try to find emoji placeholders in the message
|
||||
// string and replace them with the correct unicode equivalent
|
||||
func parseEmoji(msg string) string {
|
||||
r := regexp.MustCompile("(:\\w+:)")
|
||||
|
||||
return r.ReplaceAllStringFunc(
|
||||
msg, func(str string) string {
|
||||
code, ok := EmojiCodemap[str]
|
||||
if !ok {
|
||||
return str
|
||||
}
|
||||
return code
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// createMessageFromAttachments will construct a array of string of the Field
|
||||
// values of Attachments from a Message.
|
||||
func createMessageFromAttachments(atts []slack.Attachment) []string {
|
||||
var msgs []string
|
||||
for _, att := range atts {
|
||||
for i := len(att.Fields) - 1; i >= 0; i-- {
|
||||
msgs = append(msgs,
|
||||
fmt.Sprintf(
|
||||
"%s %s",
|
||||
att.Fields[i].Title,
|
||||
att.Fields[i].Value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if att.Text != "" {
|
||||
msgs = append(msgs, att.Text)
|
||||
}
|
||||
|
||||
if att.Title != "" {
|
||||
msgs = append(msgs, att.Title)
|
||||
}
|
||||
}
|
||||
|
||||
return msgs
|
||||
}
|
45
view_chat.go
Normal file
45
view_chat.go
Normal file
@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gizak/termui"
|
||||
)
|
||||
|
||||
type View struct {
|
||||
Input *Input
|
||||
Chat *Chat
|
||||
Channels *Channels
|
||||
Mode *Mode
|
||||
}
|
||||
|
||||
func CreateChatView(svc *SlackService) *View {
|
||||
input := CreateInput()
|
||||
|
||||
channels := CreateChannels(svc, input.Par.Height)
|
||||
|
||||
chat := CreateChat(
|
||||
svc,
|
||||
input.Par.Height,
|
||||
svc.SlackChannels[channels.SelectedChannel],
|
||||
svc.Channels[channels.SelectedChannel],
|
||||
)
|
||||
|
||||
mode := CreateMode()
|
||||
|
||||
view := &View{
|
||||
Input: input,
|
||||
Channels: channels,
|
||||
Chat: chat,
|
||||
Mode: mode,
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func (v *View) Refresh() {
|
||||
termui.Render(
|
||||
v.Input,
|
||||
v.Chat,
|
||||
v.Channels,
|
||||
v.Mode,
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user