|
@@ -46,6 +46,12 @@ type messagesList struct {
|
|
|
|
|
|
|
|
attachmentsPicker *attachmentsPicker
|
|
attachmentsPicker *attachmentsPicker
|
|
|
|
|
|
|
|
|
|
+ timestampsHidden bool
|
|
|
|
|
+ repliesCollapsed bool
|
|
|
|
|
+ expandedReplies map[discord.MessageID]struct{}
|
|
|
|
|
+
|
|
|
|
|
+ lastWidth int
|
|
|
|
|
+
|
|
|
fetchingMembers struct {
|
|
fetchingMembers struct {
|
|
|
mu sync.Mutex
|
|
mu sync.Mutex
|
|
|
value bool
|
|
value bool
|
|
@@ -71,11 +77,12 @@ type messagesListRow struct {
|
|
|
|
|
|
|
|
func newMessagesList(cfg *config.Config, chatView *Model) *messagesList {
|
|
func newMessagesList(cfg *config.Config, chatView *Model) *messagesList {
|
|
|
ml := &messagesList{
|
|
ml := &messagesList{
|
|
|
- Model: list.NewModel(),
|
|
|
|
|
- cfg: cfg,
|
|
|
|
|
- chatView: chatView,
|
|
|
|
|
- renderer: markdown.NewRenderer(cfg),
|
|
|
|
|
- itemByID: make(map[discord.MessageID]*tview.TextView),
|
|
|
|
|
|
|
+ Model: list.NewModel(),
|
|
|
|
|
+ cfg: cfg,
|
|
|
|
|
+ chatView: chatView,
|
|
|
|
|
+ renderer: markdown.NewRenderer(cfg),
|
|
|
|
|
+ itemByID: make(map[discord.MessageID]*tview.TextView),
|
|
|
|
|
+ expandedReplies: make(map[discord.MessageID]struct{}),
|
|
|
}
|
|
}
|
|
|
ml.attachmentsPicker = newAttachmentsPicker(cfg, chatView)
|
|
ml.attachmentsPicker = newAttachmentsPicker(cfg, chatView)
|
|
|
|
|
|
|
@@ -103,6 +110,7 @@ func (ml *messagesList) reset() {
|
|
|
ml.rows = nil
|
|
ml.rows = nil
|
|
|
ml.rowsDirty = false
|
|
ml.rowsDirty = false
|
|
|
clear(ml.itemByID)
|
|
clear(ml.itemByID)
|
|
|
|
|
+ clear(ml.expandedReplies)
|
|
|
ml.
|
|
ml.
|
|
|
Clear().
|
|
Clear().
|
|
|
SetBuilder(ml.buildItem).
|
|
SetBuilder(ml.buildItem).
|
|
@@ -158,9 +166,18 @@ func (ml *messagesList) clearSelection() {
|
|
|
ml.SetCursor(-1)
|
|
ml.SetCursor(-1)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const wrapIndent = 2
|
|
|
|
|
+
|
|
|
func (ml *messagesList) buildItem(index int, cursor int) list.Item {
|
|
func (ml *messagesList) buildItem(index int, cursor int) list.Item {
|
|
|
ml.ensureRows()
|
|
ml.ensureRows()
|
|
|
|
|
|
|
|
|
|
+ // Invalidate cache when viewport width changes.
|
|
|
|
|
+ _, _, innerWidth, _ := ml.InnerRect()
|
|
|
|
|
+ if ml.lastWidth != 0 && ml.lastWidth != innerWidth {
|
|
|
|
|
+ clear(ml.itemByID)
|
|
|
|
|
+ }
|
|
|
|
|
+ ml.lastWidth = innerWidth
|
|
|
|
|
+
|
|
|
if index < 0 || index >= len(ml.rows) {
|
|
if index < 0 || index >= len(ml.rows) {
|
|
|
return nil
|
|
return nil
|
|
|
}
|
|
}
|
|
@@ -172,18 +189,18 @@ func (ml *messagesList) buildItem(index int, cursor int) list.Item {
|
|
|
|
|
|
|
|
message := ml.messages[row.messageIndex]
|
|
message := ml.messages[row.messageIndex]
|
|
|
if index == cursor {
|
|
if index == cursor {
|
|
|
|
|
+ lines := ml.renderMessage(message, ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style)
|
|
|
return tview.NewTextView().
|
|
return tview.NewTextView().
|
|
|
- SetWrap(true).
|
|
|
|
|
- SetWordWrap(true).
|
|
|
|
|
- SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style))
|
|
|
|
|
|
|
+ SetWrap(false).
|
|
|
|
|
+ SetLines(ml.indentWrappedLines(lines, innerWidth))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
item, ok := ml.itemByID[message.ID]
|
|
item, ok := ml.itemByID[message.ID]
|
|
|
if !ok {
|
|
if !ok {
|
|
|
|
|
+ lines := ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style)
|
|
|
item = tview.NewTextView().
|
|
item = tview.NewTextView().
|
|
|
- SetWrap(true).
|
|
|
|
|
- SetWordWrap(true).
|
|
|
|
|
- SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style))
|
|
|
|
|
|
|
+ SetWrap(false).
|
|
|
|
|
+ SetLines(ml.indentWrappedLines(lines, innerWidth))
|
|
|
ml.itemByID[message.ID] = item
|
|
ml.itemByID[message.ID] = item
|
|
|
// Evict stale cache entries when the map grows too large.
|
|
// Evict stale cache entries when the map grows too large.
|
|
|
if len(ml.itemByID) > 500 {
|
|
if len(ml.itemByID) > 500 {
|
|
@@ -193,6 +210,40 @@ func (ml *messagesList) buildItem(index int, cursor int) list.Item {
|
|
|
return item
|
|
return item
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+func (ml *messagesList) indentWrappedLines(lines []tview.Line, viewportWidth int) []tview.Line {
|
|
|
|
|
+ if viewportWidth <= wrapIndent {
|
|
|
|
|
+ return lines
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ wrapWidth := viewportWidth - wrapIndent
|
|
|
|
|
+ indentStr := strings.Repeat(" ", wrapIndent)
|
|
|
|
|
+ result := make([]tview.Line, 0, len(lines))
|
|
|
|
|
+
|
|
|
|
|
+ for _, line := range lines {
|
|
|
|
|
+ wrapped := wrapStyledLine(line, viewportWidth)
|
|
|
|
|
+ if len(wrapped) <= 1 {
|
|
|
|
|
+ result = append(result, wrapped...)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ // First sub-line: no indent
|
|
|
|
|
+ result = append(result, wrapped[0])
|
|
|
|
|
+ // Re-wrap with reduced width for continuation lines
|
|
|
|
|
+ remaining := make(tview.Line, 0)
|
|
|
|
|
+ for _, w := range wrapped[1:] {
|
|
|
|
|
+ remaining = append(remaining, w...)
|
|
|
|
|
+ }
|
|
|
|
|
+ rewrapped := wrapStyledLine(remaining, wrapWidth)
|
|
|
|
|
+ for _, sub := range rewrapped {
|
|
|
|
|
+ indented := make(tview.Line, 0, len(sub)+1)
|
|
|
|
|
+ indented = append(indented, tview.NewSegment(indentStr, tcell.StyleDefault))
|
|
|
|
|
+ indented = append(indented, sub...)
|
|
|
|
|
+ result = append(result, indented)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return result
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func (ml *messagesList) evictStaleCache() {
|
|
func (ml *messagesList) evictStaleCache() {
|
|
|
active := make(map[discord.MessageID]struct{}, len(ml.messages))
|
|
active := make(map[discord.MessageID]struct{}, len(ml.messages))
|
|
|
for _, msg := range ml.messages {
|
|
for _, msg := range ml.messages {
|
|
@@ -381,6 +432,9 @@ func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func (ml *messagesList) drawTimestamps(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
|
|
func (ml *messagesList) drawTimestamps(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
|
|
|
|
|
+ if ml.timestampsHidden {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
dimStyle := baseStyle.Dim(true)
|
|
dimStyle := baseStyle.Dim(true)
|
|
|
builder.Write(ml.formatTimestamp(ts)+" ", dimStyle)
|
|
builder.Write(ml.formatTimestamp(ts)+" ", dimStyle)
|
|
|
}
|
|
}
|
|
@@ -484,7 +538,7 @@ func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, parent d
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
|
|
func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
|
|
|
- if ml.cfg.Timestamps.Enabled {
|
|
|
|
|
|
|
+ if ml.cfg.Timestamps.Enabled && !ml.timestampsHidden {
|
|
|
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
|
|
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -620,6 +674,15 @@ func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
|
|
func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
|
|
|
|
|
+ _, expanded := ml.expandedReplies[message.ID]
|
|
|
|
|
+ if ml.repliesCollapsed && !expanded {
|
|
|
|
|
+ // Collapsed: show indicator only, then main message on same line
|
|
|
|
|
+ replyStyle := baseStyle.Dim(true).Italic(true)
|
|
|
|
|
+ builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", replyStyle)
|
|
|
|
|
+ ml.drawDefaultMessage(builder, message, baseStyle)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
replyStyle := baseStyle.Dim(true).Italic(true)
|
|
replyStyle := baseStyle.Dim(true).Italic(true)
|
|
|
// indicator
|
|
// indicator
|
|
|
builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", replyStyle)
|
|
builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", replyStyle)
|
|
@@ -661,7 +724,10 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
|
|
|
switch {
|
|
switch {
|
|
|
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
|
|
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
|
|
|
ml.clearSelection()
|
|
ml.clearSelection()
|
|
|
- return tview.SetFocus(ml.chatView.guildsTree)
|
|
|
|
|
|
|
+ if cmd := ml.chatView.focusGuildsTree(); cmd != nil {
|
|
|
|
|
+ return cmd
|
|
|
|
|
+ }
|
|
|
|
|
+ return ml.chatView.focusMessageInput()
|
|
|
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind),
|
|
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind),
|
|
|
keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowUp.Keybind):
|
|
keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowUp.Keybind):
|
|
|
return ml.selectUp()
|
|
return ml.selectUp()
|
|
@@ -719,6 +785,22 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
|
|
|
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.UserInfo.Keybind):
|
|
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.UserInfo.Keybind):
|
|
|
ml.showUserInfo()
|
|
ml.showUserInfo()
|
|
|
return nil
|
|
return nil
|
|
|
|
|
+ case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ToggleTimestamps.Keybind):
|
|
|
|
|
+ ml.timestampsHidden = !ml.timestampsHidden
|
|
|
|
|
+ clear(ml.itemByID)
|
|
|
|
|
+ ml.invalidateRows()
|
|
|
|
|
+ return nil
|
|
|
|
|
+ case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ToggleReplies.Keybind):
|
|
|
|
|
+ ml.repliesCollapsed = !ml.repliesCollapsed
|
|
|
|
|
+ if !ml.repliesCollapsed {
|
|
|
|
|
+ clear(ml.expandedReplies)
|
|
|
|
|
+ }
|
|
|
|
|
+ clear(ml.itemByID)
|
|
|
|
|
+ ml.invalidateRows()
|
|
|
|
|
+ return nil
|
|
|
|
|
+ case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.AddReaction.Keybind):
|
|
|
|
|
+ ml.addReaction()
|
|
|
|
|
+ return nil
|
|
|
}
|
|
}
|
|
|
return ml.Model.HandleEvent(event)
|
|
return ml.Model.HandleEvent(event)
|
|
|
|
|
|
|
@@ -1036,6 +1118,21 @@ func (ml *messagesList) deleteSelectedMessage() tview.Command {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+func (ml *messagesList) addReaction() {
|
|
|
|
|
+ msg, err := ml.selectedMessage()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ slog.Error("failed to get selected message", "err", err)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ selectedChannel := ml.chatView.SelectedChannel()
|
|
|
|
|
+ if selectedChannel == nil {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ml.chatView.openEmojiPicker(msg.ID, selectedChannel.ID)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {
|
|
func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {
|
|
|
usersToFetch := make([]discord.UserID, 0, len(messages))
|
|
usersToFetch := make([]discord.UserID, 0, len(messages))
|
|
|
seen := make(map[discord.UserID]struct{}, len(messages))
|
|
seen := make(map[discord.UserID]struct{}, len(messages))
|
|
@@ -1181,5 +1278,6 @@ func (ml *messagesList) FullHelp() [][]keybind.Keybind {
|
|
|
actions,
|
|
actions,
|
|
|
manage,
|
|
manage,
|
|
|
{cfg.YankContent.Keybind, cfg.YankURL.Keybind, cfg.YankID.Keybind, cfg.Search.Keybind},
|
|
{cfg.YankContent.Keybind, cfg.YankURL.Keybind, cfg.YankID.Keybind, cfg.Search.Keybind},
|
|
|
|
|
+ {cfg.ToggleTimestamps.Keybind, cfg.ToggleReplies.Keybind, cfg.AddReaction.Keybind},
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|