Procházet zdrojové kódy

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

Ayyan před 2 měsíci
rodič
revize
4fcb97ba83

+ 23 - 0
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)
 }

+ 7 - 0
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.

+ 232 - 55
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 {
-				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 {
+			added := ml.prependOlderMessages()
+			if added == 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