feat(ui/chat): add timezone date separators with toggle (#747)
This commit is contained in:
parent
c7e2ccecce
commit
4fcb97ba83
3 changed files with 262 additions and 55 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue