cordless/ui/chatview.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()
}
}