mirror of https://github.com/Bios-Marcel/cordless
2776 lines
94 KiB
Go
2776 lines
94 KiB
Go
package ui
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mdp/qrterminal/v3"
|
|
"github.com/skratchdot/open-golang/open"
|
|
|
|
"github.com/Bios-Marcel/cordless/fileopen"
|
|
"github.com/Bios-Marcel/cordless/logging"
|
|
"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"
|
|
tcell "github.com/gdamore/tcell/v2"
|
|
"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/components"
|
|
"github.com/Bios-Marcel/cordless/ui/shortcutdialog"
|
|
"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
|
|
|
|
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
|
|
|
|
selectedGuild *discordgo.Guild
|
|
selectedChannel *discordgo.Channel
|
|
previousChannel *discordgo.Channel
|
|
|
|
extensionEngines []scripting.Engine
|
|
|
|
commandMode bool
|
|
commandView *CommandView
|
|
commands []commands.Command
|
|
|
|
userActive bool
|
|
userActiveTimer *time.Timer
|
|
|
|
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(app *tview.Application, session *discordgo.Session, readyEvent *discordgo.Ready) (*Window, error) {
|
|
window := &Window{
|
|
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.app, window.ExecuteCommand)
|
|
logging.SetAdditionalOutput(window.commandView)
|
|
|
|
for _, engine := range window.extensionEngines {
|
|
initError := window.initExtensionEngine(engine)
|
|
if initError != nil {
|
|
return nil, initError
|
|
}
|
|
}
|
|
|
|
guilds := readyEvent.Guilds
|
|
|
|
mentionWindowRootNode := tview.NewTreeNode("")
|
|
autocompleteView := components.NewAutocompleteView()
|
|
autocompleteView.
|
|
SetRoot(mentionWindowRootNode).
|
|
SetBorder(true).
|
|
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 && channel.Type != discordgo.ChannelTypeGuildCategory {
|
|
loadError := window.LoadChannel(channel)
|
|
if loadError != nil {
|
|
window.ShowErrorDialog(loadError.Error())
|
|
}
|
|
}
|
|
})
|
|
window.registerGuildChannelHandler()
|
|
|
|
discordutil.SortGuilds(window.session.State.Settings, guilds)
|
|
guildList := NewGuildList(guilds)
|
|
window.guildList = guildList
|
|
window.guildList.UpdateUnreadGuildCount()
|
|
guildList.SetOnGuildSelect(func(guildID string) {
|
|
//Update previously selected guild.
|
|
if window.selectedGuild != nil {
|
|
window.updateServerReadStatus(window.selectedGuild.ID, false)
|
|
}
|
|
|
|
guild, cacheError := window.session.Guild(guildID)
|
|
if cacheError != nil {
|
|
window.ShowErrorDialog(cacheError.Error())
|
|
return
|
|
}
|
|
|
|
window.selectedGuild = guild
|
|
window.updateServerReadStatus(guild.ID, 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)
|
|
}
|
|
}
|
|
|
|
window.updateUserList()
|
|
})
|
|
|
|
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(channelID string) {
|
|
channel, stateError := window.session.State.Channel(channelID)
|
|
if stateError != nil {
|
|
window.ShowErrorDialog(fmt.Sprintf("Error loading chat: %s", stateError.Error()))
|
|
return
|
|
}
|
|
|
|
window.chatView.Lock()
|
|
defer window.chatView.Unlock()
|
|
|
|
loadError := window.LoadChannel(channel)
|
|
if loadError != nil {
|
|
window.ShowErrorDialog(loadError.Error())
|
|
}
|
|
})
|
|
|
|
window.privateList.SetOnFriendSelect(func(userID string) {
|
|
dmError := window.OpenDirectMessage(userID)
|
|
if dmError != nil {
|
|
window.ShowErrorDialog(dmError.Error())
|
|
}
|
|
})
|
|
|
|
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.NewDirectMessage.Equals(event) {
|
|
dmError := window.OpenDirectMessage(message.Author.ID)
|
|
if dmError != nil {
|
|
window.ShowErrorDialog(dmError.Error())
|
|
}
|
|
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) {
|
|
copyError := clipboard.WriteAll(discordutil.MessageToPlainText(message))
|
|
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.FileDownloadSaveLocation)
|
|
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())
|
|
}
|
|
}
|
|
|
|
urlMatches := urlRegex.FindAllString(message.Content, 1000)
|
|
for _, url := range urlMatches {
|
|
header, _ := http.Head(url)
|
|
|
|
//A website! Any other text/ could be a file, like .txt, .css or whatever.
|
|
//Is there a more bulletproof way to doing this?
|
|
if strings.Contains(header.Header.Get("Content-Type"), "text/html") {
|
|
//We hope to just open this with the users browser ;)
|
|
open.Run(url)
|
|
continue
|
|
}
|
|
|
|
openError := fileopen.OpenFile(targetFolder, "file", url)
|
|
if openError != nil {
|
|
window.ShowCustomErrorDialog("Couldn't open file", openError.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
//If permanent saving isn't disabled, we clear files older
|
|
//than two weeks 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
|
|
}
|
|
|
|
if shortcuts.DownloadMessageFiles.Equals(event) {
|
|
absolutePath, pathError := files.ToAbsolutePath(config.Current.FileDownloadSaveLocation)
|
|
if pathError != nil || absolutePath == "" {
|
|
window.ShowErrorDialog("Please specify a valid path in 'FileOpenSaveFolder' of your configuration.")
|
|
} else {
|
|
downloadFunction := func(savePath, fileURL string) {
|
|
_, statErr := os.Stat(savePath)
|
|
//If the file exists already, we needn't do anything.
|
|
if statErr == nil {
|
|
return
|
|
}
|
|
|
|
downloadError := files.DownloadFile(savePath, fileURL)
|
|
if downloadError != nil {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.ShowErrorDialog("Error download file: " + downloadError.Error())
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, file := range message.Attachments {
|
|
extension := strings.TrimPrefix(filepath.Ext(file.URL), ".")
|
|
targetFile := filepath.Join(absolutePath, file.ID+"."+extension)
|
|
|
|
//All files are downloaded separately in order to not
|
|
//block the UI and not download for ages if one or more
|
|
//page has a slow download speed.
|
|
go downloadFunction(targetFile, file.URL)
|
|
}
|
|
|
|
urlMatches := urlRegex.FindAllString(message.Content, 1000)
|
|
for _, url := range urlMatches {
|
|
baseName := filepath.Base(url)
|
|
if baseName == "" {
|
|
continue
|
|
}
|
|
|
|
targetFile := filepath.Join(absolutePath, filepath.Base(url))
|
|
|
|
//All files are downloaded separately in order to not
|
|
//block the UI and not download for ages if one or more
|
|
//page has a slow download speed.
|
|
go downloadFunction(targetFile, url)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return event
|
|
})
|
|
window.messageContainer = window.chatView.GetPrimitive()
|
|
|
|
window.messageInput = NewEditor(window.app)
|
|
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)
|
|
_, _, _, height := window.app.GetRoot().GetRect()
|
|
//The preferred height is limited to a certain to avoid the
|
|
//autocomplete taking too much space in small windows, or
|
|
//furthermore terminals with a big font size.
|
|
prefHeight := maths.Min(maths.Min(10, height/4), len(values))
|
|
window.chatArea.ResizeItem(autocompleteView, prefHeight, 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 {
|
|
//Workaround for discord just having a default role called "@everyone"
|
|
if role.Name == "@everyone" {
|
|
autocompleteValues = append(autocompleteValues, &AutocompleteValue{
|
|
RenderValue: role.Name,
|
|
InsertValue: "@everyone",
|
|
})
|
|
} else {
|
|
autocompleteValues = append(autocompleteValues, &AutocompleteValue{
|
|
RenderValue: role.Name,
|
|
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
|
|
})
|
|
|
|
window.messageInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
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.messageInput.GetText()
|
|
if 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) {
|
|
messageToSend := window.messageInput.GetText()
|
|
if window.selectedChannel != nil {
|
|
window.TrySendMessage(window.selectedChannel, messageToSend)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return event
|
|
})
|
|
|
|
//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.registerReactionEventHandlers()
|
|
|
|
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 {
|
|
focusTextViewOnTypeInputHandler := tviewutil.CreateFocusTextViewOnTypeInputHandler(
|
|
window.app, window.messageInput.internalTextView)
|
|
guildList.SetInputCapture(focusTextViewOnTypeInputHandler)
|
|
channelTree.SetInputCapture(focusTextViewOnTypeInputHandler)
|
|
window.userList.SetInputCapture(focusTextViewOnTypeInputHandler)
|
|
window.privateList.SetInputCapture(focusTextViewOnTypeInputHandler)
|
|
window.chatView.internalTextView.SetInputCapture(focusTextViewOnTypeInputHandler)
|
|
}
|
|
|
|
newGuildHandler := func(event *tcell.EventKey) *tcell.EventKey {
|
|
if shortcuts.GuildListMarkRead.Equals(event) {
|
|
selectedGuildNode := guildList.GetCurrentNode()
|
|
if selectedGuildNode != nil && !readstate.HasGuildBeenRead(selectedGuildNode.GetReference().(string)) {
|
|
ackError := window.session.GuildMessageAck(selectedGuildNode.GetReference().(string))
|
|
if ackError != nil {
|
|
window.ShowErrorDialog(ackError.Error())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
oldGuildListHandler := guildList.GetInputCapture()
|
|
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
|
|
})
|
|
}
|
|
|
|
newChannelListHandler := func(event *tcell.EventKey) *tcell.EventKey {
|
|
if shortcuts.ChannelTreeMarkRead.Equals(event) {
|
|
selectedChannelNode := channelTree.GetCurrentNode()
|
|
if selectedChannelNode != nil {
|
|
ackError := discordutil.AcknowledgeChannel(window.session, selectedChannelNode.GetReference().(string))
|
|
if ackError != nil {
|
|
window.ShowErrorDialog(ackError.Error())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
oldChannelListHandler := channelTree.GetInputCapture()
|
|
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
|
|
})
|
|
}
|
|
|
|
//If another client acknowledges a message, we locally mark the channel as read.
|
|
window.session.AddHandler(func(s *discordgo.Session, event *discordgo.MessageAck) {
|
|
window.app.QueueUpdateDraw(func() {
|
|
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.MarkAsRead(channel.ID)
|
|
} else {
|
|
selectedGuild := window.selectedGuild
|
|
if selectedGuild != nil && selectedGuild.ID == channel.GuildID {
|
|
window.channelTree.MarkAsRead(channel.ID)
|
|
} else {
|
|
window.updateServerReadStatus(channel.GuildID, false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
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 := components.NewBottomBar()
|
|
var loggedInAsText string
|
|
username := tviewutil.Escape(session.State.User.Username)
|
|
if session.State.User.Bot {
|
|
loggedInAsText = fmt.Sprintf("Logged in as: Bot '%s'", username)
|
|
} else {
|
|
loggedInAsText = fmt.Sprintf("Logged in as: '%s'", username)
|
|
}
|
|
bottomBar.AddItem(loggedInAsText)
|
|
bottomBar.AddItem(fmt.Sprintf("View / Change shortcuts: %s", shortcutdialog.EventToString(shortcutsDialogShortcut)))
|
|
window.rootContainer.AddItem(bottomBar, 1, 0, false)
|
|
}
|
|
|
|
window.rootContainer.SetInputCapture(window.handleChatWindowShortcuts)
|
|
app.SetInputCapture(window.handleGlobalShortcuts)
|
|
app.SetRoot(window.rootContainer, true)
|
|
|
|
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)
|
|
}
|
|
window.updateUserList()
|
|
|
|
autocompleteView.SetVisible(false)
|
|
|
|
//All uncaptured events, e.g. events not relevant for TreeViews, will be
|
|
//forwarded to the message input, as both full typing capability, but also
|
|
//autocomplete capability should work at the same time.
|
|
autocompleteView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
return window.messageInput.internalTextView.GetInputCapture()(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()
|
|
|
|
window.messageInput.internalTextView.SetNextFocusableComponents(tview.Up, window.chatView.internalTextView)
|
|
window.messageInput.internalTextView.SetNextFocusableComponents(tview.Down, window.commandView.commandOutput, window.chatView.internalTextView)
|
|
window.messageInput.internalTextView.SetNextFocusableComponents(tview.Right, window.userList.internalTreeView, window.channelTree, window.privateList.internalTreeView)
|
|
window.messageInput.internalTextView.SetNextFocusableComponents(tview.Left, window.channelTree, window.privateList.internalTreeView)
|
|
|
|
window.channelTree.SetNextFocusableComponents(tview.Up, window.guildList)
|
|
window.channelTree.SetNextFocusableComponents(tview.Down, window.guildList)
|
|
window.channelTree.SetNextFocusableComponents(tview.Left, window.userList.internalTreeView, window.commandView.commandOutput, window.messageInput.GetPrimitive())
|
|
window.channelTree.SetNextFocusableComponents(tview.Right, window.commandView.commandOutput, window.messageInput.GetPrimitive())
|
|
|
|
window.guildList.SetNextFocusableComponents(tview.Up, window.channelTree)
|
|
window.guildList.SetNextFocusableComponents(tview.Down, window.channelTree)
|
|
window.guildList.SetNextFocusableComponents(tview.Left, window.userList.internalTreeView, window.chatView.GetPrimitive())
|
|
window.guildList.SetNextFocusableComponents(tview.Right, window.chatView.GetPrimitive(), window.userList.internalTreeView)
|
|
|
|
window.privateList.internalTreeView.SetNextFocusableComponents(tview.Right, window.chatView.GetPrimitive())
|
|
window.privateList.internalTreeView.SetNextFocusableComponents(tview.Left, window.userList.internalTreeView, window.chatView.GetPrimitive())
|
|
|
|
window.userList.internalTreeView.SetNextFocusableComponents(tview.Left, window.chatView.GetPrimitive())
|
|
|
|
window.chatView.internalTextView.SetNextFocusableComponents(tview.Down, window.messageInput.GetPrimitive())
|
|
window.chatView.internalTextView.SetNextFocusableComponents(tview.Up, window.commandView.commandInput.internalTextView, window.messageInput.GetPrimitive())
|
|
|
|
window.commandView.commandInput.internalTextView.SetNextFocusableComponents(tview.Up, window.commandView.commandOutput)
|
|
window.commandView.commandInput.internalTextView.SetNextFocusableComponents(tview.Down, window.chatView.GetPrimitive())
|
|
window.commandView.commandInput.internalTextView.SetNextFocusableComponents(tview.Right, window.userList.internalTreeView, window.channelTree, window.privateList.internalTreeView)
|
|
window.commandView.commandInput.internalTextView.SetNextFocusableComponents(tview.Left, window.channelTree, window.privateList.internalTreeView)
|
|
|
|
window.commandView.commandOutput.SetNextFocusableComponents(tview.Up, window.messageInput.GetPrimitive())
|
|
window.commandView.commandOutput.SetNextFocusableComponents(tview.Down, window.commandView.commandInput.GetPrimitive())
|
|
window.commandView.commandOutput.SetNextFocusableComponents(tview.Right, window.userList.internalTreeView, window.channelTree, window.privateList.internalTreeView)
|
|
window.commandView.commandOutput.SetNextFocusableComponents(tview.Left, window.channelTree, window.privateList.internalTreeView)
|
|
|
|
app.SetFocus(guildList)
|
|
|
|
if config.Current.MouseEnabled {
|
|
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
|
|
- Changes
|
|
- Bugfixes
|
|
[::b]2020-10-24
|
|
- Features
|
|
- DM people via "p" in the chatview or use the dm-open command
|
|
- Mark guilds as read
|
|
- Mark guild channels as read
|
|
- Write to logfile by setting "--log"
|
|
- Mentions are now displayed in the guild list
|
|
- You can now bulk send folders and files
|
|
- Changes
|
|
- Dialogs shown at the bottom of the chatview now allow tab / backtab
|
|
- There's now a double-colon to separate author and messages
|
|
- There's more customizable shortcuts now
|
|
- Bugfixes
|
|
- Guilds and channels were sometimes falsely seen as muted
|
|
- Deleting / Leaving guilds now properly deletes them from the UI
|
|
- Jumping to guilds / channels you were mentioned in, now works by
|
|
by typing their name again
|
|
- Fixed deadlock when spamming "Switch to previous channel"
|
|
- "Switch to previous channel" doesn't jumble the state anymore
|
|
when switching between different guilds and DMs
|
|
- Muted guilds, channels and categories shouldn't be displayed as
|
|
unread anymore
|
|
- @everyone works again, so you can piss of others again
|
|
- Messages containing links won't disappear anymore after sending
|
|
- Messages from blocked users won't trigger notifications anymore
|
|
- No more spammed empty error messages when receiving notifications
|
|
[::b]2020-08-30
|
|
- Features
|
|
- Nicknames can now be disabled via the configuration
|
|
- Files from messages can now be downloaded (key d) or opened (key o)
|
|
- New parameter "--account" to start cordless with a certain account
|
|
- Changes
|
|
- The "friends" command now has "friend" as an alias
|
|
- "logout" is now a separate command, but "account logout" still works
|
|
- Currently active account is now highlight in "account list" output
|
|
- Password input dialog now uses the configured shortcut for paste
|
|
- Baremode
|
|
- Now includes the message input
|
|
- The command view will hide when entering baremode
|
|
- Bugfixes
|
|
- Fix crash due to race condition in readmarker feature
|
|
- Embed-Edits won't be ignored anymore
|
|
- Names with role colors now respect their role order
|
|
- Unread message numbers now always update when loading a channel instead of when leaving it
|
|
- UTF-8 disabling wasn't taken into account when rendering the channel tree
|
|
[::b]2020-08-11 - 2020-06-30
|
|
- 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
|
|
}
|
|
|
|
// OpenDirectMessage creates a new chat with the given user or loads an
|
|
// already existing one. On success, the channel is loaded.
|
|
func (window *Window) OpenDirectMessage(userID string) error {
|
|
//Can't message yourself, goon!
|
|
if userID == window.session.State.User.ID {
|
|
return nil
|
|
}
|
|
|
|
window.chatView.Lock()
|
|
defer window.chatView.Unlock()
|
|
|
|
//If there's an existing channel, we use that and avoid unnecessary traffic.
|
|
existingChannel := discordutil.FindDMChannelWithUser(window.session.State, userID)
|
|
if existingChannel != nil {
|
|
window.SwitchToPrivateChannel(existingChannel)
|
|
return nil
|
|
}
|
|
|
|
newChannel, createError := window.session.UserChannelCreate(userID)
|
|
if createError != nil {
|
|
return createError
|
|
}
|
|
|
|
window.SwitchToPrivateChannel(newChannel)
|
|
return nil
|
|
}
|
|
|
|
// SwitchToPrivateChannel switches to the friends page, loads the given channel
|
|
// and then focuses the input primitive.
|
|
func (window *Window) SwitchToPrivateChannel(channel *discordgo.Channel) {
|
|
window.SwitchToFriendsPage()
|
|
window.app.SetFocus(window.messageInput.GetPrimitive())
|
|
window.LoadChannel(channel)
|
|
}
|
|
|
|
func (window *Window) insertNewLineAtCursor() {
|
|
window.messageInput.InsertCharacter('\n')
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.messageInput.TriggerHeightRequestIfNecessary()
|
|
window.messageInput.internalTextView.ScrollToHighlight()
|
|
})
|
|
}
|
|
|
|
// IsCursorInsideCodeBlock checks if the cursor comes after three backticks
|
|
// that don't have another 3 backticks following after them.
|
|
func (window *Window) IsCursorInsideCodeBlock() bool {
|
|
var backtickCount int
|
|
var foundUnclosedBackticks bool
|
|
for _, char := range window.messageInput.GetTextLeftOfSelection() {
|
|
if char == '`' {
|
|
backtickCount++
|
|
} else {
|
|
backtickCount = 0
|
|
}
|
|
|
|
if backtickCount == 3 {
|
|
if foundUnclosedBackticks {
|
|
foundUnclosedBackticks = false
|
|
} else {
|
|
foundUnclosedBackticks = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return foundUnclosedBackticks
|
|
}
|
|
|
|
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(
|
|
discordutil.ReplaceMentions(message), 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
|
|
}
|
|
|
|
//Deleting everything means we wanna delete the messages. This is what
|
|
//the official discord client and it's quite useful.
|
|
if len(message) == 0 {
|
|
if window.editingMessageID != nil {
|
|
msgIDCopy := *window.editingMessageID
|
|
window.askForMessageDeletion(msgIDCopy, true)
|
|
}
|
|
return
|
|
}
|
|
|
|
message = strings.TrimSpace(message)
|
|
//If the message is empty after trimming spaces, we assume it was an
|
|
//accidental send and clear the input box.
|
|
if len(message) == 0 {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.messageInput.SetText("")
|
|
})
|
|
return
|
|
}
|
|
|
|
//Prepare message first, so we can potentially make use of scripting to
|
|
//for example send a file.
|
|
messagePrepared := window.prepareMessage(targetChannel, message)
|
|
|
|
//If we are currently editing a message, we won't allow attachig a file afterwards.
|
|
//FIXME But should we?
|
|
if window.editingMessageID != nil {
|
|
window.editMessage(targetChannel.ID, *window.editingMessageID, messagePrepared)
|
|
return
|
|
}
|
|
|
|
if strings.HasPrefix(messagePrepared, "file://") {
|
|
window.app.QueueUpdateDraw(func() {
|
|
yesButton := "Yes"
|
|
window.ShowDialog(config.GetTheme().PrimitiveBackgroundColor, "Resolve filepath and send a file instead?", func(button string) {
|
|
if button == yesButton {
|
|
window.messageInput.SetText("")
|
|
go func() {
|
|
sendError := discordutil.ResolveFilePathAndSendFile(window.session, messagePrepared, targetChannel.ID)
|
|
if sendError != nil {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.ShowErrorDialog(sendError.Error())
|
|
})
|
|
}
|
|
}()
|
|
} else {
|
|
window.sendMessageWithLengthCheck(targetChannel, messagePrepared)
|
|
}
|
|
}, yesButton, "No")
|
|
})
|
|
return
|
|
}
|
|
|
|
window.sendMessageWithLengthCheck(targetChannel, messagePrepared)
|
|
}
|
|
|
|
func (window *Window) sendMessageWithLengthCheck(targetChannel *discordgo.Channel, message string) {
|
|
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
|
|
}
|
|
|
|
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, isSelected bool) {
|
|
guild, cacheError := window.session.State.Guild(guildID)
|
|
if cacheError == nil {
|
|
window.guildList.UpdateNodeStateByGuild(guild, isSelected)
|
|
window.guildList.UpdateUnreadGuildCount()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 := text.FindEmojiIndices(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 || event.Key() == tcell.KeyTab {
|
|
if len(buttonWidgets) <= indexCopy+1 {
|
|
window.app.SetFocus(buttonWidgets[0])
|
|
} else {
|
|
window.app.SetFocus(buttonWidgets[indexCopy+1])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if event.Key() == tcell.KeyLeft || event.Key() == tcell.KeyBacktab {
|
|
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) {
|
|
edit <- m.Message
|
|
})
|
|
}
|
|
|
|
// registerReactionEventHandlers are responsible for updating the cache if
|
|
// reactions are added or removed and updating the chatview if needed.
|
|
func (window *Window) registerReactionEventHandlers() {
|
|
window.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageReactionAdd) {
|
|
message, stateError := s.State.Message(m.ChannelID, m.MessageID)
|
|
if message != nil && stateError == nil {
|
|
s.State.Lock()
|
|
defer func() {
|
|
selectedChannel := window.selectedChannel
|
|
if selectedChannel != nil && selectedChannel.ID == m.ChannelID {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.chatView.Lock()
|
|
defer window.chatView.Unlock()
|
|
|
|
window.chatView.UpdateMessage(message)
|
|
})
|
|
}
|
|
}()
|
|
defer s.State.Unlock()
|
|
|
|
discordutil.HandleReactionAdd(s.State, message, m)
|
|
}
|
|
})
|
|
|
|
window.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageReactionRemove) {
|
|
message, stateError := s.State.Message(m.ChannelID, m.MessageID)
|
|
if message != nil && stateError == nil {
|
|
s.State.Lock()
|
|
defer func() {
|
|
selectedChannel := window.selectedChannel
|
|
if selectedChannel != nil && selectedChannel.ID == m.ChannelID {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.chatView.Lock()
|
|
defer window.chatView.Unlock()
|
|
|
|
window.chatView.UpdateMessage(message)
|
|
})
|
|
}
|
|
}()
|
|
defer s.State.Unlock()
|
|
|
|
discordutil.HandleReactionRemove(s.State, message, m)
|
|
}
|
|
})
|
|
|
|
window.session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageReactionRemoveAll) {
|
|
message, stateError := s.State.Message(m.ChannelID, m.MessageID)
|
|
if message != nil && stateError == nil {
|
|
s.State.Lock()
|
|
defer func() {
|
|
selectedChannel := window.selectedChannel
|
|
if selectedChannel != nil && selectedChannel.ID == m.ChannelID {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.chatView.Lock()
|
|
defer window.chatView.Unlock()
|
|
|
|
window.chatView.UpdateMessage(message)
|
|
})
|
|
}
|
|
}()
|
|
defer s.State.Unlock()
|
|
|
|
discordutil.HandleReactionRemoveAll(s.State, 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
|
|
if len(window.extensionEngines) > 0 {
|
|
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) {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.updateServerReadStatus(channel.GuildID, false)
|
|
})
|
|
}
|
|
|
|
// 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.Reorder()
|
|
})
|
|
} else {
|
|
window.privateList.Reorder()
|
|
}
|
|
}
|
|
|
|
if message.Author.ID == window.session.State.User.ID {
|
|
readstate.UpdateReadLocal(message.ChannelID, message.ID)
|
|
continue
|
|
}
|
|
|
|
if config.Current.DesktopNotifications {
|
|
notifyError := window.handleNotification(message, channel)
|
|
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 window.selectedChannel == nil || message.ChannelID != window.selectedChannel.ID {
|
|
if channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM {
|
|
if !readstate.IsPrivateChannelMuted(channel) {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.privateList.MarkAsUnread(channel.ID)
|
|
})
|
|
}
|
|
} else if channel.Type == discordgo.ChannelTypeGuildText {
|
|
if discordutil.MentionsCurrentUserExplicitly(window.session.State, message) {
|
|
readstate.MarkAsMentioned(channel.ID)
|
|
window.app.QueueUpdateDraw(func() {
|
|
isCurrentGuild := window.selectedGuild != nil && window.selectedGuild.ID == channel.GuildID
|
|
window.updateServerReadStatus(channel.GuildID, isCurrentGuild)
|
|
window.channelTree.MarkAsMentioned(channel.ID)
|
|
})
|
|
} else if !readstate.IsGuildChannelMuted(channel) {
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.channelTree.MarkAsUnread(channel.ID)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
for messageDeleted := range delete {
|
|
tempMessageDeleted := messageDeleted
|
|
|
|
if len(window.extensionEngines) > 0 {
|
|
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() {
|
|
MESSAGE_EDIT_LOOP:
|
|
for messageEdited := range edit {
|
|
tempMessageEdited := messageEdited
|
|
if len(window.extensionEngines) > 0 {
|
|
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.
|
|
|
|
//FIXME Workaround for the workaround, since discord appears to not send the content
|
|
//again for messages that have only had an embed added. In that situation, the
|
|
//timestamp for editing will also not be set, therefore we can circumvent this issue.
|
|
if tempMessageEdited.EditedTimestamp != "" && tempMessageEdited.Content != "" {
|
|
message.Content = tempMessageEdited.Content
|
|
}
|
|
|
|
message.Mentions = tempMessageEdited.Mentions
|
|
message.MentionRoles = tempMessageEdited.MentionRoles
|
|
message.MentionEveryone = tempMessageEdited.MentionEveryone
|
|
|
|
window.QueueUpdateDrawSynchronized(func() {
|
|
defer window.chatView.Unlock()
|
|
window.chatView.UpdateMessage(message)
|
|
})
|
|
continue MESSAGE_EDIT_LOOP
|
|
}
|
|
}
|
|
}
|
|
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.previousChannel != nil && window.previousChannel.GuildID == guildRemove.ID {
|
|
window.previousChannel = nil
|
|
}
|
|
|
|
guildID := guildRemove.ID
|
|
window.guildList.RemoveGuild(guildID)
|
|
|
|
selectedGuild := window.selectedGuild
|
|
if selectedGuild != nil && selectedGuild.ID == guildID {
|
|
window.app.QueueUpdateDraw(func() {
|
|
if window.selectedChannel != nil && window.selectedChannel.GuildID == guildID {
|
|
window.UnloadChannel()
|
|
//Unload channel sets the selectedChannel as the previous
|
|
//one, which isn't correct.
|
|
window.previousChannel = nil
|
|
}
|
|
|
|
window.channelTree.Clear()
|
|
window.selectedGuild = nil
|
|
window.updateUserList()
|
|
})
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
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.previousChannel != nil && window.previousChannel.ID == event.ID {
|
|
window.previousChannel = nil
|
|
}
|
|
|
|
if window.selectedChannel != nil && window.selectedChannel.ID == event.ID {
|
|
window.selectedChannel = 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) isElligibleForNotification(message *discordgo.Message, channel *discordgo.Channel) bool {
|
|
if discordutil.IsBlocked(window.session.State, message.Author) {
|
|
return false
|
|
}
|
|
|
|
isCurrentChannel := window.selectedChannel == nil || message.ChannelID != window.selectedChannel.ID
|
|
//Client is not in a state elligible for notifications.
|
|
if isCurrentChannel && (window.userActive || !config.Current.DesktopNotificationsForLoadedChannel) {
|
|
return false
|
|
}
|
|
|
|
isPrivateChannel := channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM
|
|
mentionsCurrentUser := discordutil.MentionsCurrentUserExplicitly(window.session.State, message)
|
|
//We always show notification for private messages, no matter whether
|
|
//the user was explicitly mentioned.
|
|
if !isPrivateChannel && !mentionsCurrentUser {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (window *Window) handleNotification(message *discordgo.Message, channel *discordgo.Channel) error {
|
|
if !window.isElligibleForNotification(message, channel) {
|
|
return nil
|
|
}
|
|
|
|
var notificationLocation string
|
|
if channel.Type == discordgo.ChannelTypeDM {
|
|
notificationLocation = message.Author.Username
|
|
} else if channel.Type == discordgo.ChannelTypeGroupDM {
|
|
notificationLocation = message.Author.Username + " - " +
|
|
discordutil.GetPrivateChannelNameUnescaped(channel)
|
|
} 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)
|
|
}
|
|
}
|
|
|
|
return beeep.Notify("Cordless - "+notificationLocation, message.ContentWithMentionsReplaced(), "assets/information.png")
|
|
}
|
|
|
|
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#Shutdown unnecessary, as we shut the whole process down.
|
|
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)
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
func (window *Window) handleChatWindowShortcuts(event *tcell.EventKey) *tcell.EventKey {
|
|
if window.dialogReplacement.IsVisible() {
|
|
//Delegate to focused component which is the dialog.
|
|
//We do this, cause we don't want people to focus anything else than
|
|
//the dialog.
|
|
return event
|
|
}
|
|
|
|
if shortcuts.DirectionalFocusHandling(event, window.app) == nil {
|
|
return nil
|
|
}
|
|
|
|
if shortcuts.ToggleBareChat.Equals(event) {
|
|
window.toggleBareChat()
|
|
} else if shortcuts.FocusMessageInput.Equals(event) {
|
|
window.app.SetFocus(window.messageInput.GetPrimitive())
|
|
} else if shortcuts.FocusMessageContainer.Equals(event) {
|
|
window.app.SetFocus(window.chatView.internalTextView)
|
|
} else if shortcuts.EventsEqual(event, shortcutsDialogShortcut) {
|
|
shortcutdialog.ShowShortcutsDialog(window.app, func() {
|
|
window.app.SetRoot(window.rootContainer, true)
|
|
})
|
|
} 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.FocusUserContainer.Equals(event) {
|
|
if window.userList.internalTreeView.IsVisible() {
|
|
window.app.SetFocus(window.userList.internalTreeView)
|
|
}
|
|
} else {
|
|
return event
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (window *Window) toggleUserContainer() {
|
|
config.Current.ShowUserContainer = !config.Current.ShowUserContainer
|
|
config.PersistConfig()
|
|
window.updateUserList()
|
|
//Solves https://github.com/Bios-Marcel/cordless/issues/246
|
|
window.chatView.Reprint()
|
|
}
|
|
|
|
// 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.SetBorderSides(true, false, true, false)
|
|
window.commandView.commandOutput.SetBorderSides(true, false, true, false)
|
|
|
|
//Initially this should be gone. Reaccessing it with the normal
|
|
//shortcut is possible though.
|
|
window.SetCommandModeEnabled(false)
|
|
|
|
previousFocus := window.app.GetFocus()
|
|
window.app.SetRoot(window.chatArea, true)
|
|
window.app.SetFocus(previousFocus)
|
|
} else {
|
|
window.chatView.internalTextView.SetBorderSides(true, true, true, true)
|
|
window.commandView.commandOutput.SetBorderSides(true, true, true, true)
|
|
|
|
window.app.SetRoot(window.rootContainer, true)
|
|
window.app.SetFocus(window.messageInput.GetPrimitive())
|
|
}
|
|
|
|
window.app.QueueUpdateDraw(func() {
|
|
window.messageInput.TriggerHeightRequestIfNecessary()
|
|
window.chatView.Reprint()
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
//On Vtxxx the yellow color won't work, so we blink instead.
|
|
window.messageInput.SetBorderBlinking(tview.IsVtxxx)
|
|
window.editingMessageID = &message.ID
|
|
window.app.SetFocus(window.messageInput.GetPrimitive())
|
|
}
|
|
}
|
|
|
|
func (window *Window) exitMessageEditMode() {
|
|
if window.editingMessageID != nil {
|
|
window.exitMessageEditModeAndKeepText()
|
|
window.messageInput.SetText("")
|
|
}
|
|
}
|
|
|
|
func (window *Window) exitMessageEditModeAndKeepText() {
|
|
window.editingMessageID = nil
|
|
//On Vtxxx the yellow color won't work, so we blink instead.
|
|
window.messageInput.SetBorderBlinking(false)
|
|
window.messageInput.SetBorderColor(tview.Styles.BorderColor)
|
|
window.messageInput.SetBorderFocusColor(tview.Styles.BorderFocusColor)
|
|
}
|
|
|
|
// 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)
|
|
if discordError != nil {
|
|
window.app.QueueUpdateDraw(func() {
|
|
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
|
|
|
|
window.userList.internalTreeView.SetNextFocusableComponents(tview.Right, window.guildList)
|
|
window.chatView.internalTextView.SetNextFocusableComponents(tview.Left, window.guildList)
|
|
window.chatView.internalTextView.SetNextFocusableComponents(tview.Right, window.userList.internalTreeView, window.guildList)
|
|
}
|
|
|
|
//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
|
|
|
|
window.userList.internalTreeView.SetNextFocusableComponents(tview.Right, window.privateList.internalTreeView)
|
|
window.chatView.internalTextView.SetNextFocusableComponents(tview.Left, window.privateList.internalTreeView)
|
|
window.chatView.internalTextView.SetNextFocusableComponents(tview.Right, window.userList.internalTreeView, window.privateList.internalTreeView)
|
|
}
|
|
|
|
// SwitchToPreviousChannel loads the previously loaded channel and focuses it
|
|
// in it's respective UI primitive.
|
|
func (window *Window) SwitchToPreviousChannel() error {
|
|
previousChannel := window.previousChannel
|
|
if previousChannel == nil || previousChannel == window.selectedChannel {
|
|
// No previous channel.
|
|
return nil
|
|
}
|
|
|
|
_, channelStateError := window.session.State.Channel(previousChannel.ID)
|
|
if channelStateError != nil {
|
|
window.previousChannel = nil
|
|
return fmt.Errorf("channel %s not found", previousChannel.Name)
|
|
}
|
|
|
|
if previousChannel.GuildID == "" {
|
|
window.SwitchToFriendsPage()
|
|
window.privateList.onChannelSelect(window.previousChannel.ID)
|
|
} else {
|
|
_, guildStateError := window.session.State.Guild(previousChannel.GuildID)
|
|
if guildStateError != nil {
|
|
window.previousChannel = nil
|
|
return fmt.Errorf("Unable to load guild: %s", previousChannel.GuildID)
|
|
}
|
|
|
|
if !discordutil.HasReadMessagesPermission(previousChannel.ID, window.session.State) {
|
|
window.previousChannel = nil
|
|
return fmt.Errorf("No read permissions for channel: %s", previousChannel.Name)
|
|
}
|
|
|
|
window.SwitchToGuildsPage()
|
|
window.guildList.onGuildSelect(previousChannel.GuildID)
|
|
window.channelTree.onChannelSelect(previousChannel.ID)
|
|
|
|
previousGuildNode := tviewutil.GetNodeByReference(previousChannel.GuildID, window.guildList.TreeView)
|
|
previousChannelNode := tviewutil.GetNodeByReference(previousChannel.ID, window.channelTree.TreeView)
|
|
window.guildList.SetCurrentNode(previousGuildNode)
|
|
window.channelTree.SetCurrentNode(previousChannelNode)
|
|
}
|
|
|
|
window.app.SetFocus(window.messageInput.internalTextView)
|
|
return nil
|
|
}
|
|
|
|
// updateUserList decides whether the userlist should be shown according to
|
|
// the current window state. Depending on the result, the list is cleared
|
|
// and loaded.
|
|
func (window *Window) updateUserList() {
|
|
selectedGuild := window.selectedGuild
|
|
selectedChannel := window.selectedChannel
|
|
showUserList := config.Current.ShowUserContainer &&
|
|
//We want to show it always when a guild is loaded.
|
|
(selectedGuild != nil &&
|
|
//This means also when no channel is loaded.
|
|
(selectedChannel == nil ||
|
|
//However, if there is a lodaded channel and it is a DM, this component
|
|
//is basically useless, as it's obvious who's part of the channel.
|
|
selectedChannel.Type != discordgo.ChannelTypeDM))
|
|
|
|
//If the userList was focused before, we focus the input, so that the
|
|
//user can still properly navigate around via alt+arrowkey.
|
|
if !showUserList && window.app.GetFocus() == window.userList.internalTreeView {
|
|
window.app.SetFocus(window.messageInput.GetPrimitive())
|
|
}
|
|
|
|
window.userList.internalTreeView.SetVisible(showUserList)
|
|
|
|
if showUserList {
|
|
if selectedChannel != nil && selectedChannel.Type == discordgo.ChannelTypeGroupDM {
|
|
window.userList.LoadGroup(selectedChannel.ID)
|
|
} else if selectedGuild != nil {
|
|
window.userList.LoadGuild(selectedGuild.ID)
|
|
} else {
|
|
window.userList.Clear()
|
|
}
|
|
} else {
|
|
window.userList.Clear()
|
|
}
|
|
}
|
|
|
|
// ApplyFixedLayoutSettings applies the current settings for the FixedLayout
|
|
// to the window. This means resizing the components.
|
|
func (window *Window) ApplyFixedLayoutSettings() {
|
|
if config.Current.UseFixedLayout {
|
|
window.middleContainer.ResizeItem(window.leftArea, config.Current.FixedSizeLeft, 0)
|
|
window.middleContainer.ResizeItem(window.chatArea, 0, 1)
|
|
window.middleContainer.ResizeItem(window.userList.internalTreeView, config.Current.FixedSizeRight, 0)
|
|
} else {
|
|
window.middleContainer.ResizeItem(window.leftArea, 0, 7)
|
|
window.middleContainer.ResizeItem(window.chatArea, 0, 20)
|
|
window.middleContainer.ResizeItem(window.userList.internalTreeView, 0, 6)
|
|
}
|
|
}
|
|
|
|
// UnloadChannel resets the windows to the state at which no channel has
|
|
// been loaded yet.
|
|
func (window *Window) UnloadChannel() {
|
|
currentChannel := window.selectedChannel
|
|
//NO-OP, nothing to do here.
|
|
if currentChannel == nil {
|
|
return
|
|
}
|
|
|
|
//window.selectedGuild is kept, as just the chatview is unloaded, but not
|
|
//the channel tree.
|
|
window.selectedChannel = nil
|
|
window.previousChannel = currentChannel
|
|
|
|
window.chatView.ClearViewAndCache()
|
|
window.UpdateChatHeader(nil)
|
|
window.exitMessageEditModeAndKeepText()
|
|
|
|
hasBeenRead := readstate.HasBeenRead(currentChannel, currentChannel.LastMessageID)
|
|
if currentChannel.Type == discordgo.ChannelTypeDM || currentChannel.Type == discordgo.ChannelTypeGroupDM {
|
|
if hasBeenRead {
|
|
window.privateList.MarkAsRead(currentChannel.ID)
|
|
} else {
|
|
window.privateList.MarkAsUnread(currentChannel.ID)
|
|
}
|
|
} else {
|
|
if hasBeenRead {
|
|
window.channelTree.MarkAsRead(currentChannel.ID)
|
|
} else {
|
|
window.channelTree.MarkAsUnread(currentChannel.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
//LoadChannel eagerly loads the channels messages.
|
|
func (window *Window) LoadChannel(channel *discordgo.Channel) error {
|
|
window.UnloadChannel()
|
|
|
|
var guild *discordgo.Guild
|
|
if channel.GuildID != "" {
|
|
var cacheError error
|
|
guild, cacheError = window.session.State.Guild(channel.GuildID)
|
|
if cacheError != nil {
|
|
return cacheError
|
|
}
|
|
}
|
|
|
|
messages, loadError := window.messageLoader.LoadMessages(channel)
|
|
if loadError != nil {
|
|
return loadError
|
|
}
|
|
discordutil.SortMessagesByTimestamp(messages)
|
|
|
|
window.selectedChannel = channel
|
|
//This happens before setting the messages into the view, to avoid
|
|
//incorrectly drawing the date separators initially.
|
|
window.updateUserList()
|
|
|
|
//FIXME Only slow function here. 200-500 MS depending on content.
|
|
//That's horrible!
|
|
window.chatView.SetMessages(messages)
|
|
window.chatView.internalTextView.ScrollToEnd()
|
|
window.UpdateChatHeader(channel)
|
|
|
|
if channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM {
|
|
window.privateList.MarkAsLoaded(channel.ID)
|
|
} else {
|
|
window.channelTree.MarkAsLoaded(channel.ID)
|
|
}
|
|
|
|
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 guild otherwise.
|
|
if guild != nil {
|
|
window.app.QueueUpdateDraw(func() {
|
|
//Since we are in a background thread, we assume that the
|
|
//selected guild could have technically changed in the meantime.
|
|
selectedGuild := window.selectedGuild
|
|
window.updateServerReadStatus(channel.GuildID, selectedGuild != nil && window.selectedGuild.ID == channel.GuildID)
|
|
})
|
|
}
|
|
}()
|
|
|
|
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 {
|
|
window.chatView.SetTitle("")
|
|
return
|
|
}
|
|
|
|
if channel.GuildID != "" {
|
|
if channel.Topic != "" {
|
|
window.chatView.SetTitle(channel.Name + " - " + channel.Topic)
|
|
} else {
|
|
window.chatView.SetTitle(channel.Name)
|
|
}
|
|
} 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 a fullscreen 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 {
|
|
return PrompSecretSingleLineInput(window.app, title, message)
|
|
}
|
|
|
|
// 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()
|
|
}
|