cordless/ui/window.go

2867 lines
92 KiB
Go

package ui
import (
"bytes"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"github.com/mattn/go-runewidth"
"github.com/mdp/qrterminal/v3"
"github.com/Bios-Marcel/cordless/fileopen"
"github.com/Bios-Marcel/cordless/util/files"
"github.com/Bios-Marcel/cordless/util/fuzzy"
"github.com/Bios-Marcel/cordless/util/text"
"github.com/Bios-Marcel/cordless/version"
"github.com/Bios-Marcel/discordemojimap"
"github.com/Bios-Marcel/goclipimg"
"github.com/atotto/clipboard"
"github.com/Bios-Marcel/discordgo"
"github.com/gdamore/tcell"
"github.com/gen2brain/beeep"
"github.com/Bios-Marcel/cordless/tview"
"github.com/Bios-Marcel/cordless/commands"
"github.com/Bios-Marcel/cordless/config"
"github.com/Bios-Marcel/cordless/discordutil"
"github.com/Bios-Marcel/cordless/readstate"
"github.com/Bios-Marcel/cordless/scripting"
"github.com/Bios-Marcel/cordless/scripting/js"
"github.com/Bios-Marcel/cordless/shortcuts"
"github.com/Bios-Marcel/cordless/ui/tviewutil"
"github.com/Bios-Marcel/cordless/util/maths"
)
var (
shortcutsDialogShortcut = tcell.NewEventKey(tcell.KeyCtrlK, rune(tcell.KeyCtrlK), tcell.ModCtrl)
)
// Window is basically the whole application, as it contains all the
// components and the necessary global state.
type Window struct {
app *tview.Application
middleContainer *tview.Flex
rootContainer *tview.Flex
dialogReplacement *tview.Flex
dialogButtonBar *tview.Flex
dialogTextView *tview.TextView
currentContainer tview.Primitive
leftArea *tview.Flex
guildList *GuildList
guildPage *tview.Flex
channelTree *ChannelTree
privateList *PrivateChatList
chatArea *tview.Flex
chatView *ChatView
messageContainer tview.Primitive
messageInput *Editor
editingMessageID *string
messageLoader *discordutil.MessageLoader
userList *UserTree
session *discordgo.Session
selectedGuildNode *tview.TreeNode
previousGuildNode *tview.TreeNode
selectedGuild *discordgo.Guild
previousGuild *discordgo.Guild
selectedChannelNode *tview.TreeNode
previousChannelNode *tview.TreeNode
selectedChannel *discordgo.Channel
previousChannel *discordgo.Channel
extensionEngines []scripting.Engine
commandMode bool
commandView *CommandView
commands []commands.Command
userActive bool
userActiveTimer *time.Timer
doRestart chan bool
bareChat bool
activeView ActiveView
}
type ActiveView bool
const Guilds ActiveView = true
const Dms ActiveView = false
//NewWindow constructs the whole application window and also registers all
//necessary handlers and functions. If this function returns an error, we can't
//start the application.
func NewWindow(doRestart chan bool, app *tview.Application, session *discordgo.Session, readyEvent *discordgo.Ready) (*Window, error) {
window := &Window{
doRestart: doRestart,
session: session,
app: app,
activeView: Guilds,
extensionEngines: []scripting.Engine{js.New()},
messageLoader: discordutil.CreateMessageLoader(session),
}
if config.Current.DesktopNotificationsUserInactivityThreshold > 0 {
window.userActiveTimer = time.NewTimer(time.Duration(config.Current.DesktopNotificationsUserInactivityThreshold) * time.Second)
go func() {
for {
<-window.userActiveTimer.C
window.userActive = false
}
}()
}
window.commandView = NewCommandView(window.ExecuteCommand)
log.SetOutput(window.commandView)
for _, engine := range window.extensionEngines {
initError := window.initExtensionEngine(engine)
if initError != nil {
return nil, initError
}
}
guilds := readyEvent.Guilds
mentionWindowRootNode := tview.NewTreeNode("")
autocompleteView := tview.NewTreeView().
SetVimBindingsEnabled(false).
SetRoot(mentionWindowRootNode).
SetTopLevel(1).
SetCycleSelection(true)
autocompleteView.SetBorder(true)
autocompleteView.SetBorderSides(false, true, false, true)
window.leftArea = tview.NewFlex().SetDirection(tview.FlexRow)
window.guildPage = tview.NewFlex()
window.guildPage.SetDirection(tview.FlexRow)
channelTree := NewChannelTree(window.session.State)
window.channelTree = channelTree
channelTree.SetOnChannelSelect(func(channelID string) {
channel, cacheError := window.session.State.Channel(channelID)
if cacheError == nil {
go func() {
window.chatView.Lock()
defer window.chatView.Unlock()
window.QueueUpdateDrawSynchronized(func() {
loadError := window.LoadChannel(channel)
if loadError == nil {
channelTree.MarkChannelAsLoaded(channelID)
}
})
}()
}
})
window.registerGuildChannelHandler()
discordutil.SortGuilds(window.session.State.Settings, guilds)
guildList := NewGuildList(guilds, window)
window.guildList = guildList
window.updateUnreadGuildAmount(guildList.GetRoot())
guildList.SetOnGuildSelect(func(node *tview.TreeNode, guildID string) {
if window.selectedGuild != nil && window.selectedGuildNode != nil {
window.updateServerReadStatus(window.selectedGuild.ID, window.selectedGuildNode, false)
}
guild, cacheError := window.session.Guild(guildID)
if cacheError != nil {
window.ShowErrorDialog(cacheError.Error())
return
}
// previousGuild and previousGuildNode should be set initially.
// If going from first guild -> private chat, SwitchToPreviousChannel would crash.
if window.selectedGuildNode == nil {
window.previousGuildNode = node
window.previousGuild = guild
} else if window.previousGuildNode != window.selectedGuildNode {
window.previousGuildNode = window.selectedGuildNode
window.previousGuild = window.selectedGuild
}
window.selectedGuildNode = node
window.selectedGuild = guild
window.updateServerReadStatus(window.selectedGuild.ID, window.selectedGuildNode, true)
//FIXME Request presences as soon as that stuff remotely works?
requestError := session.RequestGuildMembers(guildID, "", 0, false)
if requestError != nil {
fmt.Fprintln(window.commandView, "Error retrieving all guild members.")
}
channelLoadError := window.channelTree.LoadGuild(guildID)
if channelLoadError != nil {
window.ShowErrorDialog(channelLoadError.Error())
} else {
if config.Current.FocusChannelAfterGuildSelection {
app.SetFocus(window.channelTree)
}
}
//Currently has to happen before userlist loading, as it might not load otherwise
window.RefreshLayout()
window.userList.Clear()
if window.userList.internalTreeView.IsVisible() {
userLoadError := window.userList.LoadGuild(guildID)
if userLoadError != nil {
window.ShowErrorDialog(userLoadError.Error())
}
}
})
window.registerGuildHandlers()
window.registerGuildMemberHandlers()
window.guildPage.AddItem(guildList, 0, 1, true)
window.guildPage.AddItem(channelTree, 0, 2, false)
window.privateList = NewPrivateChatList(window.session.State)
window.privateList.Load()
window.registerPrivateChatsHandler()
window.leftArea.AddItem(window.privateList.GetComponent(), 1, 0, false)
window.leftArea.AddItem(window.guildPage, 0, 1, false)
window.privateList.SetOnChannelSelect(func(node *tview.TreeNode, channelID string) {
channel, stateError := window.session.State.Channel(channelID)
if stateError != nil {
window.ShowErrorDialog(fmt.Sprintf("Error loading chat: %s", stateError.Error()))
return
}
go func() {
window.chatView.Lock()
defer window.chatView.Unlock()
window.QueueUpdateDrawSynchronized(func() {
window.userList.Clear()
window.RefreshLayout()
if channel.Type == discordgo.ChannelTypeGroupDM {
if window.userList.internalTreeView.IsVisible() {
loadError := window.userList.LoadGroup(channel.ID)
if loadError != nil {
fmt.Fprintln(window.commandView.commandOutput, "Error loading users for channel.")
}
}
}
})
window.QueueUpdateDrawSynchronized(func() {
window.LoadChannel(channel)
})
}()
})
window.privateList.SetOnFriendSelect(func(userID string) {
go func() {
window.chatView.Lock()
defer window.chatView.Unlock()
userChannels, _ := window.session.UserChannels()
for _, userChannel := range userChannels {
if userChannel.Type == discordgo.ChannelTypeDM && userChannel.Recipients[0].ID == userID {
window.QueueUpdateDrawSynchronized(func() {
window.loadPrivateChannel(userChannel)
})
return
}
}
newChannel, discordError := window.session.UserChannelCreate(userID)
if discordError == nil {
messages, discordError := window.session.ChannelMessages(newChannel.ID, 100, "", "", "")
if discordError == nil {
for _, message := range messages {
window.session.State.MessageAdd(message)
}
}
window.QueueUpdateDrawSynchronized(func() {
window.loadPrivateChannel(newChannel)
})
}
}()
})
window.chatArea = tview.NewFlex().
SetDirection(tview.FlexRow)
window.chatView = NewChatView(window.session.State, window.session.State.User.ID)
window.chatView.SetOnMessageAction(func(message *discordgo.Message, event *tcell.EventKey) *tcell.EventKey {
if shortcuts.QuoteSelectedMessage.Equals(event) {
window.insertQuoteOfMessage(message)
return nil
}
if shortcuts.ReplySelectedMessage.Equals(event) {
window.messageInput.SetText("@" + message.Author.Username + "#" + message.Author.Discriminator + " " + window.messageInput.GetText())
app.SetFocus(window.messageInput.GetPrimitive())
return nil
}
if shortcuts.CopySelectedMessageLink.Equals(event) {
copyError := clipboard.WriteAll(fmt.Sprintf("<https://discordapp.com/channels/@me/%s/%s>", message.ChannelID, message.ID))
if copyError != nil {
window.ShowErrorDialog(fmt.Sprintf("Error copying message link: %s", copyError.Error()))
}
return nil
}
if shortcuts.DeleteSelectedMessage.Equals(event) {
if message.Author.ID == window.session.State.User.ID {
window.askForMessageDeletion(message.ID, true)
}
return nil
}
if shortcuts.EditSelectedMessage.Equals(event) {
window.startEditingMessage(message)
return nil
}
if shortcuts.CopySelectedMessage.Equals(event) {
content := message.ContentWithMentionsReplaced()
if len(message.Attachments) == 1 {
if content == "" {
content = message.Attachments[0].URL
} else {
content += "\n" + message.Attachments[0].URL
}
} else if len(message.Attachments) > 1 {
links := make([]string, 0, len(message.Attachments))
for _, file := range message.Attachments {
links = append(links, file.URL)
}
if content == "" {
content = strings.Join(links, "\n")
} else {
content += "\n" + strings.Join(links, "\n")
}
}
copyError := clipboard.WriteAll(content)
if copyError != nil {
window.ShowErrorDialog(fmt.Sprintf("Error copying message: %s", copyError.Error()))
}
return nil
}
if shortcuts.ViewSelectedMessageImages.Equals(event) {
var targetFolder string
if config.Current.FileOpenSaveFilesPermanently {
absolutePath, pathError := files.ToAbsolutePath(config.Current.FileOpenSaveFolder)
if pathError == nil {
targetFolder = absolutePath
}
}
if targetFolder == "" {
cacheDir, osError := os.UserCacheDir()
if osError == nil && cacheDir != "" {
//Own subdirectory to avoid nuking foreing files by accident.
targetFolder = filepath.Join(cacheDir, "cordless")
makeDirError := os.MkdirAll(targetFolder, 0766)
if makeDirError != nil {
window.ShowCustomErrorDialog("Couldn't open file", "Can't create cache subdirectory.")
return nil
}
}
}
if targetFolder == "" {
window.ShowCustomErrorDialog("Couldn't open file", "Can't find cache directory.")
} else {
for _, file := range message.Attachments {
openError := fileopen.OpenFile(targetFolder, file.ID, file.URL)
if openError != nil {
window.ShowCustomErrorDialog("Couldn't open file", openError.Error())
}
}
}
//If permanent saving isn't disabled, we clear files older
//than one month whenever something is opened. Since this
//will happen in a background thread, it won't cause
//application blocking.
if !config.Current.FileOpenSaveFilesPermanently && targetFolder != "" {
fileopen.LaunchCacheCleaner(targetFolder, time.Hour*(24*14))
}
return nil
}
return event
})
window.messageContainer = window.chatView.GetPrimitive()
window.messageInput = NewEditor()
window.messageInput.internalTextView.SetIndicateOverflow(true)
window.messageInput.SetOnHeightChangeRequest(func(height int) {
_, _, _, chatViewHeight := window.chatView.internalTextView.GetRect()
newHeight := maths.Min(height, chatViewHeight/2)
window.chatArea.ResizeItem(window.messageInput.GetPrimitive(), newHeight, 0)
})
window.messageInput.SetAutocompleteValuesUpdateHandler(func(values []*AutocompleteValue) {
autocompleteView.GetRoot().ClearChildren()
if len(values) == 0 {
autocompleteView.SetVisible(false)
window.app.SetFocus(window.messageInput.GetPrimitive())
} else {
rootNode := autocompleteView.GetRoot()
for _, value := range values {
newNode := tview.NewTreeNode(value.RenderValue)
newNode.SetReference(value)
rootNode.AddChild(newNode)
}
autocompleteView.SetCurrentNode(rootNode)
autocompleteView.SetVisible(true)
window.app.SetFocus(autocompleteView)
window.chatArea.ResizeItem(autocompleteView, maths.Min(10, len(values)), 0)
}
})
autocompleteView.SetSelectedFunc(func(node *tview.TreeNode) {
value := node.GetReference().(*AutocompleteValue)
window.messageInput.Autocomplete(value.InsertValue)
window.app.SetFocus(window.messageInput.GetPrimitive())
autocompleteView.SetVisible(false)
})
window.messageInput.RegisterAutocomplete('#', false, func(value string) []*AutocompleteValue {
if window.selectedChannel != nil && window.selectedChannel.GuildID != "" {
guild, stateError := session.State.Guild(window.selectedChannel.GuildID)
if stateError != nil {
return nil
}
filtered := fuzzy.ScoreAndSortChannels(value, guild.Channels)
var autocompleteValues []*AutocompleteValue
for _, channel := range filtered {
if channel.Type != discordgo.ChannelTypeGuildText {
continue
}
autocompleteValues = append(autocompleteValues, &AutocompleteValue{
RenderValue: channel.Name,
InsertValue: "<#" + channel.ID + ">",
})
}
return autocompleteValues
}
return nil
})
window.messageInput.RegisterAutocomplete('@', true, func(value string) []*AutocompleteValue {
if window.selectedChannel != nil {
guildID := window.selectedChannel.GuildID
var autocompleteValues []*AutocompleteValue
if guildID != "" {
guild, stateError := session.State.Guild(guildID)
if stateError == nil {
filteredRoles := fuzzy.ScoreAndSortRoles(value, guild.Roles)
for _, role := range filteredRoles {
autocompleteValues = append(autocompleteValues, &AutocompleteValue{
RenderValue: role.Name,
//FIXME Inconsistent, we should change this.
InsertValue: "<@&" + role.ID + ">",
})
}
filteredMembers := fuzzy.ScoreAndSortMembers(value, guild.Members)
for _, member := range filteredMembers {
insertValue := member.User.Username + "#" + member.User.Discriminator
var renderValue string
if member.Nick != "" {
renderValue = insertValue + " | " + member.Nick
} else {
renderValue = insertValue
}
autocompleteValues = append(autocompleteValues, &AutocompleteValue{
RenderValue: renderValue,
InsertValue: "@" + insertValue,
})
}
return autocompleteValues
}
}
filtered := fuzzy.ScoreAndSortUsers(value, window.selectedChannel.Recipients)
for _, user := range filtered {
insertValue := user.Username + "#" + user.Discriminator
autocompleteValues = append(autocompleteValues, &AutocompleteValue{
RenderValue: insertValue,
InsertValue: "@" + insertValue,
})
}
return autocompleteValues
}
return nil
})
emojisAsArray := make([]string, 0, len(discordemojimap.EmojiMap))
for emoji := range discordemojimap.EmojiMap {
emojisAsArray = append(emojisAsArray, emoji)
}
var globallyUsableCustomEmoji []*discordgo.Emoji
if window.session.State.User.PremiumType == discordgo.UserPremiumTypeNone {
for _, guild := range window.session.State.Guilds {
for _, emoji := range guild.Emojis {
if emoji.Animated {
continue
}
if !strings.HasPrefix(emoji.Name, "GW") {
continue
}
globallyUsableCustomEmoji = append(globallyUsableCustomEmoji, emoji)
}
}
} else {
for _, guild := range window.session.State.Guilds {
for _, emoji := range guild.Emojis {
globallyUsableCustomEmoji = append(globallyUsableCustomEmoji, emoji)
}
}
}
emojisByGuild := make(map[string][]*discordgo.Emoji)
window.messageInput.RegisterAutocomplete(':', false, func(value string) []*AutocompleteValue {
var autocompleteValues []*AutocompleteValue
var customEmojiUsableInContext []*discordgo.Emoji
if window.session.State.User.PremiumType == discordgo.UserPremiumTypeNone {
if window.selectedChannel != nil && window.selectedChannel.GuildID != "" {
guildID := window.selectedChannel.GuildID
var cached bool
customEmojiUsableInContext, cached = emojisByGuild[guildID]
if cached {
goto EVALUATE_EMOJIS
}
//Non premium users can only use the non-animated guildemojis
guild, stateError := window.session.State.Guild(guildID)
if stateError == nil {
for _, emoji := range guild.Emojis {
if emoji.Animated {
continue
}
customEmojiUsableInContext = append(customEmojiUsableInContext, emoji)
}
customEmojiUsableInContext = append(customEmojiUsableInContext, globallyUsableCustomEmoji...)
emojisByGuild[window.selectedChannel.GuildID] = customEmojiUsableInContext
}
} else {
//If not in any guild channel, we can only use the global ones
customEmojiUsableInContext = globallyUsableCustomEmoji
}
} else {
//For non-nitro users everything's available anyway
customEmojiUsableInContext = globallyUsableCustomEmoji
}
EVALUATE_EMOJIS:
filteredEmoji := fuzzy.ScoreAndSortEmoji(value, emojisAsArray, customEmojiUsableInContext)
for _, emoji := range filteredEmoji {
unicodeSymbol := discordemojimap.GetEmoji(emoji)
var renderValue string
var insertValue string
if unicodeSymbol != "" {
renderValue = unicodeSymbol + " | " + emoji
insertValue = unicodeSymbol
} else {
trimmed := emoji[1:]
renderValue = "? | " + trimmed + " (custom emoji)"
insertValue = ":!" + trimmed + ":"
}
autocompleteValues = append(autocompleteValues, &AutocompleteValue{
RenderValue: renderValue,
InsertValue: insertValue,
})
}
return autocompleteValues
})
captureFunc := func(event *tcell.EventKey) *tcell.EventKey {
messageToSend := window.messageInput.GetText()
if event.Modifiers() == tcell.ModAlt {
if event.Key() == tcell.KeyUp {
window.app.SetFocus(window.chatView.internalTextView)
return nil
}
if event.Key() == tcell.KeyDown {
if window.commandMode {
window.app.SetFocus(window.commandView.commandOutput)
} else {
window.app.SetFocus(window.chatView.internalTextView)
}
return nil
}
if event.Key() == tcell.KeyRight {
if window.userList.internalTreeView.IsVisible() {
window.app.SetFocus(window.userList.internalTreeView)
} else {
if window.activeView == Guilds {
window.app.SetFocus(window.channelTree)
return nil
} else if window.activeView == Dms {
window.app.SetFocus(window.privateList.internalTreeView)
return nil
}
}
return nil
}
if event.Key() == tcell.KeyLeft {
if window.activeView == Guilds {
window.app.SetFocus(window.channelTree)
return nil
} else if window.activeView == Dms {
window.app.SetFocus(window.privateList.internalTreeView)
return nil
}
}
}
if event.Modifiers() == tcell.ModCtrl {
if event.Key() == tcell.KeyUp {
window.chatView.internalTextView.ScrollUp()
return nil
}
if event.Key() == tcell.KeyDown {
window.chatView.internalTextView.ScrollDown()
return nil
}
}
if event.Key() == tcell.KeyPgUp {
handler := window.chatView.internalTextView.InputHandler()
handler(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone), nil)
return nil
}
if event.Key() == tcell.KeyPgDn {
handler := window.chatView.internalTextView.InputHandler()
handler(tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone), nil)
return nil
}
chooseNextMessageToEdit := func(loopStart, loopEnd int, iterNext func(int) int) *discordgo.Message {
if len(window.chatView.data) == 0 {
return nil
}
window.chatView.Lock()
defer window.chatView.Unlock()
var chooseNextMatch bool
for i := loopStart; i != loopEnd; i = iterNext(i) {
message := window.chatView.data[i]
if message.Author.ID == window.session.State.User.ID {
if !chooseNextMatch && window.editingMessageID != nil && *window.editingMessageID == message.ID {
chooseNextMatch = true
continue
}
if window.editingMessageID == nil || chooseNextMatch {
return message
}
}
}
return nil
}
//When you are already typing a message, you probably don't want to risk loosing it.
if event.Key() == tcell.KeyUp && (messageToSend == "" || window.editingMessageID != nil) {
messageToEdit := chooseNextMessageToEdit(len(window.chatView.data)-1, -1, func(i int) int { return i - 1 })
if messageToEdit != nil {
window.startEditingMessage(messageToEdit)
}
return nil
}
if event.Key() == tcell.KeyDown && window.editingMessageID != nil {
messageToEdit := chooseNextMessageToEdit(0, len(window.chatView.data), func(i int) int { return i + 1 })
if messageToEdit != nil {
window.startEditingMessage(messageToEdit)
}
return nil
}
if event.Key() == tcell.KeyEsc {
window.exitMessageEditMode()
return nil
}
if event.Key() == tcell.KeyCtrlV && window.selectedChannel != nil {
data, clipError := goclipimg.GetImageFromClipboard()
if clipError == goclipimg.ErrNoImageInClipboard {
return event
}
if clipError == nil {
dataChannel := bytes.NewReader(data)
targetChannel := window.selectedChannel
currentText := window.prepareMessage(targetChannel, strings.TrimSpace(window.messageInput.GetText()))
if currentText == "" {
go window.session.ChannelFileSend(targetChannel.ID, "img.png", dataChannel)
} else {
messageData := &discordgo.MessageSend{
Content: currentText,
File: &discordgo.File{
Name: "img.png",
ContentType: "image/png",
Reader: dataChannel,
},
}
go window.session.ChannelMessageSendComplex(targetChannel.ID, messageData)
window.messageInput.SetText("")
}
} else {
window.ShowErrorDialog(fmt.Sprintf("Error pasting image: %s", clipError.Error()))
}
return nil
}
if shortcuts.AddNewLineInCodeBlock.Equals(event) && window.IsCursorInsideCodeBlock() {
window.insertNewLineAtCursor()
return nil
} else if shortcuts.SendMessage.Equals(event) {
if window.selectedChannel != nil {
window.TrySendMessage(window.selectedChannel, messageToSend)
}
return nil
}
return event
}
window.messageInput.SetInputCapture(captureFunc)
//FIXME Buffering might just be retarded, as the event handlers are launched in separate routines either way.
messageInputChan := make(chan *discordgo.Message)
messageDeleteChan := make(chan *discordgo.Message)
messageEditChan := make(chan *discordgo.Message)
messageBulkDeleteChan := make(chan *discordgo.MessageDeleteBulk)
window.registerMessageEventHandler(messageInputChan, messageEditChan, messageDeleteChan, messageBulkDeleteChan)
window.startMessageHandlerRoutines(messageInputChan, messageEditChan, messageDeleteChan, messageBulkDeleteChan)
window.userList = NewUserTree(window.session.State)
if config.Current.OnTypeInListBehaviour == config.SearchOnTypeInList {
guildList.SetSearchOnTypeEnabled(true)
channelTree.SetSearchOnTypeEnabled(true)
window.userList.internalTreeView.SetSearchOnTypeEnabled(true)
window.privateList.internalTreeView.SetSearchOnTypeEnabled(true)
} else if config.Current.OnTypeInListBehaviour == config.FocusMessageInputOnTypeInList {
guildList.SetInputCapture(tviewutil.CreateFocusTextViewOnTypeInputHandler(
window.app, window.messageInput.internalTextView))
channelTree.SetInputCapture(tviewutil.CreateFocusTextViewOnTypeInputHandler(
window.app, window.messageInput.internalTextView))
window.userList.SetInputCapture(tviewutil.CreateFocusTextViewOnTypeInputHandler(
window.app, window.messageInput.internalTextView))
window.privateList.SetInputCapture(tviewutil.CreateFocusTextViewOnTypeInputHandler(
window.app, window.messageInput.internalTextView))
window.chatView.internalTextView.SetInputCapture(tviewutil.CreateFocusTextViewOnTypeInputHandler(
window.app, window.messageInput.internalTextView))
}
//Guild Container arrow key navigation. Please end my life.
oldGuildListHandler := guildList.GetInputCapture()
newGuildHandler := func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers() == tcell.ModAlt {
if event.Key() == tcell.KeyDown || event.Key() == tcell.KeyUp {
window.app.SetFocus(window.channelTree)
return nil
}
if event.Key() == tcell.KeyLeft {
if window.userList.internalTreeView.IsVisible() {
window.app.SetFocus(window.userList.internalTreeView)
} else {
window.app.SetFocus(window.chatView.internalTextView)
}
return nil
}
if event.Key() == tcell.KeyRight {
window.app.SetFocus(window.chatView.internalTextView)
return nil
}
}
return event
}
if oldGuildListHandler == nil {
guildList.SetInputCapture(newGuildHandler)
} else {
guildList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
handledEvent := newGuildHandler(event)
if handledEvent != nil {
return oldGuildListHandler(event)
}
return event
})
}
//Channel Container arrow key navigation. Please end my life.
oldChannelListHandler := channelTree.GetInputCapture()
newChannelListHandler := func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers() == tcell.ModAlt {
if event.Key() == tcell.KeyDown || event.Key() == tcell.KeyUp {
window.app.SetFocus(window.guildList)
return nil
}
if event.Key() == tcell.KeyLeft {
if window.userList.internalTreeView.IsVisible() {
window.app.SetFocus(window.userList.internalTreeView)
} else {
if window.commandMode {
window.app.SetFocus(window.commandView.commandOutput)
} else {
window.app.SetFocus(window.messageInput.GetPrimitive())
}
}
return nil
}
if event.Key() == tcell.KeyRight {
if window.commandMode {
window.app.SetFocus(window.commandView.commandOutput)
} else {
window.app.SetFocus(window.messageInput.GetPrimitive())
}
return nil
}
}
return event
}
if oldChannelListHandler == nil {
channelTree.SetInputCapture(newChannelListHandler)
} else {
channelTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
handledEvent := newChannelListHandler(event)
if handledEvent != nil {
return oldChannelListHandler(event)
}
return event
})
}
//Chatview arrow key navigation. Please end my life.
oldChatViewHandler := window.chatView.internalTextView.GetInputCapture()
newChatViewHandler := func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers() == tcell.ModAlt {
if event.Key() == tcell.KeyDown {
window.app.SetFocus(window.messageInput.GetPrimitive())
return nil
}
if event.Key() == tcell.KeyUp {
if window.commandMode {
window.app.SetFocus(window.commandView.commandInput.internalTextView)
} else {
window.app.SetFocus(window.messageInput.GetPrimitive())
}
return nil
}
if event.Key() == tcell.KeyLeft {
if window.activeView == Guilds {
window.app.SetFocus(window.guildList)
return nil
} else if window.activeView == Guilds {
window.app.SetFocus(window.privateList.internalTreeView)
return nil
}
}
if event.Key() == tcell.KeyRight {
if window.userList.internalTreeView.IsVisible() {
window.app.SetFocus(window.userList.internalTreeView)
} else {
if window.activeView == Guilds {
window.app.SetFocus(window.guildList)
return nil
} else if window.activeView == Guilds {
window.app.SetFocus(window.privateList.internalTreeView)
return nil
}
}
return nil
}
}
return event
}
if oldChatViewHandler == nil {
window.chatView.internalTextView.SetInputCapture(newChatViewHandler)
} else {
window.chatView.internalTextView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
handledEvent := newChatViewHandler(event)
if handledEvent != nil {
return oldChatViewHandler(event)
}
return event
})
}
//User Container arrow key navigation. Please end my life.
oldUserListHandler := window.userList.internalTreeView.GetInputCapture()
newUserListHandler := func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers() == tcell.ModAlt {
if event.Key() == tcell.KeyRight {
if window.activeView == Guilds {
window.app.SetFocus(window.guildList)
return nil
} else if window.activeView == Guilds {
window.app.SetFocus(window.privateList.internalTreeView)
return nil
}
return nil
}
if event.Key() == tcell.KeyLeft {
window.app.SetFocus(window.chatView.GetPrimitive())
return nil
}
}
return event
}
if oldUserListHandler == nil {
window.userList.internalTreeView.SetInputCapture(newUserListHandler)
} else {
window.userList.internalTreeView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
handledEvent := newUserListHandler(event)
if handledEvent != nil {
return oldUserListHandler(event)
}
return event
})
}
//Private Container arrow key navigation. Please end my life.
oldPrivateListHandler := window.privateList.internalTreeView.GetInputCapture()
newPrivateListHandler := func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers() == tcell.ModAlt {
if event.Key() == tcell.KeyLeft {
if window.userList.internalTreeView.IsVisible() {
window.app.SetFocus(window.userList.internalTreeView)
} else {
window.app.SetFocus(window.chatView.internalTextView)
}
return nil
}
if event.Key() == tcell.KeyRight {
window.app.SetFocus(window.chatView.internalTextView)
return nil
}
}
return event
}
if oldPrivateListHandler == nil {
window.privateList.internalTreeView.SetInputCapture(newPrivateListHandler)
} else {
window.privateList.internalTreeView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
handledEvent := newPrivateListHandler(event)
if handledEvent != nil {
return oldPrivateListHandler(event)
}
return event
})
}
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.MessageAck) {
if readstate.UpdateReadLocal(event.ChannelID, event.MessageID) {
channel, stateError := s.State.Channel(event.ChannelID)
if stateError == nil && event.MessageID == channel.LastMessageID {
if channel.GuildID == "" {
window.privateList.MarkChannelAsRead(channel.ID)
} else {
if window.selectedGuild != nil && channel.GuildID == window.selectedGuild.ID {
window.channelTree.MarkChannelAsRead(channel.ID)
} else {
for _, guildNode := range window.guildList.GetRoot().GetChildren() {
if guildNode.GetReference() == channel.GuildID {
window.updateServerReadStatus(channel.GuildID, guildNode, false)
break
}
}
}
}
}
}
})
window.commandView.SetInputCaptureForInput(func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers() == tcell.ModAlt {
if event.Key() == tcell.KeyUp {
window.app.SetFocus(window.commandView.commandOutput)
} else if event.Key() == tcell.KeyDown {
window.app.SetFocus(window.chatView.GetPrimitive())
} else if event.Key() == tcell.KeyRight {
if window.userList.internalTreeView.IsVisible() {
window.app.SetFocus(window.userList.internalTreeView)
} else {
window.app.SetFocus(window.channelTree)
}
} else if event.Key() == tcell.KeyLeft {
window.app.SetFocus(window.channelTree)
} else {
return event
}
return nil
}
return event
})
window.commandView.SetInputCaptureForOutput(func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers() == tcell.ModAlt {
if event.Key() == tcell.KeyUp {
window.app.SetFocus(window.messageInput.GetPrimitive())
} else if event.Key() == tcell.KeyDown {
window.app.SetFocus(window.commandView.commandInput.GetPrimitive())
} else if event.Key() == tcell.KeyRight {
if window.userList.internalTreeView.IsVisible() {
window.app.SetFocus(window.userList.internalTreeView)
} else {
window.app.SetFocus(window.channelTree)
}
} else if event.Key() == tcell.KeyLeft {
window.app.SetFocus(window.channelTree)
} else {
return event
}
return nil
}
return event
})
window.middleContainer = tview.NewFlex().
SetDirection(tview.FlexColumn)
window.rootContainer = tview.NewFlex().
SetDirection(tview.FlexRow)
window.rootContainer.SetTitleAlign(tview.AlignCenter)
window.rootContainer.AddItem(window.middleContainer, 0, 1, false)
window.dialogReplacement = tview.NewFlex().
SetDirection(tview.FlexRow)
window.dialogTextView = tview.NewTextView()
window.dialogReplacement.AddItem(window.dialogTextView, 0, 1, false)
window.dialogButtonBar = tview.NewFlex().
SetDirection(tview.FlexColumn)
window.dialogReplacement.AddItem(window.dialogButtonBar, 1, 0, false)
window.dialogReplacement.SetVisible(false)
window.rootContainer.AddItem(window.dialogReplacement, 2, 0, false)
if config.Current.ShowBottomBar {
bottomBar := tview.NewFlex().SetDirection(tview.FlexColumn)
bottomBar.SetBackgroundColor(config.GetTheme().PrimitiveBackgroundColor)
loggedInAsText := fmt.Sprintf("Logged in as: '%s'", tviewutil.Escape(session.State.User.String()))
if vtxxx {
// For some reason there's an offset when the tag is applied. I have no idea how to fix this.
// Except maybe unify the bottom bar into a single TextView rather than using two in a Flex.
loggedInAsText = "[::r]" + loggedInAsText
}
loggedInAs := tview.NewTextView().SetText(loggedInAsText).SetDynamicColors(true)
loggedInAs.SetTextColor(config.GetTheme().PrimitiveBackgroundColor).SetBackgroundColor(config.GetTheme().PrimaryTextColor)
bottomBar.AddItem(loggedInAs, runewidth.StringWidth(loggedInAsText), 0, false)
bottomBar.AddItem(tview.NewBox(), 1, 0, false)
shortcutInfoText := fmt.Sprintf("View / Change shortcuts: %s", shortcuts.EventToString(shortcutsDialogShortcut))
if vtxxx {
shortcutInfoText = "[::r]" + shortcutInfoText
}
shortcutInfo := tview.NewTextView().SetText(shortcutInfoText).SetDynamicColors(true)
shortcutInfo.SetTextColor(config.GetTheme().PrimitiveBackgroundColor).SetBackgroundColor(config.GetTheme().PrimaryTextColor)
bottomBar.AddItem(shortcutInfo, runewidth.StringWidth(shortcutInfoText), 0, false)
window.rootContainer.AddItem(bottomBar, 1, 0, false)
}
app.SetRoot(window.rootContainer, true)
window.currentContainer = window.rootContainer
app.SetInputCapture(window.handleGlobalShortcuts)
if config.Current.UseFixedLayout {
window.middleContainer.AddItem(window.leftArea, config.Current.FixedSizeLeft, 0, true)
window.middleContainer.AddItem(window.chatArea, 0, 1, false)
window.middleContainer.AddItem(window.userList.internalTreeView, config.Current.FixedSizeRight, 0, false)
} else {
window.middleContainer.AddItem(window.leftArea, 0, 7, true)
window.middleContainer.AddItem(window.chatArea, 0, 20, false)
window.middleContainer.AddItem(window.userList.internalTreeView, 0, 6, false)
}
autocompleteView.SetVisible(false)
autocompleteView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch key := event.Key(); key {
case tcell.KeyRune, tcell.KeyDelete, tcell.KeyBackspace, tcell.KeyBackspace2, tcell.KeyLeft, tcell.KeyRight, tcell.KeyCtrlA, tcell.KeyCtrlV:
window.messageInput.internalTextView.GetInputCapture()(event)
return nil
}
return event
})
window.chatArea.AddItem(window.messageContainer, 0, 1, false)
window.chatArea.AddItem(autocompleteView, 2, 2, true)
window.chatArea.AddItem(window.messageInput.GetPrimitive(), window.messageInput.GetRequestedHeight(), 0, false)
window.commandView.commandOutput.SetVisible(false)
window.commandView.commandInput.internalTextView.SetVisible(false)
window.chatArea.AddItem(window.commandView.commandOutput, 0, 1, false)
window.chatArea.AddItem(window.commandView.commandInput.internalTextView, 3, 0, false)
window.SwitchToGuildsPage()
app.SetFocus(guildList)
window.registerMouseFocusListeners()
window.chatView.internalTextView.SetText(getWelcomeText())
return window, nil
}
func getWelcomeText() string {
return fmt.Sprintf(splashText+`
Welcome to version %s of Cordless. Below you can see the most
important changes of the last two versions officially released.
[::b]THIS VERSION
- Features
- Notifications for servers and DMs are now displayed in the containers header row
- Embeds can now be rendered
- Usernames can now be rendered with their respective role color.
Bots however can't have colors, to avoid confusion with real users.
The default is set to "single", meaning it uses the default user
color from the specified theme. The setting "UseRandomUserColors" has
been removed.
- Changes
- The button to switch between DMs and servers is gone. Instead you can
click the containers, since the header row is always visible now
- Token input now ingores surrounding spaces
- Bot token syntax is more lenient now
- Bugfixes
- Bot login works again
- Holding down your left mouse and moving it on the chatview won't
cause lags anymore
- No more false positives for unread dm-channels
[::b]20-06-26
- Features
- you can now define a custom status
- shortened URLs optionally can display a file suffix (extension)
- You can now cycle through message in edit-mode by repeatedly hitting KeyUp/Down
- Bugfixes
- config directory path now read from "XDF_CONFIG_HOME" instead of "XDG_CONFIG_DIR"
- the delete message shortcut was pointing to the same value as "show spoilered message"
- the lack of the config directory would cause a crash
- nitro users couldn't use emojis anymore
- several typos have been corrected
- the "version" command printed it's help output to stdout
- the "man" command now searches through the content of pages and suggests those
[::b]2020-01-05
- Features
- VT320 terminals are now supported
- quoted messages now preserve attachment URLs
- Ctrl-W now deletes the word to the left
- announcement channels are now shown as well
- Cordless now has an amazing autocompletion
- support for TFA
- user-set command allows supplying emojis
- custom emojis are now rendered as links
- login now navigable via arrow keys
- Ctrl-B now toggles the so called "bare mode", giving all space to the chat
- configuration path is now customizable via parameters
- Bugfixes
- emoji sequences with underscores now work
- text channels sometimes didn't show up'
- Cordless doesn't crash anymore when sending a message into an empty channel
- attachment links are now copied as well
- Performance improvements
- the usertree will now load lazily
- dummycall to validate session token has been removed
- Changes
- login button has been removed ... just hit enter ;)
- tokeninput on login is now masked
- Docs have been improved
- JS API
- there's now an "init" function that gets called on script load
`, version.Version)
}
// initExtensionEngine injections necessary functions into the engine.
// those functions can be called by each script inside of an engine.
func (window *Window) initExtensionEngine(engine scripting.Engine) error {
engine.SetErrorOutput(window.commandView.commandOutput)
if err := engine.LoadScripts(config.GetScriptDirectory()); err != nil {
return err
}
engine.SetTriggerNotificationFunction(func(title, text string) {
notifyError := beeep.Notify("Cordless - "+title, text, "assets/information.png")
if notifyError != nil {
log.Printf("["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]Error sending notification:\n\t[%s]%s\n", tviewutil.ColorToHex(config.GetTheme().ErrorColor), notifyError)
}
})
engine.SetGetCurrentGuildFunction(func() string {
if window.selectedGuild != nil {
return window.selectedGuild.ID
}
return ""
})
engine.SetGetCurrentChannelFunction(func() string {
if window.selectedChannel != nil {
return window.selectedChannel.ID
}
return ""
})
// Even though scripts might already have functions for logging, like the
// JS engine already has console.log, this is sadly hardcoded to print to
// stdout instead of a custom specified IO writer.
engine.SetPrintToConsoleFunction(func(text string) {
fmt.Fprint(window.commandView, text)
})
engine.SetPrintLineToConsoleFunction(func(text string) {
fmt.Fprintln(window.commandView, text)
})
return nil
}
func (window *Window) loadPrivateChannel(channel *discordgo.Channel) {
window.LoadChannel(channel)
window.RefreshLayout()
}
func (window *Window) insertNewLineAtCursor() {
window.messageInput.InsertCharacter('\n')
window.app.QueueUpdateDraw(func() {
window.messageInput.triggerHeightRequestIfNecessary()
window.messageInput.internalTextView.ScrollToHighlight()
})
}
func (window *Window) IsCursorInsideCodeBlock() bool {
left := window.messageInput.GetTextLeftOfSelection()
leftSplit := strings.Split(left, "```")
return len(leftSplit)%2 == 0
}
func getUsernameForQuote(state *discordgo.State, message *discordgo.Message) string {
if message.GuildID != "" {
//The error handling here is rather lax, since not being able to show
// a nickname isn't really a problem worth crashing over.
guild, stateError := state.Guild(message.GuildID)
if stateError == nil {
member, stateError := state.Member(guild.ID, message.Author.ID)
if stateError == nil && member.Nick != "" {
return member.Nick
}
}
}
//Fallback if no respective member can be found, the cache couldn't be
//accessed or we are in a private chat.
return message.Author.Username
}
func (window *Window) insertQuoteOfMessage(message *discordgo.Message) {
username := getUsernameForQuote(window.session.State, message)
quotedMessage, generateError := discordutil.GenerateQuote(message.ContentWithMentionsReplaced(), username, message.Timestamp, message.Attachments, window.messageInput.GetText())
if generateError == nil {
window.messageInput.SetText(quotedMessage)
window.app.SetFocus(window.messageInput.GetPrimitive())
} else {
window.ShowErrorDialog(fmt.Sprintf("Error quoting message:\n\t%s", generateError.Error()))
}
}
func (window *Window) TrySendMessage(targetChannel *discordgo.Channel, message string) {
if targetChannel == nil {
return
}
if len(message) == 0 {
if window.editingMessageID != nil {
msgIDCopy := *window.editingMessageID
window.askForMessageDeletion(msgIDCopy, true)
}
return
}
message = strings.TrimSpace(message)
if len(message) == 0 {
window.app.QueueUpdateDraw(func() {
window.messageInput.SetText("")
})
return
}
message = window.prepareMessage(targetChannel, message)
overlength := len(message) - 2000
if overlength > 0 {
window.app.QueueUpdateDraw(func() {
sendAsFile := "Send as file"
window.ShowDialog(config.GetTheme().PrimitiveBackgroundColor, fmt.Sprintf("Your message is %d characters too long, what do you want to do?", overlength),
func(button string) {
if button == sendAsFile {
window.messageInput.SetText("")
go window.sendMessageAsFile(message, targetChannel.ID)
}
}, sendAsFile, "Nothing")
})
return
}
if window.editingMessageID != nil {
window.editMessage(targetChannel.ID, *window.editingMessageID, message)
return
}
go window.sendMessage(targetChannel.ID, message)
}
func (window *Window) sendMessageAsFile(message string, channel string) {
discordutil.SendMessageAsFile(window.session, message, channel, func(sendError error) {
retry := "Retry sending"
edit := "Edit"
window.app.QueueUpdateDraw(func() {
window.ShowDialog(config.GetTheme().ErrorColor,
fmt.Sprintf("Error sending message: %s.\n\nWhat do you want to do?", sendError),
func(button string) {
switch button {
case retry:
go window.sendMessageAsFile(channel, message)
case edit:
window.messageInput.SetText(message)
}
}, retry, edit, "Cancel")
})
})
}
func (window *Window) sendMessage(targetChannelID, message string) {
window.app.QueueUpdateDraw(func() {
window.messageInput.SetText("")
window.chatView.internalTextView.ScrollToEnd()
})
_, sendError := window.session.ChannelMessageSend(targetChannelID, message)
if sendError != nil {
window.app.QueueUpdateDraw(func() {
retry := "Retry sending"
edit := "Edit"
cancel := "Cancel"
window.ShowDialog(config.GetTheme().ErrorColor,
fmt.Sprintf("Error sending message: %s.\n\nWhat do you want to do?", sendError),
func(button string) {
switch button {
case retry:
go window.sendMessage(targetChannelID, message)
case edit:
window.messageInput.SetText(message)
}
}, retry, edit, cancel)
})
}
}
func (window *Window) updateServerReadStatus(guildID string, guildNode *tview.TreeNode, isSelected bool) {
if isSelected {
if vtxxx {
guildNode.SetAttributes(tcell.AttrUnderline)
} else {
guildNode.SetColor(tview.Styles.ContrastBackgroundColor)
}
} else {
if !readstate.HasGuildBeenRead(guildID) {
if vtxxx {
guildNode.SetAttributes(tcell.AttrBlink)
} else {
guildNode.SetColor(config.GetTheme().AttentionColor)
}
} else {
guildNode.SetAttributes(tcell.AttrNone)
guildNode.SetColor(tview.Styles.PrimaryTextColor)
}
}
//FIXME Lazy and dumb way to do this.
window.updateUnreadGuildAmount(guildNode.GetParent())
}
func (window *Window) updateUnreadGuildAmount(rootNode *tview.TreeNode) {
if rootNode != nil {
var notificationAmount int
for _, child := range rootNode.GetChildren() {
if !readstate.HasGuildBeenRead((child.GetReference()).(string)) {
notificationAmount++
}
}
if notificationAmount == 0 {
window.guildList.SetTitle("Servers")
} else {
window.guildList.SetTitle(fmt.Sprintf("Servers[%s](%d)", tviewutil.ColorToHex(config.GetTheme().AttentionColor), notificationAmount))
}
}
}
// prepareMessage prepares a message for being sent to the discord API.
// This will do all necessary escaping and resolving of channel-mentions,
// user-mentions, emojis and the likes.
//
// The input is expected to be a string without surrounding whitespace.
func (window *Window) prepareMessage(targetChannel *discordgo.Channel, inputText string) string {
message := codeBlockRegex.ReplaceAllStringFunc(inputText, func(input string) string {
return strings.ReplaceAll(input, ":", "\\:")
})
for _, engine := range window.extensionEngines {
message = engine.OnMessageSend(message)
}
if targetChannel.GuildID != "" {
channelGuild, discordError := window.session.State.Guild(targetChannel.GuildID)
if discordError == nil {
//Those could be optimized by searching the string for patterns.
for _, channel := range channelGuild.Channels {
if channel.Type == discordgo.ChannelTypeGuildText {
message = strings.ReplaceAll(message, "#"+channel.Name, "<#"+channel.ID+">")
}
}
message = window.replaceEmojiSequences(channelGuild, message)
}
} else {
message = window.replaceEmojiSequences(nil, message)
}
message = strings.Replace(message, "\\:", ":", -1)
if targetChannel.GuildID == "" {
for _, user := range targetChannel.Recipients {
message = strings.ReplaceAll(message, "@"+user.Username+"#"+user.Discriminator, "<@"+user.ID+">")
}
} else {
members, discordError := window.session.State.Members(targetChannel.GuildID)
if discordError == nil {
for _, member := range members {
message = strings.ReplaceAll(message, "@"+member.User.Username+"#"+member.User.Discriminator, "<@"+member.User.ID+">")
}
}
}
return message
}
// emojiSequenceIndexes will find all parts of a string (rune array) that
// could potentially be emoji sequences and will return them backwards.
// they'll be returned backwards in order to allow easily manipulating the
// data without invalidating the following indexes accidentally.
// Example:
// Hello :world:, what a :nice: day.
// would result in
// []int{22,,27,6,12}
func emojiSequenceIndexes(runes []rune) []int {
var sequencesBackwards []int
for i := len(runes) - 1; i >= 0; i-- {
if runes[i] == ':' {
for j := i - 1; j >= 0; j-- {
char := runes[j]
if char == ':' {
//Handle this ':' in the next iteration of the outer loop otherwise
if j != i-1 {
sequencesBackwards = append(sequencesBackwards, j, i)
i = j
break
} else {
break
}
} else if unicode.IsSpace(char) {
i = j
break
}
}
}
}
return sequencesBackwards
}
// mergeRuneSlices copies the passed rune arrays into a new rune array of the
// correct size.
func mergeRuneSlices(a, b, c []rune) *[]rune {
length := len(a) + len(b) + len(c)
result := make([]rune, length, length)
copy(result[:len(a)], a)
copy(result[len(a):len(a)+len(b)], b)
copy(result[len(a)+len(b):], c)
return &result
}
// replaceEmojiSequences replaces all emoji codes for custom emojis and unicode
// emojis alike. The matching is case-insensitive. It can't differentiate
// between different custom emojis. Forcing the usage of a custom emoji can be
// done by adding a '!' being the first ':'.
// For private channels, the channelGuild may be nil.
func (window *Window) replaceEmojiSequences(channelGuild *discordgo.Guild, message string) string {
asRunes := []rune(message)
indexes := emojiSequenceIndexes(asRunes)
INDEX_LOOP:
for i := 0; i < len(indexes); i += 2 {
startIndex := indexes[i]
endIndex := indexes[i+1]
emojiSequence := strings.ToLower(string(asRunes[startIndex+1 : endIndex]))
if !strings.HasPrefix(emojiSequence, "!") {
emoji := discordemojimap.GetEmoji(emojiSequence)
if emoji != "" {
asRunes = *mergeRuneSlices(asRunes[:startIndex], []rune(emoji), asRunes[endIndex+1:])
continue INDEX_LOOP
}
}
emojiSequence = strings.TrimPrefix(emojiSequence, "!")
if window.session.State.User.PremiumType == discordgo.UserPremiumTypeNitroClassic ||
window.session.State.User.PremiumType == discordgo.UserPremiumTypeNitro {
for _, guild := range window.session.State.Guilds {
for _, emoji := range guild.Emojis {
if strings.EqualFold(emoji.Name, emojiSequence) {
var emojiRunes []rune
if emoji.Animated {
emojiRunes = []rune("<a:" + emoji.Name + ":" + emoji.ID + ">")
} else {
emojiRunes = []rune("<:" + emoji.Name + ":" + emoji.ID + ">")
}
asRunes = *mergeRuneSlices(asRunes[:startIndex], emojiRunes, asRunes[endIndex+1:])
continue INDEX_LOOP
}
}
}
} else {
//Local guild emoji take priority
if channelGuild != nil {
emoji := discordutil.FindEmojiInGuild(window.session, channelGuild, true, emojiSequence)
if emoji != "" {
asRunes = *mergeRuneSlices(asRunes[:startIndex], []rune(emoji), asRunes[endIndex+1:])
continue INDEX_LOOP
}
}
//Check for global emotes
for _, guild := range window.session.State.Guilds {
emoji := discordutil.FindEmojiInGuild(window.session, guild, false, emojiSequence)
if emoji != "" {
asRunes = *mergeRuneSlices(asRunes[:startIndex], []rune(emoji), asRunes[endIndex+1:])
continue INDEX_LOOP
}
}
}
}
return string(asRunes)
}
// ShowDialog shows a dialog at the bottom of the window. It doesn't surrender
// its focus and requires action before allowing the user to proceed. The
// buttons are handled depending on their text.
func (window *Window) ShowDialog(color tcell.Color, text string, buttonHandler func(button string), buttons ...string) {
window.dialogButtonBar.RemoveAllItems()
if len(buttons) == 0 {
return
}
previousFocus := window.app.GetFocus()
buttonWidgets := make([]*tview.Button, 0)
for index, button := range buttons {
newButton := tview.NewButton(button)
newButton.SetSelectedFunc(func() {
buttonHandler(newButton.GetLabel())
window.dialogReplacement.SetVisible(false)
window.app.SetFocus(previousFocus)
})
buttonWidgets = append(buttonWidgets, newButton)
indexCopy := index
newButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyRight {
if len(buttonWidgets) <= indexCopy+1 {
window.app.SetFocus(buttonWidgets[0])
} else {
window.app.SetFocus(buttonWidgets[indexCopy+1])
}
return nil
}
if event.Key() == tcell.KeyLeft {
if indexCopy == 0 {
window.app.SetFocus(buttonWidgets[len(buttonWidgets)-1])
} else {
window.app.SetFocus(buttonWidgets[indexCopy-1])
}
return nil
}
return event
})
window.dialogButtonBar.AddItem(newButton, len(button)+2, 0, false)
window.dialogButtonBar.AddItem(tview.NewBox(), 1, 0, false)
}
window.dialogButtonBar.AddItem(tview.NewBox(), 0, 1, false)
window.dialogTextView.SetText(text)
window.dialogTextView.SetBackgroundColor(color)
window.dialogReplacement.SetVisible(true)
window.app.SetFocus(buttonWidgets[0])
_, _, width, _ := window.rootContainer.GetRect()
height := tviewutil.CalculateNecessaryHeight(width, window.dialogTextView.GetText(true))
window.rootContainer.ResizeItem(window.dialogReplacement, height+2, 0)
}
func (window *Window) registerMouseFocusListeners() {
window.chatView.internalTextView.SetMouseHandler(func(event *tcell.EventMouse) bool {
if event.Buttons() == tcell.Button1 {
window.app.SetFocus(window.chatView.internalTextView)
} else if event.Buttons() == tcell.WheelDown {
window.chatView.internalTextView.ScrollDown()
} else if event.Buttons() == tcell.WheelUp {
window.chatView.internalTextView.ScrollUp()
} else {
return false
}
return true
})
var lastLeftContainerSwitchTimeMillis int64
window.guildList.SetMouseHandler(func(event *tcell.EventMouse) bool {
if event.Buttons() == tcell.Button1 {
if window.activeView != Guilds {
nowMillis := time.Now().UnixNano() / 1000 / 1000
//Avoid triggering multiple times in a row due to mouse movement during the click
if nowMillis-lastLeftContainerSwitchTimeMillis > 60 {
window.SwitchToGuildsPage()
window.app.SetFocus(window.guildList)
}
lastLeftContainerSwitchTimeMillis = nowMillis
} else {
window.app.SetFocus(window.guildList)
}
return true
}
return false
})
window.channelTree.SetMouseHandler(func(event *tcell.EventMouse) bool {
if event.Buttons() == tcell.Button1 {
window.app.SetFocus(window.channelTree)
return true
}
return false
})
window.userList.internalTreeView.SetMouseHandler(func(event *tcell.EventMouse) bool {
if event.Buttons() == tcell.Button1 {
window.app.SetFocus(window.userList.internalTreeView)
return true
}
return false
})
window.privateList.internalTreeView.SetMouseHandler(func(event *tcell.EventMouse) bool {
if event.Buttons() == tcell.Button1 {
if window.activeView != Dms {
nowMillis := time.Now().UnixNano() / 1000 / 1000
//Avoid triggering multiple times in a row due to mouse movement during the click
if nowMillis-lastLeftContainerSwitchTimeMillis > 60 {
window.SwitchToFriendsPage()
window.app.SetFocus(window.privateList.internalTreeView)
}
lastLeftContainerSwitchTimeMillis = nowMillis
} else {
window.app.SetFocus(window.privateList.internalTreeView)
}
return true
}
return false
})
window.messageInput.internalTextView.SetMouseHandler(func(event *tcell.EventMouse) bool {
if event.Buttons() == tcell.Button1 {
window.app.SetFocus(window.messageInput.internalTextView)
return true
}
return false
})
window.commandView.commandInput.internalTextView.SetMouseHandler(func(event *tcell.EventMouse) bool {
if event.Buttons() == tcell.Button1 {
window.app.SetFocus(window.commandView.commandInput.internalTextView)
return true
}
return false
})
window.commandView.commandOutput.SetMouseHandler(func(event *tcell.EventMouse) bool {
if event.Buttons() == tcell.Button1 {
window.app.SetFocus(window.commandView.commandOutput)
} else if event.Buttons() == tcell.WheelDown {
window.commandView.commandOutput.ScrollDown()
} else if event.Buttons() == tcell.WheelUp {
window.commandView.commandOutput.ScrollUp()
} else {
return false
}
return true
})
}
func (window *Window) registerMessageEventHandler(input, edit, delete chan *discordgo.Message, bulkDelete chan *discordgo.MessageDeleteBulk) {
window.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
input <- m.Message
})
window.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDeleteBulk) {
bulkDelete <- m
})
window.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) {
delete <- m.Message
})
window.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageUpdate) {
//Ignore just-embed edits
if m.Content != "" {
edit <- m.Message
}
})
}
// QueueUpdateDrawSynchronized is meant to be used by goroutines that aren't
// the main goroutine in order to wait for the UI-Thread to execute the given
// If this method is ever called from the main thread, the application will
// deadlock.
func (window *Window) QueueUpdateDrawSynchronized(runnable func()) {
blocker := make(chan bool, 1)
window.app.QueueUpdateDraw(func() {
runnable()
blocker <- true
})
<-blocker
close(blocker)
}
// startMessageHandlerRoutines registers the handlers for certain message
// events. It updates the cache and the UI if necessary.
func (window *Window) startMessageHandlerRoutines(input, edit, delete chan *discordgo.Message, bulkDelete chan *discordgo.MessageDeleteBulk) {
go func() {
for tempMessage := range input {
message := tempMessage
go func() {
for _, engine := range window.extensionEngines {
engine.OnMessageReceive(message)
}
}()
channel, stateError := window.session.State.Channel(message.ChannelID)
if stateError != nil {
continue
}
window.chatView.Lock()
if window.selectedChannel != nil && message.ChannelID == window.selectedChannel.ID {
if message.Author.ID != window.session.State.User.ID {
readstate.UpdateReadBuffered(window.session, channel, message.ID)
}
window.QueueUpdateDrawSynchronized(func() {
window.chatView.AddMessage(message)
})
}
window.chatView.Unlock()
if channel.Type == discordgo.ChannelTypeGuildText && (window.selectedGuild == nil ||
window.selectedGuild.ID != channel.GuildID) {
for _, guildNode := range window.guildList.GetRoot().GetChildren() {
if guildNode.GetReference() == channel.GuildID {
window.app.QueueUpdateDraw(func() {
window.updateServerReadStatus(channel.GuildID, guildNode, false)
})
break
}
}
}
// TODO,HACK.FIXME Since the cache is inconsistent, I have to
// update it myself. This should be moved over into the
// discordgo code ASAP.
channel.LastMessageID = message.ID
if channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM {
//Avoid unnecessary drawing if the updates wouldn't be visible either way.
//FIXME Useful to use locking here?
if window.activeView == Dms {
window.app.QueueUpdateDraw(func() {
window.privateList.ReorderChannelList()
})
} else {
window.privateList.ReorderChannelList()
}
}
if message.Author.ID == window.session.State.User.ID {
readstate.UpdateReadLocal(message.ChannelID, message.ID)
continue
}
isCurrentChannel := window.selectedChannel == nil || message.ChannelID != window.selectedChannel.ID
mentionsCurrentUser := discordutil.MentionsCurrentUserExplicitly(window.session.State, message)
if config.Current.DesktopNotifications &&
(mentionsCurrentUser ||
//Always show notification for private messages
channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM) &&
(!isCurrentChannel ||
(config.Current.DesktopNotificationsForLoadedChannel && !window.userActive)) {
var notificationLocation string
if channel.Type == discordgo.ChannelTypeDM {
notificationLocation = message.Author.Username
} else if channel.Type == discordgo.ChannelTypeGroupDM {
notificationLocation = channel.Name
if notificationLocation == "" {
for index, recipient := range channel.Recipients {
if index == 0 {
notificationLocation = recipient.Username
} else {
notificationLocation = fmt.Sprintf("%s, %s", notificationLocation, recipient.Username)
}
}
}
notificationLocation = message.Author.Username + " - " + notificationLocation
} else if channel.Type == discordgo.ChannelTypeGuildText {
guild, cacheError := window.session.State.Guild(message.GuildID)
if guild != nil && cacheError == nil {
notificationLocation = fmt.Sprintf("%s - %s - %s", guild.Name, channel.Name, message.Author.Username)
} else {
notificationLocation = fmt.Sprintf("%s - %s", message.Author.Username, channel.Name)
}
}
notifyError := beeep.Notify("Cordless - "+notificationLocation, message.ContentWithMentionsReplaced(), "assets/information.png")
if notifyError != nil {
log.Printf("["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]Error sending notification:\n\t[%s]%s\n", tviewutil.ColorToHex(config.GetTheme().ErrorColor), notifyError)
}
}
if isCurrentChannel {
if channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM {
if !readstate.IsPrivateChannelMuted(channel) {
window.app.QueueUpdateDraw(func() {
window.privateList.MarkChannelAsUnread(channel)
})
}
} else if channel.Type == discordgo.ChannelTypeGuildText {
if mentionsCurrentUser {
window.app.QueueUpdateDraw(func() {
window.channelTree.MarkChannelAsMentioned(channel.ID)
})
} else if !readstate.IsGuildChannelMuted(channel) {
window.app.QueueUpdateDraw(func() {
window.channelTree.MarkChannelAsUnread(channel.ID)
})
}
}
}
}
}()
go func() {
for messageDeleted := range delete {
tempMessageDeleted := messageDeleted
go func() {
for _, engine := range window.extensionEngines {
engine.OnMessageDelete(tempMessageDeleted)
}
}()
window.chatView.Lock()
if window.selectedChannel != nil && window.selectedChannel.ID == tempMessageDeleted.ChannelID {
window.QueueUpdateDrawSynchronized(func() {
window.chatView.DeleteMessage(tempMessageDeleted)
})
}
window.chatView.Unlock()
}
}()
go func() {
for messagesDeleted := range bulkDelete {
tempMessagesDeleted := messagesDeleted
window.chatView.Lock()
if window.selectedChannel != nil && window.selectedChannel.ID == tempMessagesDeleted.ChannelID {
window.QueueUpdateDrawSynchronized(func() {
window.chatView.DeleteMessages(tempMessagesDeleted.Messages)
})
}
window.chatView.Unlock()
}
}()
go func() {
for messageEdited := range edit {
tempMessageEdited := messageEdited
go func() {
for _, engine := range window.extensionEngines {
engine.OnMessageEdit(tempMessageEdited)
}
}()
window.chatView.Lock()
if window.selectedChannel != nil && window.selectedChannel.ID == tempMessageEdited.ChannelID {
for _, message := range window.chatView.data {
if message.ID == tempMessageEdited.ID {
//FIXME Workaround for the fact that discordgo doesn't update already filled fields.
message.Content = tempMessageEdited.Content
message.Mentions = tempMessageEdited.Mentions
message.MentionRoles = tempMessageEdited.MentionRoles
message.MentionEveryone = tempMessageEdited.MentionEveryone
window.QueueUpdateDrawSynchronized(func() {
window.chatView.UpdateMessage(message)
})
break
}
}
}
window.chatView.Unlock()
}
}()
}
func (window *Window) registerGuildHandlers() {
//Using buffered channels with a size of three, since this shouldn't really happen often
guildCreateChannel := make(chan *discordgo.GuildCreate, 3)
window.session.AddHandler(func(s *discordgo.Session, guildCreate *discordgo.GuildCreate) {
guildCreateChannel <- guildCreate
})
guildRemoveChannel := make(chan *discordgo.GuildDelete, 3)
window.session.AddHandler(func(s *discordgo.Session, guildRemove *discordgo.GuildDelete) {
guildRemoveChannel <- guildRemove
})
guildUpdateChannel := make(chan *discordgo.GuildUpdate, 3)
window.session.AddHandler(func(s *discordgo.Session, guildUpdate *discordgo.GuildUpdate) {
guildUpdateChannel <- guildUpdate
})
go func() {
for guildCreate := range guildCreateChannel {
guild := guildCreate
if window.guildList.GetCurrentNode() == nil {
window.app.QueueUpdateDraw(func() {
window.guildList.AddGuild(guild.ID, guild.Name)
window.guildList.SetCurrentNode(window.guildList.GetRoot())
})
} else {
window.app.QueueUpdateDraw(func() {
window.guildList.AddGuild(guild.ID, guild.Name)
})
}
}
}()
go func() {
for guildUpdate := range guildUpdateChannel {
guild := guildUpdate
window.app.QueueUpdateDraw(func() {
window.guildList.UpdateName(guild.ID, guild.Name)
})
}
}()
go func() {
for guildRemove := range guildRemoveChannel {
if window.selectedGuildNode == nil {
continue
}
if window.previousGuildNode != nil && window.previousGuildNode.GetReference() == guildRemove.ID {
window.previousGuildNode = nil
window.previousGuild = nil
window.previousChannelNode = nil
window.previousChannel = nil
}
if window.selectedGuildNode.GetReference() == guildRemove.ID {
guildID := guildRemove.ID
window.app.QueueUpdateDraw(func() {
if window.selectedChannel != nil && window.selectedChannel.GuildID == guildID {
window.chatView.ClearViewAndCache()
window.selectedChannel = nil
window.selectedChannelNode = nil
}
window.channelTree.Clear()
window.userList.Clear()
window.guildList.RemoveGuild(guildID)
window.selectedGuildNode = nil
window.selectedGuild = nil
})
}
}
}()
}
func (window *Window) registerGuildMemberHandlers() {
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.GuildMembersChunk) {
if window.selectedGuild != nil && window.selectedGuild.ID == event.GuildID {
window.app.QueueUpdateDraw(func() {
window.userList.AddOrUpdateMembers(event.Members)
})
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.GuildMemberRemove) {
if window.selectedGuild != nil && window.selectedGuild.ID == event.GuildID {
window.app.QueueUpdateDraw(func() {
window.userList.RemoveMember(event.Member)
})
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.GuildMemberAdd) {
if window.selectedGuild != nil && window.selectedGuild.ID == event.GuildID {
window.app.QueueUpdateDraw(func() {
window.userList.AddOrUpdateMember(event.Member)
})
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.GuildMemberUpdate) {
if window.selectedGuild != nil && window.selectedGuild.ID == event.GuildID {
window.app.QueueUpdateDraw(func() {
window.userList.AddOrUpdateMember(event.Member)
})
}
})
}
func (window *Window) registerPrivateChatsHandler() {
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.ChannelCreate) {
if event.Type == discordgo.ChannelTypeDM || event.Type == discordgo.ChannelTypeGroupDM {
window.app.QueueUpdateDraw(func() {
window.privateList.AddOrUpdateChannel(event.Channel)
})
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.ChannelDelete) {
if event.Type == discordgo.ChannelTypeDM || event.Type == discordgo.ChannelTypeGroupDM {
window.app.QueueUpdateDraw(func() {
window.privateList.RemoveChannel(event.Channel)
})
readstate.ClearReadStateFor(event.ID)
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.ChannelUpdate) {
if event.Type == discordgo.ChannelTypeDM || event.Type == discordgo.ChannelTypeGroupDM {
window.app.QueueUpdateDraw(func() {
window.privateList.AddOrUpdateChannel(event.Channel)
})
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.RelationshipAdd) {
if event.Relationship.Type == discordgo.RelationTypeFriend {
window.app.QueueUpdateDraw(func() {
window.privateList.addFriend(event.User)
})
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.RelationshipRemove) {
if event.Relationship.Type == discordgo.RelationTypeFriend {
for _, relationship := range window.session.State.Relationships {
if relationship.ID == event.ID {
window.app.QueueUpdateDraw(func() {
window.privateList.RemoveFriend(relationship.User.ID)
})
break
}
}
}
})
}
func (window *Window) isChannelEventRelevant(channelEvent *discordgo.Channel) bool {
if window.selectedGuild == nil {
return false
}
if channelEvent.Type != discordgo.ChannelTypeGuildText && channelEvent.Type != discordgo.ChannelTypeGuildCategory {
return false
}
if window.selectedGuild.ID != channelEvent.GuildID {
return false
}
return true
}
func (window *Window) registerGuildChannelHandler() {
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.ChannelCreate) {
if window.isChannelEventRelevant(event.Channel) {
window.channelTree.Lock()
window.QueueUpdateDrawSynchronized(func() {
window.channelTree.AddOrUpdateChannel(event.Channel)
})
window.channelTree.Unlock()
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.ChannelUpdate) {
if window.isChannelEventRelevant(event.Channel) {
window.channelTree.Lock()
window.QueueUpdateDrawSynchronized(func() {
window.channelTree.AddOrUpdateChannel(event.Channel)
})
window.channelTree.Unlock()
}
})
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.ChannelDelete) {
if window.isChannelEventRelevant(event.Channel) {
if window.previousChannelNode != nil && window.previousChannelNode.GetReference() == event.ID {
window.previousGuildNode = nil
window.previousGuild = nil
window.previousChannelNode = nil
window.previousChannel = nil
}
if window.selectedChannelNode != nil && window.selectedChannelNode.GetReference() == event.ID {
window.selectedChannel = nil
window.selectedChannelNode = nil
window.app.QueueUpdateDraw(func() {
window.chatView.ClearViewAndCache()
})
}
window.messageLoader.DeleteFromCache(event.Channel.ID)
//On purpose, since we don't care much about removing the channel timely.
window.app.QueueUpdateDraw(func() {
window.channelTree.Lock()
window.channelTree.RemoveChannel(event.Channel)
window.channelTree.Unlock()
})
}
})
}
func (window *Window) askForMessageDeletion(messageID string, usedWithSelection bool) {
deleteButtonText := "Delete"
window.ShowDialog(tview.Styles.PrimitiveBackgroundColor,
"Do you really want to delete the message?", func(button string) {
if button == deleteButtonText {
go window.session.ChannelMessageDelete(window.selectedChannel.ID, messageID)
}
window.exitMessageEditMode()
if usedWithSelection {
window.chatView.SignalSelectionDeleted()
}
}, deleteButtonText, "Abort")
}
// SetCommandModeEnabled hides or shows the command ui elements and toggles
// the commandMode flag.
func (window *Window) SetCommandModeEnabled(enabled bool) {
if window.commandMode != enabled {
window.commandMode = enabled
window.commandView.SetVisible(enabled)
}
}
func (window *Window) handleGlobalShortcuts(event *tcell.EventKey) *tcell.EventKey {
if shortcuts.ExitApplication.Equals(event) {
window.doRestart <- false
window.app.Stop()
return nil
}
// Maybe compare directly to table?
if config.Current.DesktopNotificationsUserInactivityThreshold > 0 {
window.userActive = true
window.userActiveTimer.Reset(time.Duration(config.Current.DesktopNotificationsUserInactivityThreshold) * time.Second)
}
if shortcuts.ToggleBareChat.Equals(event) {
window.toggleBareChat()
return nil
}
if window.currentContainer != window.rootContainer {
return event
}
if window.dialogReplacement.IsVisible() {
return event
}
if shortcuts.EventsEqual(event, shortcutsDialogShortcut) {
shortcuts.ShowShortcutsDialog(window.app, func() {
window.app.SetRoot(window.rootContainer, true)
window.currentContainer = window.rootContainer
window.app.ForceDraw()
}, func(view *tview.Flex) {
window.currentContainer = view
})
} else if shortcuts.ToggleCommandView.Equals(event) {
window.SetCommandModeEnabled(!window.commandMode)
if window.commandMode {
window.app.SetFocus(window.commandView.commandInput.internalTextView)
} else {
window.app.SetFocus(window.messageInput.GetPrimitive())
}
} else if shortcuts.FocusCommandOutput.Equals(event) {
if !window.commandMode {
window.SetCommandModeEnabled(true)
}
window.app.SetFocus(window.commandView.commandOutput)
} else if shortcuts.FocusCommandInput.Equals(event) {
if !window.commandMode {
window.SetCommandModeEnabled(true)
}
window.app.SetFocus(window.commandView.commandInput.internalTextView)
} else if shortcuts.ToggleUserContainer.Equals(event) {
window.toggleUserContainer()
} else if shortcuts.FocusChannelContainer.Equals(event) {
window.SwitchToGuildsPage()
window.app.SetFocus(window.channelTree)
} else if shortcuts.FocusPrivateChatPage.Equals(event) {
window.SwitchToFriendsPage()
window.app.SetFocus(window.privateList.GetComponent())
} else if shortcuts.SwitchToPreviousChannel.Equals(event) {
err := window.SwitchToPreviousChannel()
if err != nil {
window.ShowErrorDialog(err.Error())
}
} else if shortcuts.FocusGuildContainer.Equals(event) {
window.SwitchToGuildsPage()
window.app.SetFocus(window.guildList)
} else if shortcuts.FocusMessageContainer.Equals(event) {
window.app.SetFocus(window.chatView.internalTextView)
} else if shortcuts.FocusUserContainer.Equals(event) {
if window.activeView == Guilds && window.userList.internalTreeView.IsVisible() {
window.app.SetFocus(window.userList.internalTreeView)
}
} else if shortcuts.FocusMessageInput.Equals(event) {
window.app.SetFocus(window.messageInput.GetPrimitive())
} else {
return event
}
return nil
}
func (window *Window) toggleUserContainer() {
config.Current.ShowUserContainer = !config.Current.ShowUserContainer
if !config.Current.ShowUserContainer && window.app.GetFocus() == window.userList.internalTreeView {
window.app.SetFocus(window.messageInput.GetPrimitive())
}
if config.Current.ShowUserContainer {
if !window.userList.IsLoaded() {
if window.selectedChannel != nil && window.selectedChannel.GuildID == "" {
window.userList.LoadGroup(window.selectedChannel.ID)
} else if window.selectedGuild != nil {
window.userList.LoadGuild(window.selectedGuild.ID)
}
}
} else {
window.userList.Clear()
}
config.PersistConfig()
window.RefreshLayout()
}
// toggleBareChat will display only the chatview as the fullscreen application
// root. Calling this method again will revert the view to it's normal state.
func (window *Window) toggleBareChat() {
window.bareChat = !window.bareChat
if window.bareChat {
window.chatView.internalTextView.SetBorder(false)
window.currentContainer = window.chatView.GetPrimitive()
window.app.SetRoot(window.chatView.GetPrimitive(), true)
} else {
window.chatView.internalTextView.SetBorder(true)
window.currentContainer = window.rootContainer
window.app.SetRoot(window.rootContainer, true)
}
}
// FindCommand searches through the registered command, whether any of them
// equals the passed name.
func (window *Window) FindCommand(name string) commands.Command {
for _, cmd := range window.commands {
if commands.CommandEquals(cmd, name) {
return cmd
}
}
return nil
}
//ExecuteCommand tries to execute the given input as a command. The first word
//will be passed as the commands name and the rest will be parameters. If a
//command can't be found, that info will be printed onto the command output.
func (window *Window) ExecuteCommand(input string) {
parts := commands.ParseCommand(input)
fmt.Fprintf(window.commandView, "[gray]$ %s\n", input)
if len(parts) > 0 {
command := window.FindCommand(parts[0])
if command != nil {
command.Execute(window.commandView, parts[1:])
} else {
fmt.Fprintf(window.commandView, "["+tviewutil.ColorToHex(config.GetTheme().ErrorColor)+"]The command '%s' doesn't exist[white]\n", parts[0])
}
}
}
// ShowTFASetup generates a new TFA-Secret and shows a QR-Code. The QR-Code can
// be scanned and the resulting TFA-Token can be entered into cordless and used
// to enable TFA on this account.
func (window *Window) ShowTFASetup() error {
tfaSecret, secretError := text.GenerateBase32Key()
if secretError != nil {
return secretError
}
qrURL := fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=Discord", window.session.State.User.Email, tfaSecret)
qrCodeText := text.GenerateQRCode(qrURL, qrterminal.M)
qrCodeImage := tview.NewTextView().SetText(qrCodeText).SetTextAlign(tview.AlignCenter)
qrCodeImage.SetTextColor(tcell.ColorWhite).SetBackgroundColor(tcell.ColorBlack)
qrCodeView := tview.NewFlex().SetDirection(tview.FlexRow)
width := len([]rune(strings.TrimSpace(strings.Split(qrCodeText, "\n")[2])))
qrCodeView.AddItem(tviewutil.CreateCenteredComponent(qrCodeImage, width), strings.Count(qrCodeText, "\n"), 0, false)
humanReadableSecret := tfaSecret[:4] + " " + tfaSecret[4:8] + " " + tfaSecret[8:12] + " " + tfaSecret[12:16]
defaultInstructions := "1. Scan the QR-Code with your 2FA application\n or enter the secret manually:\n " +
humanReadableSecret + "\n2. Enter the code generated on your 2FA device\n3. Hit Enter!"
message := tview.NewTextView().SetText(defaultInstructions).SetDynamicColors(true)
qrCodeView.AddItem(tviewutil.CreateCenteredComponent(message, 68), 0, 1, false)
tokenInput := tview.NewInputField()
tokenInput.SetBorder(true)
tokenInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEnter {
code, codeError := text.ParseTFACode(tokenInput.GetText())
if codeError != nil {
message.SetText(fmt.Sprintf("%s\n\n[red]Code invalid:\n\t[red]%s", defaultInstructions, codeError))
return nil
}
//panic(fmt.Sprintf("Secret: %s\nCode: %s", tfaSecret, code))
backupCodes, tfaError := window.session.TwoFactorEnable(tfaSecret, code)
if tfaError != nil {
message.SetText(fmt.Sprintf("%s\n\n[red]Error setting up Two-Factor-Authentication:\n\t[red]%s", defaultInstructions, tfaError))
return nil
}
//The token is being updated internally, therefore we need to update our config.
config.UpdateCurrentToken(window.session.Token)
configError := config.PersistConfig()
if configError != nil {
log.Println(fmt.Sprintf("Error settings new token: %s\n\t%s", window.session.Token, configError))
}
var backupCodesAsString string
for index, backupCode := range backupCodes {
if index != 0 {
backupCodesAsString += "\n"
}
backupCodesAsString += backupCode.Code
}
clipboard.WriteAll(backupCodesAsString)
successText := tview.NewTextView().SetTextAlign(tview.AlignCenter)
successText.SetText("Setting up Two-Factor-Authentication was a success.\n\n" +
"The backup codes have been put into your clipboard." +
"If you need to view your backup codes again, just run `tfa backup` in the cordless CLI.\n\n" +
"Currently cordless doesn't support applying backup codes.")
successView := tview.NewFlex().SetDirection(tview.FlexRow)
okayButton := tview.NewButton("Okay")
okayButton.SetSelectedFunc(func() {
window.app.SetRoot(window.rootContainer, true)
})
successView.AddItem(successText, 0, 1, false)
successView.AddItem(okayButton, 1, 0, false)
window.app.SetRoot(tviewutil.CreateCenteredComponent(successView, 68), true)
window.app.SetFocus(okayButton)
return nil
}
if event.Key() == tcell.KeyESC {
window.app.SetRoot(window.rootContainer, true)
window.app.ForceDraw()
return nil
}
return event
})
qrCodeView.AddItem(tviewutil.CreateCenteredComponent(tokenInput, 68), 3, 0, false)
window.app.SetRoot(qrCodeView, true)
window.app.SetFocus(tokenInput)
return nil
}
func (window *Window) startEditingMessage(message *discordgo.Message) {
if message.Author.ID == window.session.State.User.ID {
window.messageInput.SetText(message.Content)
window.messageInput.SetBorderColor(tcell.ColorYellow)
window.messageInput.SetBorderFocusColor(tcell.ColorYellow)
if vtxxx {
window.messageInput.SetBorderFocusAttributes(tcell.AttrBlink | tcell.AttrBold)
}
window.editingMessageID = &message.ID
window.app.SetFocus(window.messageInput.GetPrimitive())
}
}
func (window *Window) exitMessageEditMode() {
window.exitMessageEditModeAndKeepText()
window.messageInput.SetText("")
}
func (window *Window) exitMessageEditModeAndKeepText() {
window.editingMessageID = nil
window.messageInput.SetBorderColor(tview.Styles.BorderColor)
window.messageInput.SetBorderFocusColor(tview.Styles.BorderFocusColor)
if vtxxx {
window.messageInput.SetBorderFocusAttributes(tcell.AttrBold)
window.messageInput.SetBorderAttributes(tcell.AttrNone)
}
}
// ShowErrorDialog shows a simple error dialog that has only an Okay button,
// a generic title and the given text.
func (window *Window) ShowErrorDialog(text string) {
window.ShowDialog(config.GetTheme().ErrorColor, "An error occurred - "+text, func(_ string) {}, "Okay")
}
// ShowCustomErrorDialog shows a simple error dialog with a custom title
// and text. The button says "Okay" and only closes the dialog.
func (window *Window) ShowCustomErrorDialog(title, text string) {
window.ShowDialog(config.GetTheme().ErrorColor, title+" - "+text, func(_ string) {}, "Okay")
}
func (window *Window) editMessage(channelID, messageID, messageEdited string) {
go func() {
window.app.QueueUpdateDraw(func() {
window.exitMessageEditMode()
window.messageInput.SetText("")
})
_, discordError := window.session.ChannelMessageEdit(channelID, messageID, messageEdited)
window.app.QueueUpdateDraw(func() {
if discordError != nil {
retry := "Retry sending"
edit := "Edit"
cancel := "Cancel"
window.ShowDialog(config.GetTheme().ErrorColor,
fmt.Sprintf("Error editing message: %s.\n\nWhat do you want to do?", discordError),
func(button string) {
switch button {
case retry:
window.editMessage(channelID, messageID, messageEdited)
case edit:
window.messageInput.SetText(messageEdited)
}
}, retry, edit, cancel)
}
})
}()
}
//SwitchToGuildsPage the left side of the layout over to the view where you can
//see the servers and their channels. In additional to that, it also shows the
//user list in case the user didn't explicitly hide it.
func (window *Window) SwitchToGuildsPage() {
window.leftArea.RemoveAllItems()
window.leftArea.AddItem(window.privateList.GetComponent(), 1, 0, false)
window.leftArea.AddItem(window.guildPage, 0, 1, false)
window.activeView = Guilds
}
//SwitchToFriendsPage switches the left side of the layout over to the view
//where you can see your private chats and groups. In addition to that it
//hides the user list.
func (window *Window) SwitchToFriendsPage() {
window.leftArea.RemoveAllItems()
window.leftArea.AddItem(window.guildList, 1, 0, false)
window.leftArea.AddItem(window.privateList.GetComponent(), 0, 1, false)
window.activeView = Dms
}
// Switches to the previous channel and layout.
func (window *Window) SwitchToPreviousChannel() error {
if window.previousChannel == nil || window.previousChannel == window.selectedChannel {
// No previous channel.
return nil
}
_, err := window.session.State.Channel(window.previousChannel.ID)
if err != nil {
window.previousChannel = nil
window.previousChannelNode = nil
return fmt.Errorf("Channel %s not found", window.previousChannel.Name)
}
// Switch to appropriate layout.
switch window.previousChannel.Type {
case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM:
window.SwitchToFriendsPage()
window.privateList.onChannelSelect(window.previousChannelNode, window.previousChannel.ID)
case discordgo.ChannelTypeGuildText:
_, err := window.session.State.Guild(window.previousGuild.ID)
if err != nil {
window.previousGuild = nil
window.previousGuildNode = nil
return fmt.Errorf("Unable to load guild: %s", window.previousGuild.Name)
}
if !discordutil.HasReadMessagesPermission(window.previousChannel.ID, window.session.State) {
return fmt.Errorf("No read permissions for channel: %s", window.previousChannel.Name)
}
window.SwitchToGuildsPage()
window.guildList.SetCurrentNode(window.previousGuildNode)
window.guildList.onGuildSelect(window.previousGuildNode, window.previousGuild.ID)
window.channelTree.SetCurrentNode(window.previousChannelNode)
window.channelTree.onChannelSelect(window.previousChannel.ID)
default:
return fmt.Errorf("Invalid channel type: %v", window.previousChannel.Type)
}
window.app.SetFocus(window.messageInput.internalTextView)
return nil
}
//RefreshLayout removes and adds the main parts of the layout
//so that the ones that are disabled by settings do not show up.
func (window *Window) RefreshLayout() {
window.userList.internalTreeView.SetVisible(config.Current.ShowUserContainer && (window.selectedGuild != nil ||
(window.selectedChannel != nil && window.selectedChannel.Type == discordgo.ChannelTypeGroupDM)))
if config.Current.UseFixedLayout {
window.middleContainer.ResizeItem(window.leftArea, config.Current.FixedSizeLeft, 7)
window.middleContainer.ResizeItem(window.chatArea, 0, 1)
window.middleContainer.ResizeItem(window.userList.internalTreeView, config.Current.FixedSizeRight, 6)
} else {
window.middleContainer.ResizeItem(window.leftArea, 0, 7)
window.middleContainer.ResizeItem(window.chatArea, 0, 20)
window.middleContainer.ResizeItem(window.userList.internalTreeView, 0, 6)
}
window.app.ForceDraw()
}
//LoadChannel eagerly loads the channels messages.
func (window *Window) LoadChannel(channel *discordgo.Channel) error {
messages, loadError := window.messageLoader.LoadMessages(channel)
if loadError != nil {
return loadError
}
discordutil.SortMessagesByTimestamp(messages)
window.chatView.SetMessages(messages)
window.chatView.ClearSelection()
window.chatView.internalTextView.ScrollToEnd()
window.UpdateChatHeader(channel)
if window.selectedChannel == nil {
window.previousChannel = channel
window.previousChannelNode = window.channelTree.GetCurrentNode()
} else if channel != window.selectedChannel {
window.previousChannel = window.selectedChannel
window.previousChannelNode = window.selectedChannelNode
// When switching to a channel in the same guild, the previousGuild must be set.
if window.previousChannel.GuildID == channel.GuildID {
window.previousGuild = window.selectedGuild
window.previousGuildNode = window.selectedGuildNode
}
}
//If there is a currently loaded guild channel and it isn't the same as
//the new one we assume it must be read and mark it white.
if window.selectedChannelNode != nil && channel.ID != window.selectedChannel.ID {
window.selectedChannelNode.SetColor(tview.Styles.PrimaryTextColor)
}
window.selectedChannel = channel
//FIXME this is a bit bad, since it could be wrong
window.selectedChannelNode = window.channelTree.GetCurrentNode()
//Unlike with the channel, where we can assume it is read, we gotta check
//whether there is still an unread channel and mark the server accordingly.
if window.selectedGuild != nil && window.selectedGuild.ID != channel.GuildID {
window.updateServerReadStatus(window.selectedGuild.ID, window.selectedGuildNode, false)
}
if channel.GuildID == "" {
window.selectedGuild = nil
window.selectedGuildNode = nil
}
if channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM {
window.privateList.MarkChannelAsLoaded(channel)
}
window.exitMessageEditModeAndKeepText()
if config.Current.FocusMessageInputAfterChannelSelection {
window.app.SetFocus(window.messageInput.internalTextView)
}
go func() {
readstate.UpdateRead(window.session, channel, channel.LastMessageID)
// Here we make the assumption that the channel we are loading must be part
// of the currently loaded guild, since we don't allow loading a channel of
// a guilder otherwise.
if channel.GuildID != "" {
guild, cacheError := window.session.State.Guild(channel.GuildID)
if cacheError == nil {
window.selectedGuild = guild
window.app.QueueUpdateDraw(func() {
for _, guildNode := range window.guildList.GetRoot().GetChildren() {
if guildNode.GetReference() == channel.GuildID {
window.guildList.SetCurrentNode(guildNode)
if vtxxx {
guildNode.SetAttributes(tcell.AttrUnderline)
} else {
guildNode.SetColor(tview.Styles.ContrastBackgroundColor)
}
window.selectedGuildNode = guildNode
break
}
}
})
}
}
}()
return nil
}
// UpdateChatHeader updates the bordertitle of the chatviews container.o
// The title consist of the channel name and its topic for guild channels.
// For private channels it's either the recipient in a dm, or all recipients
// in a group dm channel. If the channel has a nickname, that is chosen.
func (window *Window) UpdateChatHeader(channel *discordgo.Channel) {
if channel == nil {
return
}
if channel.Type == discordgo.ChannelTypeGuildText {
if channel.Topic != "" {
window.chatView.SetTitle(channel.Name + " - " + channel.Topic)
} else {
window.chatView.SetTitle(channel.Name)
}
} else if channel.Type == discordgo.ChannelTypeDM {
window.chatView.SetTitle(channel.Recipients[0].Username)
} else {
window.chatView.SetTitle(discordutil.GetPrivateChannelName(channel))
}
}
// RegisterCommand register a command. That makes the command available for
// being called from the message input field, in case the user-defined prefix
// is in front of the input.
func (window *Window) RegisterCommand(command commands.Command) {
window.commands = append(window.commands, command)
}
// GetRegisteredCommands returns the map of all registered commands.
func (window *Window) GetRegisteredCommands() []commands.Command {
return window.commands
}
// GetSelectedGuild returns a reference to the currently selected Guild.
func (window *Window) GetSelectedGuild() *discordgo.Guild {
return window.selectedGuild
}
// GetSelectedChannel returns a reference to the currently selected Channel.
func (window *Window) GetSelectedChannel() *discordgo.Channel {
return window.selectedChannel
}
// PromptSecretInput shows an input dialog that masks the user input. The
// returned value will either be empty or what the user has entered.
func (window *Window) PromptSecretInput(title, message string) string {
waitChannel := make(chan struct{})
var output string
var previousFocus tview.Primitive
window.app.QueueUpdateDraw(func() {
previousFocus = window.app.GetFocus()
inputField := tview.NewInputField()
inputField.SetMaskCharacter('*')
inputField.SetDoneFunc(func(key tcell.Key) {
if key == tcell.KeyEnter {
output = inputField.GetText()
waitChannel <- struct{}{}
} else if key == tcell.KeyEscape {
waitChannel <- struct{}{}
}
})
inputField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
//FIXME Use shortcut and make it proper PasteAtCursor/Selection
if event.Key() == tcell.KeyCtrlV {
content, clipError := clipboard.ReadAll()
if clipError == nil {
inputField.SetText(content)
}
return nil
}
return event
})
frame := tview.NewFrame(inputField)
frame.SetTitle(title)
frame.SetBorder(true)
frame.AddText(message, true, tview.AlignLeft, tcell.ColorDefault)
window.app.SetRoot(frame, true)
window.currentContainer = frame
})
<-waitChannel
window.app.QueueUpdateDraw(func() {
window.app.SetRoot(window.rootContainer, true)
window.currentContainer = window.rootContainer
window.app.SetFocus(previousFocus)
waitChannel <- struct{}{}
})
<-waitChannel
return output
}
// ForceRedraw triggers ForceDraw on the underlying tview application, causing
// it to redraw all currently shown components.
func (window *Window) ForceRedraw() {
window.app.ForceDraw()
}
//Run Shows the window optionally returning an error.
func (window *Window) Run() error {
return window.app.Run()
}
// Shutdown disconnects from the discord API and stops the tview application.
func (window *Window) Shutdown() {
if config.Current.ShortenLinks {
window.chatView.shortener.Close()
}
window.session.Close()
window.app.Stop()
}