diff --git a/internal/config/config.go b/internal/config/config.go index 33fc526..0616b68 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "path/filepath" + "unicode/utf8" "github.com/BurntSushi/toml" "github.com/ayn2op/discordo/internal/consts" @@ -20,6 +21,12 @@ type ( Format string `toml:"format"` } + DateSeparator struct { + Enabled bool `toml:"enabled"` + Format string `toml:"format"` + Character string `toml:"character"` + } + Notifications struct { Enabled bool `toml:"enabled"` Duration int `toml:"duration"` @@ -77,6 +84,7 @@ type ( Markdown MarkdownConfig `toml:"markdown"` Picker PickerConfig `toml:"picker"` Timestamps Timestamps `toml:"timestamps"` + DateSeparator DateSeparator `toml:"date_separator"` Notifications Notifications `toml:"notifications"` TypingIndicator TypingIndicator `toml:"typing_indicator"` @@ -142,4 +150,19 @@ func applyDefaults(cfg *Config) { if cfg.Status == "default" { cfg.Status = discord.UnknownStatus } + + if cfg.DateSeparator.Format == "" { + cfg.DateSeparator.Format = "January 2, 2006" + } + if cfg.DateSeparator.Character == "" { + cfg.DateSeparator.Character = "─" + return + } + + r, _ := utf8.DecodeRuneInString(cfg.DateSeparator.Character) + if r == utf8.RuneError { + cfg.DateSeparator.Character = "─" + return + } + cfg.DateSeparator.Character = string(r) } diff --git a/internal/config/config.toml b/internal/config/config.toml index 1b91c86..f10f7ca 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -35,6 +35,13 @@ enabled = true # https://pkg.go.dev/time#Layout format = "3:04PM" +[date_separator] +enabled = true +# https://pkg.go.dev/time#Layout +format = "January 2, 2006" +# The fill character used on both sides of the date label. +character = "─" + [notifications] enabled = true # The duration of the sound. Set the value to `0` to use default duration. This is only supported on Unix and Windows. diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 75fc47d..86c83f2 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/ayn2op/tview/layers" @@ -40,6 +41,10 @@ type messagesList struct { cfg *config.Config chatView *View messages []discord.Message + // rows is the virtual list model rendered by tview (message rows + + // date-separator rows). It is rebuilt lazily when rowsDirty is true. + rows []messagesListRow + rowsDirty bool renderer *markdown.Renderer // itemByID caches unselected message TextViews. @@ -53,6 +58,19 @@ type messagesList struct { } } +type messagesListRowKind uint8 + +const ( + messagesListRowMessage messagesListRowKind = iota + messagesListRowSeparator +) + +type messagesListRow struct { + kind messagesListRowKind + messageIndex int + timestamp discord.Timestamp +} + func newMessagesList(cfg *config.Config, chatView *View) *messagesList { ml := &messagesList{ List: tview.NewList(), @@ -65,6 +83,7 @@ func newMessagesList(cfg *config.Config, chatView *View) *messagesList { ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme) ml.SetTitle("Messages") ml.SetBuilder(ml.buildItem) + ml.SetChangedFunc(ml.onRowCursorChanged) ml.SetTrackEnd(true) ml.SetInputCapture(ml.onInputCapture) return ml @@ -72,6 +91,8 @@ func newMessagesList(cfg *config.Config, chatView *View) *messagesList { func (ml *messagesList) reset() { ml.messages = nil + ml.rows = nil + ml.rowsDirty = false clear(ml.itemByID) ml. Clear(). @@ -91,6 +112,7 @@ func (ml *messagesList) setTitle(channel discord.Channel) { func (ml *messagesList) setMessages(messages []discord.Message) { ml.messages = slices.Clone(messages) slices.Reverse(ml.messages) + ml.invalidateRows() // New channel payload / refetch: replace the cache wholesale to keep it in // lockstep with the current message slice. clear(ml.itemByID) @@ -99,6 +121,7 @@ func (ml *messagesList) setMessages(messages []discord.Message) { func (ml *messagesList) addMessage(message discord.Message) { ml.messages = append(ml.messages, message) + ml.invalidateRows() // Defensive invalidation for ID reuse/edits delivered out-of-order. delete(ml.itemByID, message.ID) ml.MarkDirty() @@ -111,6 +134,7 @@ func (ml *messagesList) setMessage(index int, message discord.Message) { ml.messages[index] = message delete(ml.itemByID, message.ID) + ml.invalidateRows() ml.MarkDirty() } @@ -121,6 +145,7 @@ func (ml *messagesList) deleteMessage(index int) { delete(ml.itemByID, ml.messages[index].ID) ml.messages = append(ml.messages[:index], ml.messages[index+1:]...) + ml.invalidateRows() ml.MarkDirty() } @@ -129,11 +154,18 @@ func (ml *messagesList) clearSelection() { } func (ml *messagesList) buildItem(index int, cursor int) tview.ListItem { - if index < 0 || index >= len(ml.messages) { + ml.ensureRows() + + if index < 0 || index >= len(ml.rows) { return nil } - message := ml.messages[index] + row := ml.rows[index] + if row.kind == messagesListRowSeparator { + return ml.buildSeparatorItem(row.timestamp) + } + + message := ml.messages[row.messageIndex] if index == cursor { return tview.NewTextView(). SetWrap(true). @@ -158,6 +190,140 @@ func (ml *messagesList) renderMessage(message discord.Message, baseStyle tcell.S return builder.Finish() } +func (ml *messagesList) buildSeparatorItem(ts discord.Timestamp) *tview.TextView { + builder := tview.NewLineBuilder() + ml.drawDateSeparator(builder, ts, ml.cfg.Theme.MessagesList.MessageStyle.Style) + return tview.NewTextView(). + SetScrollable(false). + SetWrap(false). + SetWordWrap(false). + SetLines(builder.Finish()) +} + +func (ml *messagesList) drawDateSeparator(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) { + date := ts.Time().In(time.Local).Format(ml.cfg.DateSeparator.Format) + label := " " + date + " " + fillChar := ml.cfg.DateSeparator.Character + dimStyle := baseStyle.Dim(true) + _, _, width, _ := ml.GetInnerRect() + if width <= 0 { + builder.Write(strings.Repeat(fillChar, 8)+label+strings.Repeat(fillChar, 8), dimStyle) + return + } + + labelWidth := utf8.RuneCountInString(label) + if width <= labelWidth { + builder.Write(date, dimStyle) + return + } + + fillWidth := width - labelWidth + left := fillWidth / 2 + right := fillWidth - left + builder.Write(strings.Repeat(fillChar, left)+label+strings.Repeat(fillChar, right), dimStyle) +} + +func (ml *messagesList) rebuildRows() { + rows := make([]messagesListRow, 0, len(ml.messages)*2) + + for i := range ml.messages { + if ml.cfg.DateSeparator.Enabled && i > 0 && !sameLocalDate(ml.messages[i-1].Timestamp, ml.messages[i].Timestamp) { + rows = append(rows, messagesListRow{ + kind: messagesListRowSeparator, + timestamp: ml.messages[i].Timestamp, + }) + } + + rows = append(rows, messagesListRow{ + kind: messagesListRowMessage, + messageIndex: i, + }) + } + + ml.rows = rows + ml.rowsDirty = false +} + +func (ml *messagesList) invalidateRows() { + ml.rowsDirty = true +} + +// ensureRows lazily rebuilds list rows. This avoids repeated O(n) row rebuild +// work when multiple message mutations happen close together. +func (ml *messagesList) ensureRows() { + if !ml.rowsDirty { + return + } + + ml.rebuildRows() +} + +func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool { + ta := a.Time().In(time.Local) + tb := b.Time().In(time.Local) + return ta.Year() == tb.Year() && ta.YearDay() == tb.YearDay() +} + +// Cursor returns the selected message index, skipping separator rows. +func (ml *messagesList) Cursor() int { + ml.ensureRows() + rowIndex := ml.List.Cursor() + if rowIndex < 0 || rowIndex >= len(ml.rows) { + return -1 + } + + row := ml.rows[rowIndex] + if row.kind != messagesListRowMessage { + return -1 + } + return row.messageIndex +} + +// SetCursor selects a message index and maps it to the corresponding row. +func (ml *messagesList) SetCursor(index int) { + ml.List.SetCursor(ml.messageToRowIndex(index)) +} + +func (ml *messagesList) messageToRowIndex(messageIndex int) int { + ml.ensureRows() + if messageIndex < 0 || messageIndex >= len(ml.messages) { + return -1 + } + + for i, row := range ml.rows { + if row.kind == messagesListRowMessage && row.messageIndex == messageIndex { + return i + } + } + + return -1 +} + +func (ml *messagesList) onRowCursorChanged(rowIndex int) { + ml.ensureRows() + if rowIndex < 0 || rowIndex >= len(ml.rows) || ml.rows[rowIndex].kind == messagesListRowMessage { + return + } + + target := ml.nearestMessageRowIndex(rowIndex) + ml.List.SetCursor(target) +} + +// nearestMessageRowIndex expects rowIndex to be within bounds. +func (ml *messagesList) nearestMessageRowIndex(rowIndex int) int { + for i := rowIndex - 1; i >= 0; i-- { + if ml.rows[i].kind == messagesListRowMessage { + return i + } + } + for i := rowIndex + 1; i < len(ml.rows); i++ { + if ml.rows[i].kind == messagesListRowMessage { + return i + } + } + return -1 +} + func (ml *messagesList) writeMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) { if ml.cfg.HideBlockedUsers { isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID) @@ -201,23 +367,17 @@ func (ml *messagesList) drawAuthor(builder *tview.LineBuilder, message discord.M name := message.Author.DisplayOrUsername() foreground := tcell.ColorDefault - // Webhooks do not have nicknames or roles. - if message.GuildID.IsValid() && !message.WebhookID.IsValid() { - member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID) - if err != nil { - slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err) - } else { - if member.Nick != "" { - name = member.Nick - } + if member := ml.memberForMessage(message); member != nil { + if member.Nick != "" { + name = member.Nick + } - color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role { - r, _ := ml.chatView.state.Cabinet.Role(message.GuildID, id) - return r - }) - if ok { - foreground = tcell.NewHexColor(int32(color)) - } + color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role { + r, _ := ml.chatView.state.Cabinet.Role(message.GuildID, id) + return r + }) + if ok { + foreground = tcell.NewHexColor(int32(color)) } } @@ -225,6 +385,20 @@ func (ml *messagesList) drawAuthor(builder *tview.LineBuilder, message discord.M builder.Write(name+" ", style) } +func (ml *messagesList) memberForMessage(message discord.Message) *discord.Member { + // Webhooks do not have nicknames or roles. + if !message.GuildID.IsValid() || message.WebhookID.IsValid() { + return nil + } + + member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID) + if err != nil { + slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err) + return nil + } + return member +} + func (ml *messagesList) drawContent(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) { c := []byte(message.Content) if ml.chatView.cfg.Markdown.Enabled { @@ -399,36 +573,11 @@ func (ml *messagesList) _select(name string) { case cursor > 0: cursor-- case cursor == 0: - selectedChannel := ml.chatView.SelectedChannel() - if selectedChannel == nil { + added := ml.prependOlderMessages() + if added == 0 { return } - - channelID := selectedChannel.ID - before := ml.messages[0].ID - limit := uint(ml.cfg.MessagesLimit) - messages, err := ml.chatView.state.MessagesBefore(channelID, before, limit) - if err != nil { - slog.Error("failed to fetch older messages", "err", err) - return - } - if len(messages) == 0 { - return - } - - if guildID := selectedChannel.GuildID; guildID.IsValid() { - ml.requestGuildMembers(guildID, messages) - } - - older := slices.Clone(messages) - slices.Reverse(older) - - // Defensive invalidation if Discord returns overlapping windows. - for _, message := range older { - delete(ml.itemByID, message.ID) - } - ml.messages = slices.Concat(older, ml.messages) - cursor = len(messages) - 1 + cursor = added - 1 } case ml.cfg.Keybinds.MessagesList.SelectDown: switch { @@ -459,6 +608,41 @@ func (ml *messagesList) _select(name string) { ml.SetCursor(cursor) } +func (ml *messagesList) prependOlderMessages() int { + selectedChannel := ml.chatView.SelectedChannel() + if selectedChannel == nil { + return 0 + } + + channelID := selectedChannel.ID + before := ml.messages[0].ID + limit := uint(ml.cfg.MessagesLimit) + messages, err := ml.chatView.state.MessagesBefore(channelID, before, limit) + if err != nil { + slog.Error("failed to fetch older messages", "err", err) + return 0 + } + if len(messages) == 0 { + return 0 + } + + if guildID := selectedChannel.GuildID; guildID.IsValid() { + ml.requestGuildMembers(guildID, messages) + } + + older := slices.Clone(messages) + slices.Reverse(older) + + // Defensive invalidation if Discord returns overlapping windows. + for _, message := range older { + delete(ml.itemByID, message.ID) + } + ml.messages = slices.Concat(older, ml.messages) + ml.invalidateRows() + ml.MarkDirty() + return len(messages) +} + func (ml *messagesList) yankID() { msg, err := ml.selectedMessage() if err != nil { @@ -712,15 +896,8 @@ func (ml *messagesList) reply(mention bool) { } name := message.Author.DisplayOrUsername() - if message.GuildID.IsValid() { - member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID) - if err != nil { - slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err) - } else { - if member.Nick != "" { - name = member.Nick - } - } + if member := ml.memberForMessage(*message); member != nil && member.Nick != "" { + name = member.Nick } data := ml.chatView.messageInput.sendMessageData