mirror of https://github.com/Bios-Marcel/cordless
1080 lines
34 KiB
Go
1080 lines
34 KiB
Go
package ui
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
linkshortener "github.com/Bios-Marcel/shortnotforlong"
|
|
tcell "github.com/gdamore/tcell/v2"
|
|
|
|
"github.com/Bios-Marcel/cordless/config"
|
|
"github.com/Bios-Marcel/cordless/discordutil"
|
|
"github.com/Bios-Marcel/cordless/shortcuts"
|
|
"github.com/Bios-Marcel/cordless/times"
|
|
"github.com/Bios-Marcel/cordless/ui/tviewutil"
|
|
|
|
"github.com/Bios-Marcel/cordless/tview"
|
|
"github.com/Bios-Marcel/discordgo"
|
|
|
|
// Blank import for initializing the tview formatter
|
|
_ "github.com/Bios-Marcel/cordless/syntax"
|
|
|
|
"github.com/alecthomas/chroma"
|
|
"github.com/alecthomas/chroma/formatters"
|
|
"github.com/alecthomas/chroma/lexers"
|
|
"github.com/alecthomas/chroma/styles"
|
|
)
|
|
|
|
const dashCharacter = "\u2500"
|
|
|
|
// embedTimestampFormat represents the format for times used when
|
|
// rendering embeds.
|
|
const embedTimestampFormat = "2006-01-02 15:04"
|
|
|
|
var (
|
|
successiveCustomEmojiRegex = regexp.MustCompile("<a?:.+?:\\d+(><)a?:.+?:\\d+>")
|
|
customEmojiRegex = regexp.MustCompile("(?sm)(.?)<(a?):(.+?):(\\d+)>(.?)")
|
|
codeBlockRegex = regexp.MustCompile("(?sm)(^|.)?(\x60\x60\x60(.*?)?\n(.+?)\x60\x60\x60)($|.)")
|
|
colorRegex = regexp.MustCompile("\\[#.{6}\\]")
|
|
channelMentionRegex = regexp.MustCompile(`<#\d*>`)
|
|
urlRegex = regexp.MustCompile(`<?(https?://)(.+?)(/.+?)?($|\s|\||>)`)
|
|
spoilerRegex = regexp.MustCompile(`(?s)\|\|(.+?)\|\|`)
|
|
roleMentionRegex = regexp.MustCompile(`<@&\d*>`)
|
|
)
|
|
|
|
// ChatView is using a tview.TextView in order to be able to display messages
|
|
// in a simple way. It supports highlighting specific element types and it
|
|
// also supports multiline.
|
|
type ChatView struct {
|
|
*sync.Mutex
|
|
|
|
internalTextView *tview.TextView
|
|
|
|
shortener *linkshortener.Shortener
|
|
|
|
state *discordgo.State
|
|
data []*discordgo.Message
|
|
bufferSize int
|
|
ownUserID string
|
|
format string
|
|
|
|
shortenLinks bool
|
|
shortenWithExtension bool
|
|
|
|
selection int
|
|
selectionMode bool
|
|
|
|
showSpoilerContent map[string]bool
|
|
formattedMessages map[string]string
|
|
|
|
onMessageAction func(message *discordgo.Message, event *tcell.EventKey) *tcell.EventKey
|
|
}
|
|
|
|
// NewChatView constructs a new ready to use ChatView.
|
|
func NewChatView(state *discordgo.State, ownUserID string) *ChatView {
|
|
chatView := ChatView{
|
|
data: make([]*discordgo.Message, 0, 100),
|
|
internalTextView: tview.NewTextView(),
|
|
state: state,
|
|
ownUserID: ownUserID,
|
|
//Magic date which defines the format in which all dates will be formatted.
|
|
//While it isn't obvious which one is month and which one is day, this is
|
|
//is still "correctly" inferred as "year-month-day".
|
|
format: "2006-01-02",
|
|
selection: -1,
|
|
bufferSize: 100,
|
|
selectionMode: false,
|
|
showSpoilerContent: make(map[string]bool),
|
|
shortenLinks: config.Current.ShortenLinks,
|
|
shortenWithExtension: config.Current.ShortenWithExtension,
|
|
formattedMessages: make(map[string]string),
|
|
Mutex: &sync.Mutex{},
|
|
}
|
|
|
|
if chatView.shortenLinks {
|
|
chatView.shortener = linkshortener.NewShortener(config.Current.ShortenerPort)
|
|
go func() {
|
|
shortenerError := chatView.shortener.Start()
|
|
if shortenerError != nil {
|
|
//Disable shortening in case of start failure.
|
|
chatView.shortenLinks = false
|
|
}
|
|
}()
|
|
}
|
|
|
|
chatView.internalTextView.SetOnBlur(func() {
|
|
chatView.selectionMode = false
|
|
chatView.ClearSelection()
|
|
})
|
|
|
|
chatView.internalTextView.SetOnFocus(func() {
|
|
chatView.selectionMode = true
|
|
})
|
|
|
|
chatView.internalTextView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
if chatView.selectionMode && event.Modifiers() == tcell.ModNone {
|
|
if shortcuts.ChatViewSelectionUp.Equals(event) {
|
|
if chatView.selection == -1 {
|
|
chatView.selection = len(chatView.data) - 1
|
|
} else if chatView.selection >= 1 {
|
|
chatView.selection--
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
chatView.refreshSelectionAndScrollToSelection()
|
|
|
|
return nil
|
|
}
|
|
|
|
if shortcuts.ChatViewSelectionDown.Equals(event) {
|
|
if chatView.selection == -1 {
|
|
chatView.selection = 0
|
|
} else if chatView.selection <= len(chatView.data)-2 {
|
|
chatView.selection++
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
chatView.refreshSelectionAndScrollToSelection()
|
|
|
|
return nil
|
|
}
|
|
|
|
if shortcuts.ChatViewSelectionTop.Equals(event) {
|
|
if chatView.selection != 0 {
|
|
chatView.selection = 0
|
|
|
|
chatView.refreshSelectionAndScrollToSelection()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if shortcuts.ChatViewSelectionBottom.Equals(event) {
|
|
if chatView.selection != len(chatView.data)-1 {
|
|
chatView.selection = len(chatView.data) - 1
|
|
|
|
chatView.refreshSelectionAndScrollToSelection()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if chatView.selection > 0 && chatView.selection < len(chatView.data) &&
|
|
shortcuts.ToggleSelectedMessageSpoilers.Equals(event) {
|
|
message := chatView.data[chatView.selection]
|
|
messageID := message.ID
|
|
currentValue, contains := chatView.showSpoilerContent[messageID]
|
|
if contains {
|
|
chatView.showSpoilerContent[messageID] = !currentValue
|
|
} else {
|
|
chatView.showSpoilerContent[messageID] = true
|
|
}
|
|
chatView.formattedMessages[messageID] = chatView.formatMessage(message)
|
|
chatView.Reprint()
|
|
return nil
|
|
}
|
|
|
|
if chatView.selection >= 0 && chatView.selection < len(chatView.data) && chatView.onMessageAction != nil {
|
|
return chatView.onMessageAction(chatView.data[chatView.selection], event)
|
|
}
|
|
}
|
|
|
|
return event
|
|
})
|
|
|
|
chatView.internalTextView.
|
|
SetDynamicColors(true).
|
|
SetRegions(true).
|
|
SetWordWrap(true).
|
|
SetIndicateOverflow(true).
|
|
SetBorder(true).
|
|
SetTitleColor(config.GetTheme().InverseTextColor)
|
|
|
|
return &chatView
|
|
}
|
|
|
|
// SetTitle sets the border text of the chatview.
|
|
func (chatView *ChatView) SetTitle(text string) {
|
|
chatView.internalTextView.SetTitle(text)
|
|
}
|
|
|
|
// SetOnMessageAction sets the handler that will get called if the user tries
|
|
// to interact with a selected message.
|
|
func (chatView *ChatView) SetOnMessageAction(onMessageAction func(message *discordgo.Message, event *tcell.EventKey) *tcell.EventKey) {
|
|
chatView.onMessageAction = onMessageAction
|
|
}
|
|
|
|
func intToString(value int) string {
|
|
return strconv.FormatInt(int64(value), 10)
|
|
}
|
|
|
|
func (chatView *ChatView) refreshSelectionAndScrollToSelection() {
|
|
if chatView.selection == -1 {
|
|
//Empty basically clears the highlights
|
|
chatView.internalTextView.Highlight("")
|
|
} else {
|
|
chatView.internalTextView.Highlight(intToString(chatView.selection))
|
|
chatView.internalTextView.ScrollToHighlight()
|
|
}
|
|
}
|
|
|
|
// GetPrimitive returns the component that can be added to a layout, since
|
|
// the ChatView itself is not a component.
|
|
func (chatView *ChatView) GetPrimitive() tview.Primitive {
|
|
return chatView.internalTextView
|
|
}
|
|
|
|
// UpdateMessage reformats the passed message, updates the cache and triggers
|
|
// a reprint.
|
|
func (chatView *ChatView) UpdateMessage(updatedMessage *discordgo.Message) {
|
|
for _, message := range chatView.data {
|
|
if message.ID == updatedMessage.ID {
|
|
chatView.formattedMessages[updatedMessage.ID] = chatView.formatMessage(updatedMessage)
|
|
chatView.Reprint()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// DeleteMessage drops the message from the cache and triggers a reprint
|
|
func (chatView *ChatView) DeleteMessage(deletedMessage *discordgo.Message) {
|
|
delete(chatView.showSpoilerContent, deletedMessage.ID)
|
|
delete(chatView.formattedMessages, deletedMessage.ID)
|
|
|
|
for index, message := range chatView.data {
|
|
if message.ID == deletedMessage.ID {
|
|
chatView.data = append(chatView.data[:index], chatView.data[index+1:]...)
|
|
chatView.Reprint()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// DeleteMessages drops the messages from the cache and triggers a reprint
|
|
func (chatView *ChatView) DeleteMessages(deletedMessages []string) {
|
|
for _, message := range deletedMessages {
|
|
delete(chatView.showSpoilerContent, message)
|
|
delete(chatView.formattedMessages, message)
|
|
}
|
|
|
|
filteredMessages := make([]*discordgo.Message, 0, len(chatView.data)-len(deletedMessages))
|
|
OUTER_LOOP:
|
|
for _, message := range chatView.data {
|
|
for _, toDelete := range deletedMessages {
|
|
if toDelete == message.ID {
|
|
continue OUTER_LOOP
|
|
}
|
|
}
|
|
|
|
filteredMessages = append(filteredMessages, message)
|
|
}
|
|
|
|
if len(chatView.data) != len(filteredMessages) {
|
|
chatView.data = filteredMessages
|
|
chatView.Reprint()
|
|
}
|
|
}
|
|
|
|
// ClearViewAndCache clears the TextView buffer and removes all data for
|
|
// all messages.
|
|
func (chatView *ChatView) ClearViewAndCache() {
|
|
//100 as default size, as we usually have message. Even if not, this
|
|
//is worth the memory overhead.
|
|
chatView.data = make([]*discordgo.Message, 0, 100)
|
|
chatView.showSpoilerContent = make(map[string]bool)
|
|
chatView.formattedMessages = make(map[string]string)
|
|
chatView.selection = -1
|
|
chatView.internalTextView.Clear()
|
|
chatView.SetTitle("")
|
|
}
|
|
|
|
// addMessageInternal prints a new message to the textview or triggers a
|
|
// rerender. It also takes the blocked relation into consideration.
|
|
func (chatView *ChatView) addMessageInternal(message *discordgo.Message) {
|
|
isBlocked := discordutil.IsBlocked(chatView.state, message.Author)
|
|
|
|
if !config.Current.ShowPlaceholderForBlockedMessages && isBlocked {
|
|
return
|
|
}
|
|
|
|
chatFull := len(chatView.data) >= chatView.bufferSize
|
|
if chatFull {
|
|
idToDrop := chatView.data[0].ID
|
|
delete(chatView.showSpoilerContent, idToDrop)
|
|
delete(chatView.formattedMessages, idToDrop)
|
|
chatView.data = append(chatView.data[1:], message)
|
|
|
|
//Moving up the selection, since we have removed the first message. If
|
|
//the previously selected message was the first message, then no
|
|
//message will be selected.
|
|
if chatView.selection > -1 {
|
|
chatView.selection--
|
|
}
|
|
|
|
chatView.refreshSelectionAndScrollToSelection()
|
|
} else {
|
|
chatView.data = append(chatView.data, message)
|
|
}
|
|
|
|
formattedMessage, messageAlreadyFormatted := chatView.formattedMessages[message.ID]
|
|
if !messageAlreadyFormatted {
|
|
if isBlocked {
|
|
formattedMessage = chatView.messagePartsToColouredString(message.Timestamp, "Blocked user", "Blocked message")
|
|
} else {
|
|
formattedMessage = chatView.formatMessage(message)
|
|
}
|
|
chatView.formattedMessages[message.ID] = formattedMessage
|
|
}
|
|
|
|
if chatFull {
|
|
chatView.Reprint()
|
|
} else {
|
|
fmt.Fprint(chatView.internalTextView, "\n[\""+intToString(len(chatView.data)-1)+"\"]"+formattedMessage)
|
|
}
|
|
}
|
|
|
|
//AddMessage add an additional message to the ChatView.
|
|
func (chatView *ChatView) AddMessage(message *discordgo.Message) {
|
|
wasScrolledToTheEnd := chatView.internalTextView.IsScrolledToEnd()
|
|
|
|
newMessageTime, _ := message.Timestamp.Parse()
|
|
newMessageTimeLocal := newMessageTime.Local()
|
|
if len(chatView.data) > 0 {
|
|
previousMessageTime, _ := chatView.data[len(chatView.data)-1].Timestamp.Parse()
|
|
if !times.AreDatesTheSameDay(previousMessageTime.Local(), newMessageTimeLocal) {
|
|
fmt.Fprint(chatView.internalTextView, chatView.createDateDelimiter(newMessageTimeLocal.Format(chatView.format)))
|
|
}
|
|
} else {
|
|
fmt.Fprint(chatView.internalTextView, chatView.createDateDelimiter(newMessageTimeLocal.Format(chatView.format)))
|
|
}
|
|
|
|
chatView.addMessageInternal(message)
|
|
chatView.refreshSelectionAndScrollToSelection()
|
|
if wasScrolledToTheEnd {
|
|
chatView.internalTextView.ScrollToEnd()
|
|
}
|
|
}
|
|
|
|
// createDateDelimiter creates a date delimiter between messages to mark the date and returns it
|
|
func (chatView *ChatView) createDateDelimiter(date string) string {
|
|
_, _, width, _ := chatView.internalTextView.GetInnerRect()
|
|
characterAmountLeftForDashes := width - len(date) - 2 /* Because of the spaces */
|
|
amountDashesLeft := characterAmountLeftForDashes / 2
|
|
dashesLeft := strings.Repeat(dashCharacter, amountDashesLeft)
|
|
dashesRight := strings.Repeat(dashCharacter, characterAmountLeftForDashes-amountDashesLeft)
|
|
return "\n[\"\"]" + dashesLeft + " " + date + " " + dashesRight
|
|
}
|
|
|
|
// createDateDelimiterIfNecessary creates a delimiter in case that the dates
|
|
// between two messages differ.
|
|
func (chatView *ChatView) createDateDelimiterIfNecessary(messages []*discordgo.Message, index int) string {
|
|
messageTime, _ := messages[index].Timestamp.Parse()
|
|
messageTimeLocal := messageTime.Local()
|
|
if index == 0 {
|
|
return chatView.createDateDelimiter(messageTimeLocal.Format(chatView.format))
|
|
}
|
|
|
|
previousMessageTime, _ := messages[index-1].Timestamp.Parse()
|
|
if !times.AreDatesTheSameDay(previousMessageTime.Local(), messageTimeLocal) {
|
|
return chatView.createDateDelimiter(messageTimeLocal.Format(chatView.format))
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// printDateDelimiterIfNecessary prints a date delimiter if the message is the
|
|
// first message or if the dates are different between the current a
|
|
func (chatView *ChatView) printDateDelimiterIfNecessary(messages []*discordgo.Message, index int) {
|
|
delimiter := chatView.createDateDelimiterIfNecessary(messages, index)
|
|
if delimiter != "" {
|
|
fmt.Fprint(chatView.internalTextView, delimiter)
|
|
}
|
|
}
|
|
|
|
// Reprint clears the internal TextView and prints all currently cached
|
|
// messages into the internal TextView again. This will not actually cause a
|
|
// redraw in the user interface. This would still only be done by
|
|
// ForceDraw ,QueueUpdateDraw or user events. Calling this method is
|
|
// necessary if previously added content has changed or has been removed, since
|
|
// can only append to the TextViews buffers, but not cut parts out.
|
|
func (chatView *ChatView) Reprint() {
|
|
var newContent strings.Builder
|
|
for index, message := range chatView.data {
|
|
formattedMessage, contains := chatView.formattedMessages[message.ID]
|
|
//Should always be true, otherwise we got ourselves a bug.
|
|
if contains {
|
|
newContent.WriteString(chatView.createDateDelimiterIfNecessary(chatView.data, index))
|
|
//Next three lines write the message index, which is used for selection.
|
|
newContent.WriteString("\n[\"")
|
|
newContent.WriteString(intToString(index))
|
|
newContent.WriteString("\"]")
|
|
newContent.WriteString(formattedMessage)
|
|
} else {
|
|
panic("Bug in chatview, a message could not be found.")
|
|
}
|
|
}
|
|
chatView.internalTextView.SetText(newContent.String())
|
|
}
|
|
|
|
func (chatView *ChatView) formatMessage(message *discordgo.Message) string {
|
|
return chatView.messagePartsToColouredString(
|
|
message.Timestamp,
|
|
chatView.formatMessageAuthor(message),
|
|
chatView.formatMessageText(message))
|
|
}
|
|
|
|
func (chatView *ChatView) formatMessageAuthor(message *discordgo.Message) string {
|
|
var member *discordgo.Member
|
|
if message.GuildID != "" {
|
|
member, _ = chatView.state.Member(message.GuildID, message.Author.ID)
|
|
}
|
|
|
|
var messageAuthor string
|
|
var userColor string
|
|
if member != nil {
|
|
messageAuthor = discordutil.GetMemberName(member)
|
|
userColor = discordutil.GetMemberColor(chatView.state, member)
|
|
}
|
|
if messageAuthor == "" {
|
|
messageAuthor = discordutil.GetUserName(message.Author)
|
|
userColor = discordutil.GetUserColor(message.Author)
|
|
}
|
|
|
|
return "[::b][" + userColor + "]" + messageAuthor + ":[::-]"
|
|
}
|
|
|
|
func (chatView *ChatView) formatMessageText(message *discordgo.Message) string {
|
|
if message.Type == discordgo.MessageTypeDefault {
|
|
return chatView.formatDefaultMessageText(message)
|
|
} else if message.Type == discordgo.MessageTypeGuildMemberJoin {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]joined the server."
|
|
} else if message.Type == discordgo.MessageTypeCall {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]has started a call."
|
|
} else if message.Type == discordgo.MessageTypeChannelIconChange {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]changed the channel icon."
|
|
} else if message.Type == discordgo.MessageTypeChannelNameChange {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]changed the channel name to " + message.Content + "."
|
|
} else if message.Type == discordgo.MessageTypeChannelPinnedMessage {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]pinned a message."
|
|
} else if message.Type == discordgo.MessageTypeRecipientAdd {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]added " + message.Mentions[0].Username + " to the group."
|
|
} else if message.Type == discordgo.MessageTypeRecipientRemove {
|
|
removedUser := message.Mentions[0]
|
|
if removedUser.ID == message.Author.ID {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]has left the group."
|
|
}
|
|
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]removed " + removedUser.Username + " from the group."
|
|
} else if message.Type == discordgo.MessageTypeChannelFollowAdd {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]has added '" + message.Content + "' to this channel."
|
|
} else if message.Type == discordgo.MessageTypeUserPremiumGuildSubscription ||
|
|
message.Type == discordgo.MessageTypeUserPremiumGuildSubscriptionTierOne ||
|
|
message.Type == discordgo.MessageTypeUserPremiumGuildSubscriptionTierThree ||
|
|
message.Type == discordgo.MessageTypeUserPremiumGuildSubscriptionTierTwo {
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]has boosted this server."
|
|
}
|
|
|
|
//TODO Support boost messages; Would be handy to see what they look like first.
|
|
|
|
//Might happen when there are unsupported types.
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().InfoMessageColor) + "]message couldn't be rendered."
|
|
}
|
|
|
|
func (chatView *ChatView) formatDefaultMessageText(message *discordgo.Message) string {
|
|
messageText := tviewutil.Escape(message.Content)
|
|
|
|
//Message.MentionRoles only contains the mentions for mentionable.
|
|
//Therefore we do it like this, in order to render every mention.
|
|
messageText = roleMentionRegex.
|
|
ReplaceAllStringFunc(messageText, func(data string) string {
|
|
roleID := strings.TrimSuffix(strings.TrimPrefix(data, "<@&"), ">")
|
|
role, cacheError := chatView.state.Role(message.GuildID, roleID)
|
|
if cacheError != nil {
|
|
return data
|
|
}
|
|
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().LinkColor) + "]@" + role.Name + "[" + tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor) + "]"
|
|
})
|
|
|
|
messageText = strings.NewReplacer("@everyone", "["+tviewutil.ColorToHex(config.GetTheme().LinkColor)+"]@everyone["+
|
|
tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor)+"]", "@here",
|
|
"["+tviewutil.ColorToHex(config.GetTheme().LinkColor)+"]@here["+tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor)+"]",
|
|
).Replace(messageText)
|
|
|
|
for _, user := range message.Mentions {
|
|
var userName string
|
|
if message.GuildID != "" {
|
|
member, cacheError := chatView.state.Member(message.GuildID, user.ID)
|
|
if cacheError == nil {
|
|
userName = discordutil.GetMemberName(member)
|
|
}
|
|
}
|
|
|
|
if userName == "" {
|
|
userName = discordutil.GetUserName(user)
|
|
}
|
|
|
|
var color string
|
|
if tview.IsVtxxx {
|
|
if chatView.state.User.ID == user.ID {
|
|
color = "[::r]"
|
|
} else {
|
|
color = "[::b]"
|
|
}
|
|
} else {
|
|
if chatView.state.User.ID == user.ID {
|
|
color = "[" + tviewutil.ColorToHex(config.GetTheme().AttentionColor) + "]"
|
|
} else {
|
|
color = "[" + tviewutil.ColorToHex(config.GetTheme().LinkColor) + "]"
|
|
}
|
|
}
|
|
|
|
var replacement string
|
|
if tview.IsVtxxx {
|
|
replacement = color + "@" + userName + "[::-]"
|
|
} else {
|
|
replacement = color + "@" + userName + "[" + tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor) + "]"
|
|
}
|
|
messageText = strings.NewReplacer(
|
|
"<@"+user.ID+">", replacement,
|
|
"<@!"+user.ID+">", replacement,
|
|
).Replace(messageText)
|
|
}
|
|
|
|
messageText = channelMentionRegex.
|
|
ReplaceAllStringFunc(messageText, func(data string) string {
|
|
channelID := strings.TrimSuffix(strings.TrimPrefix(data, "<#"), ">")
|
|
channel, cacheError := chatView.state.Channel(channelID)
|
|
if cacheError != nil {
|
|
return data
|
|
}
|
|
|
|
return "[" + tviewutil.ColorToHex(config.GetTheme().LinkColor) + "]#" + channel.Name + "[" + tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor) + "]"
|
|
})
|
|
|
|
// FIXME Needs improvement, as it wastes space and breaks things
|
|
if message.Attachments != nil && len(message.Attachments) > 0 {
|
|
var attachments []string
|
|
for _, attachment := range message.Attachments {
|
|
attachments = append(attachments, attachment.URL)
|
|
}
|
|
attachmentsAsText := strings.Join(attachments, " ")
|
|
|
|
if messageText != "" {
|
|
messageText = messageText + "\n" + attachmentsAsText
|
|
} else {
|
|
messageText = attachmentsAsText
|
|
}
|
|
}
|
|
|
|
// FIXME Handle Non-embed links nonetheless?
|
|
if chatView.shortenLinks {
|
|
urlMatches := urlRegex.FindAllStringSubmatch(messageText, 1000)
|
|
|
|
for _, urlMatch := range urlMatches {
|
|
//Protocol+domain
|
|
domain := urlMatch[2]
|
|
newURL := urlMatch[1] + domain
|
|
|
|
if len(urlMatch) == 5 || (len(urlMatch) == 4 && len(urlMatch[3]) > 1) {
|
|
newURL = newURL + urlMatch[3]
|
|
}
|
|
|
|
// We only actually shorten the link if it would turn out shorter
|
|
lengthURL, lengthSuffix := chatView.shortener.CalculateShortenedLength(newURL)
|
|
if chatView.shortenWithExtension && (len(domain)+3+lengthURL+lengthSuffix) < len(newURL) {
|
|
url, suffix := chatView.shortener.Shorten(newURL)
|
|
newURL = fmt.Sprintf("(%s) %s%s", domain, url, suffix)
|
|
} else if (len(domain) + 3 + lengthURL) < len(newURL) {
|
|
url, _ := chatView.shortener.Shorten(newURL)
|
|
newURL = fmt.Sprintf("(%s) %s", domain, url)
|
|
}
|
|
|
|
//Fifth group is either newline embed or a greater than sign. We don't want
|
|
//to remove the spaces, but the greater sign is part of a non-embed-link, so
|
|
//we don't really care about that one.
|
|
if len(urlMatch) == 5 {
|
|
newURL = newURL + strings.TrimSuffix(urlMatch[4], ">")
|
|
}
|
|
|
|
//Replace whole url match with the shortened version or at least the one without
|
|
//the useless non-embed-link markers.
|
|
messageText = strings.Replace(messageText, urlMatch[0], newURL, 1)
|
|
}
|
|
}
|
|
|
|
codeBlocks := codeBlockRegex.
|
|
// Magicnumber, because message aren't gonna be that long anyway.
|
|
FindAllStringSubmatch(messageText, 1000)
|
|
|
|
for _, values := range codeBlocks {
|
|
language := values[3]
|
|
code := values[4]
|
|
|
|
//Remove all carriage returns to prevent bugs with windows newlines.
|
|
code = strings.ReplaceAll(code, "\r", "")
|
|
//Remove last newline, as it's usually just the newline that separates code from markdown notation.
|
|
code = strings.TrimSuffix(code, "\n")
|
|
code = removeLeadingWhitespaceInCode(code)
|
|
|
|
// Determine lexer.
|
|
l := lexers.Get(language)
|
|
if l == nil {
|
|
l = lexers.Fallback
|
|
}
|
|
l = chroma.Coalesce(l)
|
|
|
|
// Determine formatter.
|
|
f := formatters.Get("tview-8bit")
|
|
if f == nil {
|
|
f = formatters.Fallback
|
|
}
|
|
|
|
// Determine style.
|
|
s := styles.Get("monokai")
|
|
if s == nil {
|
|
s = styles.Fallback
|
|
}
|
|
|
|
it, tokeniseError := l.Tokenise(nil, code)
|
|
if tokeniseError != nil {
|
|
continue
|
|
}
|
|
|
|
writer := bytes.NewBufferString("")
|
|
|
|
formatError := f.Format(writer, s, it)
|
|
if formatError != nil {
|
|
continue
|
|
}
|
|
|
|
//Remove the last newline, as some formatters behave differently and don't drop it.
|
|
escapedCode := strings.NewReplacer("*", "\\*", "_", "\\_", "|", "\\|").Replace(writer.String())
|
|
newLineDifference := strings.Count(escapedCode, "\n") - strings.Count(code, "\n")
|
|
if newLineDifference > 0 {
|
|
for ; newLineDifference != 0; newLineDifference-- {
|
|
escapedCode = escapedCode[:(strings.LastIndex(escapedCode, "\n"))]
|
|
}
|
|
}
|
|
var formattedCode, lastColor string
|
|
lines := strings.Split(escapedCode, "\n")
|
|
for index, line := range lines {
|
|
if index != 0 {
|
|
formattedCode += "\n"
|
|
colorCodes := colorRegex.FindAllString(lines[index-1], -1)
|
|
if len(colorCodes) > 0 {
|
|
lastColor = colorCodes[len(colorCodes)-1]
|
|
}
|
|
|
|
if lastColor != "" {
|
|
formattedCode += fmt.Sprintf("[#c9dddc]▐ %s%s", lastColor, line)
|
|
continue
|
|
}
|
|
}
|
|
|
|
formattedCode += "[#c9dddc]▐ " + line
|
|
}
|
|
|
|
beforeCodeBlock := values[1]
|
|
if beforeCodeBlock != "\n" {
|
|
formattedCode = "\n" + formattedCode
|
|
}
|
|
afterCodeBlock := values[5]
|
|
if len(afterCodeBlock) != 0 && afterCodeBlock != "\n" {
|
|
formattedCode = formattedCode + "\n"
|
|
}
|
|
|
|
messageText = strings.Replace(messageText, values[2], formattedCode, 1)
|
|
}
|
|
|
|
messageText = strings.
|
|
NewReplacer("\\*", "*", "\\_", "_", "\\`", "`").
|
|
Replace(parseBoldAndUnderline(messageText))
|
|
messageText = parseCustomEmojis(messageText)
|
|
|
|
shouldShow, contains := chatView.showSpoilerContent[message.ID]
|
|
if !contains || !shouldShow {
|
|
messageText = spoilerRegex.ReplaceAllString(messageText, "["+tviewutil.ColorToHex(config.GetTheme().AttentionColor)+"]!SPOILER!["+tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor)+"]")
|
|
}
|
|
messageText = strings.Replace(messageText, "\\|", "|", -1)
|
|
|
|
var hasRichEmbed bool
|
|
for _, embed := range message.Embeds {
|
|
if embed.Type == "rich" {
|
|
hasRichEmbed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
var reactionText string
|
|
if len(message.Reactions) > 0 {
|
|
var reactionBuilder strings.Builder
|
|
reactionBuilder.Grow(10 + len(message.Reactions)*8)
|
|
reactionBuilder.WriteString("\nReactions: ")
|
|
for rIndex, reaction := range message.Reactions {
|
|
if reaction.Emoji.Name != "" {
|
|
reactionBuilder.WriteString(tviewutil.Escape(reaction.Emoji.Name))
|
|
if reaction.Me {
|
|
reactionBuilder.WriteString("[::r]")
|
|
}
|
|
reactionBuilder.WriteRune('-')
|
|
reactionBuilder.WriteString(strconv.FormatInt(int64(reaction.Count), 10))
|
|
if reaction.Me {
|
|
reactionBuilder.WriteString("[::-]")
|
|
}
|
|
if rIndex != len(message.Reactions)-1 {
|
|
reactionBuilder.WriteRune(' ')
|
|
}
|
|
}
|
|
}
|
|
reactionText = reactionBuilder.String()
|
|
}
|
|
|
|
if !hasRichEmbed {
|
|
return messageText + reactionText
|
|
}
|
|
|
|
var messageBuffer strings.Builder
|
|
messageBuffer.WriteString(messageText)
|
|
messageBuffer.WriteRune('\n')
|
|
|
|
defaultColor := tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor)
|
|
|
|
for _, embed := range message.Embeds {
|
|
if embed.Type != "rich" {
|
|
continue
|
|
}
|
|
|
|
var embedBuffer strings.Builder
|
|
color := fmt.Sprintf("[#%06x]", embed.Color)
|
|
embedBuffer.WriteString(color)
|
|
embedBuffer.WriteString("▐ [")
|
|
embedBuffer.WriteString(defaultColor)
|
|
embedBuffer.WriteRune(']')
|
|
|
|
var hasHeading bool
|
|
|
|
if embed.Author != nil {
|
|
hasHeading = true
|
|
embedBuffer.WriteString("**")
|
|
embedBuffer.WriteString(embed.Author.Name)
|
|
embedBuffer.WriteString("**")
|
|
}
|
|
|
|
if embed.Title != "" {
|
|
hasHeading = true
|
|
if embed.Author != nil {
|
|
embedBuffer.WriteString(" - __")
|
|
embedBuffer.WriteString(embed.Title)
|
|
embedBuffer.WriteString("__")
|
|
} else {
|
|
embedBuffer.WriteString("__")
|
|
embedBuffer.WriteString(embed.Title)
|
|
embedBuffer.WriteString("__")
|
|
}
|
|
}
|
|
|
|
if embed.Description != "" {
|
|
if hasHeading {
|
|
embedBuffer.WriteString("\n\n")
|
|
}
|
|
embedBuffer.WriteString(embed.Description)
|
|
}
|
|
|
|
//FIXME Might be able to improve this in order to use horizontal space more efficiently.
|
|
if len(embed.Fields) > 0 {
|
|
if hasHeading || embed.Description != "" {
|
|
embedBuffer.WriteRune('\n')
|
|
}
|
|
|
|
for index, field := range embed.Fields {
|
|
embedBuffer.WriteString("__")
|
|
embedBuffer.WriteString(field.Name)
|
|
embedBuffer.WriteString("__")
|
|
embedBuffer.WriteRune('\n')
|
|
embedBuffer.WriteString(field.Value)
|
|
|
|
if index != len(embed.Fields)-1 {
|
|
embedBuffer.WriteString("\n\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
hasFooter := embed.Footer != nil && embed.Footer.Text != ""
|
|
if hasFooter {
|
|
//Since there's always either fields or description, we always want newlines.
|
|
embedBuffer.WriteString("\n\n")
|
|
embedBuffer.WriteString(embed.Footer.Text)
|
|
}
|
|
|
|
if embed.Timestamp != "" {
|
|
parsedTimestamp, err := discordgo.Timestamp(embed.Timestamp).Parse()
|
|
if err == nil {
|
|
if hasFooter {
|
|
embedBuffer.WriteString(" - ")
|
|
}
|
|
localTime := parsedTimestamp.Local()
|
|
embedBuffer.WriteString(localTime.Format(embedTimestampFormat))
|
|
} else {
|
|
log.Println("Error parsing time: " + err.Error())
|
|
}
|
|
}
|
|
|
|
messageBuffer.WriteString(strings.Replace(parseBoldAndUnderline(embedBuffer.String()), "\n", "\n"+color+"▐["+defaultColor+"] ", -1))
|
|
embedBuffer.WriteRune('\n')
|
|
}
|
|
|
|
return messageBuffer.String() + reactionText
|
|
}
|
|
|
|
func parseCustomEmojis(text string) string {
|
|
messageText := text
|
|
|
|
//Little hack, since the customEmojiRegex can't handle <:emoji:123><:emoji:123>
|
|
//And we do it this way in order to allow overlapping matches.
|
|
var matches [][]int
|
|
for resume := true; resume; resume = len(matches) > 0 {
|
|
matches = successiveCustomEmojiRegex.FindAllStringSubmatchIndex(messageText, -1)
|
|
//Iterating backwards, since the indexes would be incorrect after
|
|
//the first match otherwise
|
|
for i := len(matches) - 1; i >= 0; i-- {
|
|
match := matches[i]
|
|
messageText = messageText[:match[2]+1] + " " + messageText[match[3]-1:]
|
|
}
|
|
}
|
|
|
|
customEmojiMatches := customEmojiRegex.FindAllStringSubmatch(messageText, -1)
|
|
for _, match := range customEmojiMatches {
|
|
customEmojiCode := match[3]
|
|
if len(match[2]) > 0 {
|
|
customEmojiCode = "a:" + customEmojiCode
|
|
}
|
|
url := tviewutil.Escape("[" + customEmojiCode + "]( https://cdn.discordapp.com/emojis/" + match[4] + " )")
|
|
if match[1] != "" && match[1] != "\n" {
|
|
url = match[1] + "\n" + url
|
|
}
|
|
if match[5] != "" && match[5] != "\n" {
|
|
url = url + "\n" + match[5]
|
|
}
|
|
messageText = strings.Replace(messageText, match[0], url, 1)
|
|
}
|
|
|
|
return messageText
|
|
}
|
|
|
|
func trimMinAmountOfCharacterAsPrefix(charToTrim rune, text string) (string, int) {
|
|
lines := strings.Split(text, "\n")
|
|
minAmountOfCharacter := math.MaxInt32
|
|
for _, line := range lines {
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
|
|
amountOfCharacters := 0
|
|
for _, character := range []rune(line) {
|
|
if character != charToTrim {
|
|
break
|
|
}
|
|
|
|
amountOfCharacters++
|
|
}
|
|
|
|
if amountOfCharacters < minAmountOfCharacter {
|
|
minAmountOfCharacter = amountOfCharacters
|
|
}
|
|
|
|
if amountOfCharacters == 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if minAmountOfCharacter > 0 {
|
|
toTrim := strings.Repeat(string(charToTrim), minAmountOfCharacter)
|
|
for index, line := range lines {
|
|
lines[index] = strings.TrimPrefix(line, toTrim)
|
|
}
|
|
|
|
return strings.Join(lines, "\n"), minAmountOfCharacter
|
|
}
|
|
|
|
return text, 0
|
|
}
|
|
|
|
func removeLeadingWhitespaceInCode(code string) string {
|
|
spacesTrimmed, amountTrimmed := trimMinAmountOfCharacterAsPrefix(' ', code)
|
|
if amountTrimmed > 0 {
|
|
return spacesTrimmed
|
|
}
|
|
|
|
tabsTrimmed, _ := trimMinAmountOfCharacterAsPrefix(' ', code)
|
|
return tabsTrimmed
|
|
}
|
|
|
|
func (chatView *ChatView) messagePartsToColouredString(timestamp discordgo.Timestamp, author, message string) string {
|
|
time, parseError := timestamp.Parse()
|
|
var timeCellText string
|
|
if parseError == nil {
|
|
timeCellText = times.TimeToLocalString(&time)
|
|
}
|
|
|
|
return fmt.Sprintf("["+tviewutil.ColorToHex(config.GetTheme().MessageTimeColor)+"]%s %s ["+tviewutil.ColorToHex(config.GetTheme().PrimaryTextColor)+"]%s[\"\"][\"\"]", timeCellText, author, message)
|
|
}
|
|
|
|
func parseBoldAndUnderline(messageText string) string {
|
|
runes := []rune(messageText)
|
|
messageTextTemp := make([]rune, 0, len(runes)+20)
|
|
|
|
firstBoldFound := false
|
|
boldOpen := false
|
|
firstUnderlineFound := false
|
|
underlineOpen := false
|
|
|
|
lastIndex := len(runes) - 1
|
|
for index, character := range runes {
|
|
messageTextTemp = append(messageTextTemp, character)
|
|
if character == '\n' {
|
|
if boldOpen && !underlineOpen {
|
|
messageTextTemp = append(messageTextTemp, '[', ':', ':', 'b', ']')
|
|
} else if !boldOpen && underlineOpen {
|
|
messageTextTemp = append(messageTextTemp, '[', ':', ':', 'u', ']')
|
|
} else if boldOpen && underlineOpen {
|
|
messageTextTemp = append(messageTextTemp, '[', ':', ':', 'u', 'b', ']')
|
|
}
|
|
} else if character == '*' {
|
|
if firstBoldFound {
|
|
firstBoldFound = false
|
|
if boldOpen {
|
|
boldOpen = false
|
|
messageTextTemp[len(messageTextTemp)-2] = '['
|
|
messageTextTemp[len(messageTextTemp)-1] = ':'
|
|
if underlineOpen {
|
|
messageTextTemp = append(messageTextTemp, ':', 'u', ']')
|
|
} else {
|
|
messageTextTemp = append(messageTextTemp, ':', '-', ']')
|
|
}
|
|
} else if index != lastIndex {
|
|
doesClosingOneExist := false
|
|
foundFirstClosing := false
|
|
for _, c := range runes[index+1:] {
|
|
if c == '*' {
|
|
if foundFirstClosing {
|
|
doesClosingOneExist = true
|
|
break
|
|
}
|
|
foundFirstClosing = true
|
|
}
|
|
}
|
|
|
|
if doesClosingOneExist {
|
|
if runes[index+1] == '*' {
|
|
firstBoldFound = true
|
|
continue
|
|
}
|
|
|
|
boldOpen = true
|
|
messageTextTemp[len(messageTextTemp)-2] = '['
|
|
messageTextTemp[len(messageTextTemp)-1] = ':'
|
|
messageTextTemp = append(messageTextTemp, ':')
|
|
if underlineOpen {
|
|
messageTextTemp = append(messageTextTemp, 'u')
|
|
}
|
|
messageTextTemp = append(messageTextTemp, 'b', ']')
|
|
}
|
|
}
|
|
} else {
|
|
firstBoldFound = true
|
|
}
|
|
} else if character == '_' {
|
|
if firstUnderlineFound {
|
|
firstUnderlineFound = false
|
|
if underlineOpen {
|
|
underlineOpen = false
|
|
messageTextTemp[len(messageTextTemp)-2] = '['
|
|
messageTextTemp[len(messageTextTemp)-1] = ':'
|
|
if boldOpen {
|
|
messageTextTemp = append(messageTextTemp, ':', 'b', ']')
|
|
} else {
|
|
messageTextTemp = append(messageTextTemp, ':', '-', ']')
|
|
}
|
|
} else if index != lastIndex {
|
|
doesClosingOneExist := false
|
|
foundFirstClosing := false
|
|
for _, c := range runes[index+1:] {
|
|
if c == '_' {
|
|
if foundFirstClosing {
|
|
doesClosingOneExist = true
|
|
break
|
|
}
|
|
foundFirstClosing = true
|
|
}
|
|
}
|
|
|
|
if doesClosingOneExist {
|
|
if runes[index+1] == '_' {
|
|
firstUnderlineFound = true
|
|
continue
|
|
}
|
|
|
|
underlineOpen = true
|
|
messageTextTemp[len(messageTextTemp)-2] = '['
|
|
messageTextTemp[len(messageTextTemp)-1] = ':'
|
|
messageTextTemp = append(messageTextTemp, ':')
|
|
if boldOpen {
|
|
messageTextTemp = append(messageTextTemp, 'b')
|
|
}
|
|
messageTextTemp = append(messageTextTemp, 'u', ']')
|
|
}
|
|
}
|
|
} else {
|
|
firstUnderlineFound = true
|
|
}
|
|
} else {
|
|
firstBoldFound = false
|
|
firstUnderlineFound = false
|
|
}
|
|
}
|
|
|
|
return string(messageTextTemp)
|
|
}
|
|
|
|
// ClearSelection clears the current selection of messages.
|
|
func (chatView *ChatView) ClearSelection() {
|
|
chatView.selection = -1
|
|
chatView.refreshSelectionAndScrollToSelection()
|
|
}
|
|
|
|
// SignalSelectionDeleted notifies the ChatView that its currently selected
|
|
// message doesn't exist anymore, moving the selection up by a row if possible.
|
|
func (chatView *ChatView) SignalSelectionDeleted() {
|
|
if chatView.selection > 0 {
|
|
chatView.selection--
|
|
}
|
|
}
|
|
|
|
// SetMessages defines all currently displayed messages. Parsing and
|
|
// manipulation of single message elements happens in this function.
|
|
func (chatView *ChatView) SetMessages(messages []*discordgo.Message) {
|
|
chatView.data = make([]*discordgo.Message, 0, len(messages))
|
|
chatView.internalTextView.Clear()
|
|
|
|
wasScrolledToTheEnd := chatView.internalTextView.IsScrolledToEnd()
|
|
|
|
for index, message := range messages {
|
|
chatView.printDateDelimiterIfNecessary(messages, index)
|
|
chatView.addMessageInternal(message)
|
|
}
|
|
|
|
chatView.refreshSelectionAndScrollToSelection()
|
|
if wasScrolledToTheEnd {
|
|
chatView.internalTextView.ScrollToEnd()
|
|
}
|
|
}
|