feat(ui/chat): add timezone date separators with toggle (#747)

This commit is contained in:
Ayyan 2026-02-12 20:34:46 -05:00 • committed by GitHub
parent c7e2ccecce
commit 4fcb97ba83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 262 additions and 55 deletions

View file

@ -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)
}

View file

@ -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.

View file

@ -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