refactor(ui): give command methods value receivers

This commit is contained in:
ayn2op 2026-10-06 16:55:51 -04:00
parent c3cb114e22
commit 0dc3f21501
12 changed files with 583 additions and 583 deletions

View file

@ -93,34 +93,34 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
}
// SetChannel sets the channel messages are sent to, disabling the composer if the user may not send messages there.
func (c *Model) SetChannel(channel *discord.Channel) {
c.channel, c.typingUntil = channel, time.Time{}
func (m *Model) SetChannel(channel *discord.Channel) {
m.channel, m.typingUntil = channel, time.Time{}
isDM := channel.Type == discord.DirectMessage || channel.Type == discord.GroupDM
c.disabled = !isDM && !c.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
m.disabled = !isDM && !m.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
}
// Height returns the number of rows the composer takes, including its border.
func (c *Model) Height() int {
_, frame := c.frame()
return min(strings.Count(c.editState.Value(), "\n")+1, max(c.cfg.Composer.MaxHeight, 1)) + frame
func (m *Model) Height() int {
_, frame := m.frame()
return min(strings.Count(m.editState.Value(), "\n")+1, max(m.cfg.Composer.MaxHeight, 1)) + frame
}
func (c *Model) Disabled() bool {
return c.disabled
func (m Model) Disabled() bool {
return m.disabled
}
func (c *Model) placeholder() string {
func (m Model) placeholder() string {
switch {
case c.channel == nil:
case m.channel == nil:
return noChannelPlaceholder
case c.disabled:
case m.disabled:
return noPermissionPlaceholder
}
return messagePlaceholder
}
// View shows the text area in a box, taking keys if focused.
func (c Model) View(focused bool) tview.Widget { return view{&c, focused} }
func (m Model) View(focused bool) tview.Widget { return view{&m, focused} }
// view is the composer as shown, with or without the focus.
type view struct {
@ -149,177 +149,177 @@ func (v view) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
}
// box returns the text area in a box with the title and footer.
func (c *Model) box(focused bool) box.Widget {
text := textarea.New(&c.editState).
Placeholder(c.placeholder()).
Keybind(c.editAction).
Focused(focused && !c.disabled).
func (m *Model) box(focused bool) box.Widget {
text := textarea.New(&m.editState).
Placeholder(m.placeholder()).
Keybind(m.editAction).
Focused(focused && !m.disabled).
OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) })
return ui.Box(text, &c.cfg.Theme, focused).Title(c.title()).Footer(c.footer())
return ui.Box(text, &m.cfg.Theme, focused).Title(m.title()).Footer(m.footer())
}
// title returns what the composer is doing: editing, replying, or nothing.
func (c *Model) title() string {
switch data := c.sendMessageData; {
case c.editing != nil:
func (m Model) title() string {
switch data := m.sendMessageData; {
case m.editing != nil:
return "Editing"
case data.Reference != nil:
if mention := data.AllowedMentions.RepliedUser; mention != nil && *mention {
return "[@] Replying to " + c.replyName
return "[@] Replying to " + m.replyName
}
return "Replying to " + c.replyName
return "Replying to " + m.replyName
}
return ""
}
// footer returns the names of the attached files, or "" if there are none.
func (c *Model) footer() string {
if len(c.sendMessageData.Files) == 0 {
func (m Model) footer() string {
if len(m.sendMessageData.Files) == 0 {
return ""
}
var names []string
for _, file := range c.sendMessageData.Files {
for _, file := range m.sendMessageData.Files {
names = append(names, file.Name)
}
return "Attached " + humanJoin(names)
}
// isBound reports whether key is one of the composer's keybinds.
func (c *Model) isBound(key tview.KeyMsg) bool {
k := c.cfg.Keybinds.Composer
func (m Model) isBound(key tview.KeyMsg) bool {
k := m.cfg.Keybinds.Composer
if keybind.Matches(key, k.Paste.Keybind, k.Send.Keybind, k.OpenEditor.Keybind, k.OpenFilePicker.Keybind, k.Cancel.Keybind, k.TabComplete.Keybind, k.ToggleReplyMention.Keybind, k.Undo.Keybind) {
return true
}
return keybind.Matches(key, k.EditLast.Keybind) && c.canEditLastMessage()
return keybind.Matches(key, k.EditLast.Keybind) && m.canEditLastMessage()
}
// insert inserts text at the cursor.
func (c *Model) insert(text string) {
c.editState.Replace(c.editState.Cursor(), c.editState.Cursor(), text)
func (m *Model) insert(text string) {
m.editState.Replace(m.editState.Cursor(), m.editState.Cursor(), text)
}
// pasteText inserts the text on the clipboard at the cursor.
func (c *Model) pasteText() {
func (m *Model) pasteText() {
data, err := clipboard.Read(context.Background(), clipboard.FmtText)
if err != nil {
slog.Error("failed to read from clipboard", "err", err)
return
}
c.insert(string(data))
m.insert(string(data))
}
// frame returns the cells the composer's border and padding take on each side.
func (c *Model) frame() (left, vertical int) {
func (m *Model) frame() (left, vertical int) {
const size = 100
inner := c.box(false).InnerArea(tview.Rectangle{Width: size, Height: size})
inner := m.box(false).InnerArea(tview.Rectangle{Width: size, Height: size})
return inner.X, size - inner.Height
}
func (c *Model) reset() {
c.editing = nil
c.sendMessageData = &api.SendMessageData{}
c.editState.SetValue("")
func (m *Model) reset() {
m.editing = nil
m.sendMessageData = &api.SendMessageData{}
m.editState.SetValue("")
}
// Update returns c changed in response to msg and a command to run, or nil.
func (c Model) Update(msg tview.Msg) (Model, tview.Cmd) {
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
switch msg := msg.(type) {
case TabSuggestMsg:
return c, c.tabSuggest()
return m, m.tabSuggest()
case imagePastedMsg:
if len(msg) == 0 {
return c, nil
return m, nil
}
c.attach(imageAttachmentName, bytes.NewReader(msg))
return c, nil
m.attach(imageAttachmentName, bytes.NewReader(msg))
return m, nil
case filesPickedMsg:
selectedChannel := c.channel
selectedChannel := m.channel
if selectedChannel == nil || selectedChannel.ID != msg.channelID {
return c, closeFiles(msg.files)
return m, closeFiles(msg.files)
}
for _, file := range msg.files {
c.attach(file.Name, file.Reader)
m.attach(file.Name, file.Reader)
}
return c, nil
return m, nil
case editorMsg:
c.editState.SetValue(string(msg))
return c, nil
m.editState.SetValue(string(msg))
return m, nil
case editMsg:
c.editState.Apply(textarea.Change(msg))
typingCmd := c.sendTyping()
if c.cfg.AutocompleteLimit > 0 {
return c, tview.Batch(typingCmd, c.tabSuggest())
m.editState.Apply(textarea.Change(msg))
typingCmd := m.sendTyping()
if m.cfg.AutocompleteLimit > 0 {
return m, tview.Batch(typingCmd, m.tabSuggest())
}
return c, typingCmd
return m, typingCmd
case tview.KeyMsg:
switch {
case keybind.Matches(msg, c.cfg.Keybinds.Composer.EditLast.Keybind) && c.canEditLastMessage():
return c, c.editLastMessage()
case keybind.Matches(msg, c.cfg.Keybinds.Composer.Paste.Keybind):
c.pasteText()
return c, pasteImage()
case keybind.Matches(msg, c.cfg.Keybinds.Composer.Send.Keybind):
if c.mentionsVisible {
return c, c.tabComplete()
case keybind.Matches(msg, m.cfg.Keybinds.Composer.EditLast.Keybind) && m.canEditLastMessage():
return m, m.editLastMessage()
case keybind.Matches(msg, m.cfg.Keybinds.Composer.Paste.Keybind):
m.pasteText()
return m, pasteImage()
case keybind.Matches(msg, m.cfg.Keybinds.Composer.Send.Keybind):
if m.mentionsVisible {
return m, m.tabComplete()
}
return c, c.send()
case keybind.Matches(msg, c.cfg.Keybinds.Composer.OpenEditor.Keybind):
c.stopTabCompletion()
return c, c.openEditor()
case keybind.Matches(msg, c.cfg.Keybinds.Composer.OpenFilePicker.Keybind):
c.stopTabCompletion()
return c, c.pickFiles()
case keybind.Matches(msg, c.cfg.Keybinds.Composer.Cancel.Keybind):
if c.mentionsVisible {
c.stopTabCompletion()
return c, nil
return m, m.send()
case keybind.Matches(msg, m.cfg.Keybinds.Composer.OpenEditor.Keybind):
m.stopTabCompletion()
return m, m.openEditor()
case keybind.Matches(msg, m.cfg.Keybinds.Composer.OpenFilePicker.Keybind):
m.stopTabCompletion()
return m, m.pickFiles()
case keybind.Matches(msg, m.cfg.Keybinds.Composer.Cancel.Keybind):
if m.mentionsVisible {
m.stopTabCompletion()
return m, nil
}
c.reset()
return c, nil
case keybind.Matches(msg, c.cfg.Keybinds.Composer.TabComplete.Keybind):
if c.mentionsVisible {
return c, c.tabComplete()
m.reset()
return m, nil
case keybind.Matches(msg, m.cfg.Keybinds.Composer.TabComplete.Keybind):
if m.mentionsVisible {
return m, m.tabComplete()
}
c.insert("\t")
return c, nil
case keybind.Matches(msg, c.cfg.Keybinds.Composer.ToggleReplyMention.Keybind):
c.toggleReplyMention()
return c, nil
case keybind.Matches(msg, c.cfg.Keybinds.Composer.Undo.Keybind):
c.editState.Undo()
return c, nil
m.insert("\t")
return m, nil
case keybind.Matches(msg, m.cfg.Keybinds.Composer.ToggleReplyMention.Keybind):
m.toggleReplyMention()
return m, nil
case keybind.Matches(msg, m.cfg.Keybinds.Composer.Undo.Keybind):
m.editState.Undo()
return m, nil
}
case mentionslist.Msg:
var cmd tview.Cmd
c.mentionsList, cmd = c.mentionsList.Update(msg)
return c, cmd
m.mentionsList, cmd = m.mentionsList.Update(msg)
return m, cmd
}
return c, nil
return m, nil
}
func (c *Model) canEditLastMessage() bool {
return !c.Disabled() && c.editing == nil && c.editState.Value() == "" &&
c.sendMessageData.Reference == nil && len(c.sendMessageData.Files) == 0 &&
!c.mentionsVisible
func (m Model) canEditLastMessage() bool {
return !m.Disabled() && m.editing == nil && m.editState.Value() == "" &&
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
!m.mentionsVisible
}
// StartEdit puts message in the composer to be edited.
func (c *Model) StartEdit(message discord.Message) {
c.editing = &message
c.editState.SetValue(message.Content)
func (m *Model) StartEdit(message discord.Message) {
m.editing = &message
m.editState.SetValue(message.Content)
}
// StartReply makes the next message a reply to message by name, mentioning its author if mention is set.
func (c *Model) StartReply(message discord.Message, name string, mention bool) {
data := c.sendMessageData
func (m *Model) StartReply(message discord.Message, name string, mention bool) {
data := m.sendMessageData
data.Reference = &discord.MessageReference{MessageID: message.ID}
data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.Some(mention)}
c.replyName = name
m.replyName = name
}
func (c *Model) toggleReplyMention() {
data := c.sendMessageData
func (m Model) toggleReplyMention() {
data := m.sendMessageData
if data.Reference == nil || data.AllowedMentions == nil || data.AllowedMentions.RepliedUser == nil {
return
}
@ -327,7 +327,7 @@ func (c *Model) toggleReplyMention() {
data.AllowedMentions.RepliedUser = option.Some(!*data.AllowedMentions.RepliedUser)
}
func (c *Model) processText(channel *discord.Channel, src []byte) string {
func (m Model) processText(channel *discord.Channel, src []byte) string {
// Fast path: no mentions to expand.
if bytes.IndexByte(src, '@') == -1 {
return string(src)
@ -335,7 +335,7 @@ func (c *Model) processText(channel *discord.Channel, src []byte) string {
// Fast path: no back ticks (code blocks), so expand mentions directly.
if bytes.IndexByte(src, '`') == -1 {
return string(c.expandMentions(channel, src))
return string(m.expandMentions(channel, src))
}
var (
@ -361,14 +361,14 @@ func (c *Model) processText(channel *discord.Channel, src []byte) string {
// Replace back to front so that expanding a mention does not shift the ranges before it.
for _, rng := range slices.Backward(ranges) {
src = slices.Replace(src, rng[0], rng[1], c.expandMentions(channel, src[rng[0]:rng[1]])...)
src = slices.Replace(src, rng[0], rng[1], m.expandMentions(channel, src[rng[0]:rng[1]])...)
}
return string(src)
}
func (c *Model) expandMentions(channel *discord.Channel, src []byte) []byte {
state := c.state
func (m Model) expandMentions(channel *discord.Channel, src []byte) []byte {
state := m.state
return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte {
output := input
name := string(input[1:])
@ -415,24 +415,24 @@ func isMentionChar(r rune) bool {
}
// suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user.
func (c *Model) suggestRecentAuthors(channel *discord.Channel) {
messages, err := c.state.Cabinet.Messages(channel.ID)
func (m *Model) suggestRecentAuthors(channel *discord.Channel) {
messages, err := m.state.Cabinet.Messages(channel.ID)
if err != nil {
return
}
me, _ := c.state.Cabinet.Me()
me, _ := m.state.Cabinet.Me()
shown := map[discord.UserID]bool{me.ID: true}
for _, m := range messages {
if shown[m.Author.ID] {
for _, message := range messages {
if shown[message.Author.ID] {
continue
}
shown[m.Author.ID] = true
shown[message.Author.ID] = true
if !channel.GuildID.IsValid() {
c.addMentionUser(&m.Author)
m.addMentionUser(&message.Author)
continue
}
c.state.MemberState.RequestMember(channel.GuildID, m.Author.ID)
if member, err := c.state.Cabinet.Member(channel.GuildID, m.Author.ID); err == nil && c.addMentionMember(channel.GuildID, member) {
m.state.MemberState.RequestMember(channel.GuildID, message.Author.ID)
if member, err := m.state.Cabinet.Member(channel.GuildID, message.Author.ID); err == nil && m.addMentionMember(channel.GuildID, member) {
return
}
}
@ -467,25 +467,25 @@ func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID dis
return perms.Has(discord.PermissionViewChannel)
}
func (c *Model) CacheMemberSearch(event *gateway.GuildMembersChunkEvent) tview.Cmd {
func (m Model) CacheMemberSearch(event *gateway.GuildMembersChunkEvent) tview.Cmd {
key, ok := strings.CutPrefix(event.Nonce, memberSearchNonce)
if !ok {
return nil
}
c.memberSearchCache[key] = uint(len(event.Members))
m.memberSearchCache[key] = uint(len(event.Members))
return func() tview.Msg {
return TabSuggestMsg{}
}
}
// OnGuildMemberRemove forgets cached member searches that the removed member may have filled.
func (c *Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
for name := event.GuildID.String() + " " + event.User.Username; name != ""; name = name[:len(name)-1] {
if count, ok := c.memberSearchCache[name]; ok && count >= c.state.MemberState.SearchLimit {
if count, ok := m.memberSearchCache[name]; ok && count >= m.state.MemberState.SearchLimit {
// A full result set may now be missing members; search these prefixes again.
for name != "" {
delete(c.memberSearchCache, name)
delete(m.memberSearchCache, name)
name = name[:len(name)-1]
}
return
@ -494,11 +494,11 @@ func (c *Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
}
// MentionsView places the mentions list just above the composer, near the cursor, or returns nil if it is hidden.
func (c *Model) MentionsView() tview.Widget {
if !c.mentionsVisible {
func (m *Model) MentionsView() tview.Widget {
if !m.mentionsVisible {
return nil
}
return mentionsPopup{c}
return mentionsPopup{m}
}
// mentionsPopup lays out the mentions list within the area above the composer, where it is drawn over the messages.
@ -547,54 +547,54 @@ func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - c.Height() - h, Width: w, Height: h}
}
func (c *Model) addMentionMember(gID discord.GuildID, m *discord.Member) bool {
if m == nil {
func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool {
if member == nil {
return false
}
name := cmp.Or(m.Nick, m.User.DisplayName, m.User.Username)
name := cmp.Or(member.Nick, member.User.DisplayName, member.User.Username)
style := tcell.StyleDefault
// This avoids a slower member color lookup path.
color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role {
r, _ := c.state.Cabinet.Role(gID, id)
color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
r, _ := m.state.Cabinet.Role(gID, id)
return r
})
if ok {
style = style.Foreground(tcell.NewHexColor(int32(color)))
}
presence, err := c.state.Cabinet.Presence(gID, m.User.ID)
presence, err := m.state.Cabinet.Presence(gID, member.User.ID)
if err != nil {
slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err)
slog.Info("failed to get presence from state", "guild_id", gID, "user_id", member.User.ID, "err", err)
} else if presence.Status == discord.OfflineStatus {
style = style.Dim(true)
}
c.mentionsList.Append(mentionslist.Item{
InsertText: m.User.Username,
m.mentionsList.Append(mentionslist.Item{
InsertText: member.User.Username,
DisplayText: name,
Style: style,
})
return c.mentionsList.ItemCount() > int(c.cfg.AutocompleteLimit)
return m.mentionsList.ItemCount() > int(m.cfg.AutocompleteLimit)
}
func (c *Model) addMentionUser(user *discord.User) {
func (m *Model) addMentionUser(user *discord.User) {
if user == nil {
return
}
name := user.DisplayOrUsername()
style := tcell.StyleDefault
presence, err := c.state.Cabinet.Presence(discord.NullGuildID, user.ID)
presence, err := m.state.Cabinet.Presence(discord.NullGuildID, user.ID)
if err != nil {
slog.Info("failed to get presence from state", "user_id", user.ID, "err", err)
} else if presence.Status == discord.OfflineStatus {
style = style.Dim(true)
}
c.mentionsList.Append(mentionslist.Item{
m.mentionsList.Append(mentionslist.Item{
InsertText: user.Username,
DisplayText: name,
Style: style,
@ -602,28 +602,28 @@ func (c *Model) addMentionUser(user *discord.User) {
}
// stopTabCompletion clears and closes the mentions list when it is in use.
func (c *Model) stopTabCompletion() {
if c.cfg.AutocompleteLimit > 0 {
c.mentionsList.Clear()
c.CloseMentions()
func (m *Model) stopTabCompletion() {
if m.cfg.AutocompleteLimit > 0 {
m.mentionsList.Clear()
m.CloseMentions()
}
}
func (c *Model) CloseMentions() {
c.mentionsVisible = false
func (m *Model) CloseMentions() {
m.mentionsVisible = false
}
func (c *Model) attach(name string, reader io.Reader) {
c.sendMessageData.Files = append(c.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
func (m Model) attach(name string, reader io.Reader) {
m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
}
func (c *Model) canAttachFiles() bool {
return c.channel != nil && c.state.HasPermissions(c.channel.ID, discord.PermissionAttachFiles)
func (m Model) canAttachFiles() bool {
return m.channel != nil && m.state.HasPermissions(m.channel.ID, discord.PermissionAttachFiles)
}
// editAction binds the configured newline key and textarea's other default keys.
func (c Model) editAction(key tview.KeyMsg) textarea.Action {
if keybind.Matches(key, c.cfg.Keybinds.Composer.Newline.Keybind) {
func (m Model) editAction(key tview.KeyMsg) textarea.Action {
if keybind.Matches(key, m.cfg.Keybinds.Composer.Newline.Keybind) {
return textarea.ActionNewline
}
if action := textarea.DefaultKeybind(key); action != textarea.ActionNewline {
@ -632,51 +632,51 @@ func (c Model) editAction(key tview.KeyMsg) textarea.Action {
return textarea.ActionNone
}
func (c Model) ShortHelp() []keybind.Keybind {
if c.mentionsVisible {
cfg := c.cfg.Keybinds.MentionsList
ccfg := c.cfg.Keybinds.Composer
func (m Model) ShortHelp() []keybind.Keybind {
if m.mentionsVisible {
cfg := m.cfg.Keybinds.MentionsList
ccfg := m.cfg.Keybinds.Composer
short := []keybind.Keybind{cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, ccfg.TabComplete.Keybind, ccfg.Cancel.Keybind}
if c.canAttachFiles() {
if m.canAttachFiles() {
short = append(short, ccfg.OpenFilePicker.Keybind)
}
return short
}
cfg := c.cfg.Keybinds.Composer
cfg := m.cfg.Keybinds.Composer
short := []keybind.Keybind{cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
if c.canEditLastMessage() {
if m.canEditLastMessage() {
short = append(short, cfg.EditLast.Keybind)
}
if c.sendMessageData.Reference != nil {
if m.sendMessageData.Reference != nil {
short = append(short, cfg.ToggleReplyMention.Keybind)
}
if c.canAttachFiles() {
if m.canAttachFiles() {
short = append(short, cfg.OpenFilePicker.Keybind)
}
return short
}
func (c Model) FullHelp() [][]keybind.Keybind {
if c.mentionsVisible {
mcfg := c.cfg.Keybinds.MentionsList
ccfg := c.cfg.Keybinds.Composer
func (m Model) FullHelp() [][]keybind.Keybind {
if m.mentionsVisible {
mcfg := m.cfg.Keybinds.MentionsList
ccfg := m.cfg.Keybinds.Composer
return [][]keybind.Keybind{
{mcfg.SelectUp.Keybind, mcfg.SelectDown.Keybind, mcfg.SelectTop.Keybind, mcfg.SelectBottom.Keybind},
{ccfg.TabComplete.Keybind, ccfg.Cancel.Keybind},
}
}
cfg := c.cfg.Keybinds.Composer
cfg := m.cfg.Keybinds.Composer
openEditor := []keybind.Keybind{cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
if c.canAttachFiles() {
if m.canAttachFiles() {
openEditor = append(openEditor, cfg.OpenFilePicker.Keybind)
}
compose := []keybind.Keybind{cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.Undo.Keybind}
compose = append(compose, cfg.EditLast.Keybind)
if c.sendMessageData.Reference != nil {
if m.sendMessageData.Reference != nil {
compose = append(compose, cfg.ToggleReplyMention.Keybind)
}

View file

@ -36,7 +36,7 @@ type editorMsg string
// editMsg edits the composer's text.
type editMsg textarea.Change
func (c *Model) editLastMessage() tview.Cmd {
func (m Model) editLastMessage() tview.Cmd {
return func() tview.Msg { return EditLastMsg{} }
}
@ -58,8 +58,8 @@ type filesPickedMsg struct {
files []sendpart.File
}
func (c *Model) pickFiles() tview.Cmd {
selectedChannel := c.channel
func (m Model) pickFiles() tview.Cmd {
selectedChannel := m.channel
if selectedChannel == nil {
return nil
}
@ -99,89 +99,89 @@ func closeFiles(files []sendpart.File) tview.Cmd {
}
}
func (c *Model) sendTyping() tview.Cmd {
if !c.cfg.TypingIndicator.Send {
func (m *Model) sendTyping() tview.Cmd {
if !m.cfg.TypingIndicator.Send {
return nil
}
now := time.Now()
if now.Before(c.typingUntil) {
if now.Before(m.typingUntil) {
return nil
}
c.typingUntil = now.Add(TypingDuration)
m.typingUntil = now.Add(TypingDuration)
selectedChannel := c.channel
selectedChannel := m.channel
if selectedChannel == nil {
return nil
}
channelID := selectedChannel.ID
return func() tview.Msg {
c.state.Typing(channelID)
m.state.Typing(channelID)
return nil
}
}
func (c *Model) send() tview.Cmd {
selectedChannel := c.channel
func (m *Model) send() tview.Cmd {
selectedChannel := m.channel
if selectedChannel == nil {
return nil
}
text := strings.TrimSpace(c.editState.Value())
if text == "" && len(c.sendMessageData.Files) == 0 {
text := strings.TrimSpace(m.editState.Value())
if text == "" && len(m.sendMessageData.Files) == 0 {
return nil
}
text = c.processText(selectedChannel, []byte(text))
data := *c.sendMessageData
text = m.processText(selectedChannel, []byte(text))
data := *m.sendMessageData
data.Files = slices.Clone(data.Files)
editing := c.editing
c.typingUntil = time.Time{}
c.reset()
editing := m.editing
m.typingUntil = time.Time{}
m.reset()
sent := func() tview.Msg { return SentMsg{} }
return tview.Batch(sent, func() tview.Msg {
defer closeFiles(data.Files)()
if editing != nil {
editData := api.EditMessageData{Content: option.SomeNullable(text)}
if _, err := c.state.EditMessageComplex(editing.ChannelID, editing.ID, editData); err != nil {
if _, err := m.state.EditMessageComplex(editing.ChannelID, editing.ID, editData); err != nil {
slog.Error("failed to edit message", "err", err)
}
return nil
}
data.Content = text
if _, err := c.state.SendMessageComplex(selectedChannel.ID, data); err != nil {
if _, err := m.state.SendMessageComplex(selectedChannel.ID, data); err != nil {
slog.Error("failed to send message in channel", "channel_id", selectedChannel.ID, "err", err)
}
return nil
})
}
func (c *Model) tabComplete() tview.Cmd {
posEnd, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar)
func (m *Model) tabComplete() tview.Cmd {
posEnd, name, r := wordBeforeCursor(m.editState.Value(), m.editState.Cursor(), isMentionChar)
if r != '@' {
c.stopTabCompletion()
m.stopTabCompletion()
return nil
}
pos := posEnd - (len(name) + 1)
selectedChannel := c.channel
selectedChannel := m.channel
if selectedChannel == nil {
return nil
}
gID := selectedChannel.GuildID
if c.cfg.AutocompleteLimit == 0 {
if m.cfg.AutocompleteLimit == 0 {
if !gID.IsValid() {
users := selectedChannel.DMRecipients
res := fuzzy.FindFrom(name, userList(users))
if len(res) > 0 {
c.editState.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ")
m.editState.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ")
}
} else {
cmd := c.searchMember(gID, name)
members, err := c.state.Cabinet.Members(gID)
cmd := m.searchMember(gID, name)
members, err := m.state.Cabinet.Members(gID)
if err != nil {
slog.Error("failed to get members from state", "guild_id", gID, "err", err)
return cmd
@ -189,8 +189,8 @@ func (c *Model) tabComplete() tview.Cmd {
res := fuzzy.FindFrom(name, memberList(members))
for _, r := range res {
if channelHasUser(c.state, selectedChannel.ID, members[r.Index].User.ID) {
c.editState.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
if channelHasUser(m.state, selectedChannel.ID, members[r.Index].User.ID) {
m.editState.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
return cmd
}
}
@ -198,107 +198,107 @@ func (c *Model) tabComplete() tview.Cmd {
}
return nil
}
if c.mentionsList.ItemCount() == 0 {
if m.mentionsList.ItemCount() == 0 {
return nil
}
name, ok := c.mentionsList.SelectedInsertText()
name, ok := m.mentionsList.SelectedInsertText()
if !ok {
return nil
}
c.editState.Replace(pos, posEnd, "@"+name+" ")
c.stopTabCompletion()
m.editState.Replace(pos, posEnd, "@"+name+" ")
m.stopTabCompletion()
return nil
}
func (c *Model) tabSuggest() tview.Cmd {
_, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar)
func (m *Model) tabSuggest() tview.Cmd {
_, name, r := wordBeforeCursor(m.editState.Value(), m.editState.Cursor(), isMentionChar)
if r != '@' {
c.stopTabCompletion()
m.stopTabCompletion()
return nil
}
channel := c.channel
channel := m.channel
if channel == nil {
return nil
}
c.mentionsList.Clear()
m.mentionsList.Clear()
gID := channel.GuildID
switch {
case name == "":
c.suggestRecentAuthors(channel)
m.suggestRecentAuthors(channel)
case !gID.IsValid():
// DMs have recipients, not members.
me, _ := c.state.Cabinet.Me()
me, _ := m.state.Cabinet.Me()
users := append(slices.Clone(channel.DMRecipients), *me)
for _, r := range fuzzy.FindFrom(name, userList(users)) {
c.addMentionUser(&users[r.Index])
m.addMentionUser(&users[r.Index])
}
default:
searchCmd := c.searchMember(gID, name)
mems, err := c.state.Cabinet.Members(gID)
searchCmd := m.searchMember(gID, name)
mems, err := m.state.Cabinet.Members(gID)
if err != nil {
slog.Error("fetching members failed", "err", err)
return searchCmd
}
res := fuzzy.FindFrom(name, memberList(mems))
if len(res) > int(c.cfg.AutocompleteLimit) {
res = res[:int(c.cfg.AutocompleteLimit)]
if len(res) > int(m.cfg.AutocompleteLimit) {
res = res[:int(m.cfg.AutocompleteLimit)]
}
for _, r := range res {
if channelHasUser(c.state, channel.ID, mems[r.Index].User.ID) &&
c.addMentionMember(gID, &mems[r.Index]) {
if channelHasUser(m.state, channel.ID, mems[r.Index].User.ID) &&
m.addMentionMember(gID, &mems[r.Index]) {
break
}
}
if c.mentionsList.ItemCount() == 0 {
c.stopTabCompletion()
if m.mentionsList.ItemCount() == 0 {
m.stopTabCompletion()
return searchCmd
}
}
if c.mentionsList.ItemCount() == 0 {
c.stopTabCompletion()
if m.mentionsList.ItemCount() == 0 {
m.stopTabCompletion()
return nil
}
c.mentionsList.Rebuild()
c.mentionsVisible = true
m.mentionsList.Rebuild()
m.mentionsVisible = true
return nil
}
// searchMember performs member discovery in a command goroutine.
// It emits a follow-up suggestion message once results are loaded.
func (c *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
if name == "" {
return nil
}
key := gID.String() + " " + name
if _, ok := c.memberSearchCache[key]; ok {
if _, ok := m.memberSearchCache[key]; ok {
return nil
}
// If searching for "ab" returns less than SearchLimit, then "abc" would not return anything new because we already searched everything starting with "ab".
// This will still be true even if a new member joins because arikawa loads new members into the state.
if count, ok := c.memberSearchCache[key[:len(key)-1]]; ok {
if count < c.state.MemberState.SearchLimit {
c.memberSearchCache[key] = count
if count, ok := m.memberSearchCache[key[:len(key)-1]]; ok {
if count < m.state.MemberState.SearchLimit {
m.memberSearchCache[key] = count
return nil
}
}
now := time.Now()
// Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect
if c.lastSearch.Add(c.state.MemberState.SearchFrequency).After(now) {
if m.lastSearch.Add(m.state.MemberState.SearchFrequency).After(now) {
return nil
}
c.lastSearch = now
m.lastSearch = now
nonce := memberSearchNonce + key
return func() tview.Msg {
if err := c.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{
if err := m.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{
GuildIDs: []discord.GuildID{gID},
Query: option.Some(name),
Presences: c.state.MemberState.RequestPresences,
Limit: c.state.MemberState.SearchLimit,
Presences: m.state.MemberState.RequestPresences,
Limit: m.state.MemberState.SearchLimit,
Nonce: nonce,
}); err != nil {
slog.Error("failed to search guild members", "err", err, "guild_id", gID, "query", name)
@ -307,15 +307,15 @@ func (c *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
}
}
func (c *Model) openEditor() tview.Cmd {
if c.cfg.Editor == "" {
func (m Model) openEditor() tview.Cmd {
if m.cfg.Editor == "" {
return func() tview.Msg {
slog.Warn("Attempt to open file with editor, but no editor is set")
return nil
}
}
text := c.editState.Value()
cfg := c.cfg
text := m.editState.Value()
cfg := m.cfg
return tview.Suspend(func() tview.Msg {
file, err := os.CreateTemp("", tmpFilePattern)
if err != nil {

View file

@ -11,7 +11,7 @@ import (
var _ help.KeyMap = Model{}
func (m *Model) selectCurrentKeybind() keybind.Keybind {
func (m Model) selectCurrentKeybind() keybind.Keybind {
selectCurrent := m.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind
selectHelp := selectCurrent.Help()
selectDesc := selectHelp.Desc
@ -54,7 +54,7 @@ func (m Model) FullHelp() [][]keybind.Keybind {
}
}
func (m *Model) collapseKeybinds() []keybind.Keybind {
func (m Model) collapseKeybinds() []keybind.Keybind {
cfg := m.cfg.Keybinds.GuildsTree
var keybinds []keybind.Keybind
@ -67,10 +67,10 @@ func (m *Model) collapseKeybinds() []keybind.Keybind {
return keybinds
}
func (m *Model) canCollapseParent(node *tree.Node) bool {
func (m Model) canCollapseParent(node *tree.Node) bool {
return node != nil && len(m.root.PathTo(node)) >= 3
}
func (m *Model) canCollapseAll() bool {
func (m Model) canCollapseAll() bool {
return slices.ContainsFunc(m.root.Children(), (*tree.Node).Expanded)
}

View file

@ -42,7 +42,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
}
// CurrentNode returns the selected node, or nil for none.
func (m *Model) CurrentNode() *tree.Node {
func (m Model) CurrentNode() *tree.Node {
return m.selectionState.CurrentNode()
}
@ -111,7 +111,7 @@ func (m *Model) rebuild(event *gateway.ReadyEvent) {
m.selectionState.SetCurrentNode(root)
}
func (m *Model) refreshReadStyles(event *read.UpdateEvent) {
func (m Model) refreshReadStyles(event *read.UpdateEvent) {
if event.GuildID.IsValid() {
if node := m.findNodeByReference(event.GuildID); node != nil {
m.setNodeLineStyle(node, m.guildNodeStyle(event.GuildID))
@ -131,7 +131,7 @@ func (m *Model) refreshReadStyles(event *read.UpdateEvent) {
m.setNodeLineStyle(node, m.channelNodeStyle(*channel))
}
func (m *Model) updateDMNodeStyle(userID discord.UserID) {
func (m Model) updateDMNodeStyle(userID discord.UserID) {
channel, err := m.state.Cabinet.CreatePrivateChannel(userID)
if err != nil {
return
@ -144,7 +144,7 @@ func (m *Model) updateDMNodeStyle(userID discord.UserID) {
m.setNodeLineStyle(node, m.channelNodeStyle(*channel))
}
func (m *Model) moveDMToFront(channelID discord.ChannelID) {
func (m Model) moveDMToFront(channelID discord.ChannelID) {
if m.dmRootNode == nil {
return
}
@ -157,7 +157,7 @@ func (m *Model) moveDMToFront(channelID discord.ChannelID) {
}
}
func (m *Model) createFolderNode(folder gateway.GuildFolder, guildsByID map[discord.GuildID]*gateway.GuildCreateEvent) {
func (m Model) createFolderNode(folder gateway.GuildFolder, guildsByID map[discord.GuildID]*gateway.GuildCreateEvent) {
name := "Folder"
if folder.Name != "" {
name = folder.Name
@ -177,12 +177,12 @@ func (m *Model) createFolderNode(folder gateway.GuildFolder, guildsByID map[disc
}
}
func (m *Model) guildNodeStyle(guildID discord.GuildID) tview.Style {
func (m Model) guildNodeStyle(guildID discord.GuildID) tview.Style {
indication := m.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{IncludeMutedCategories: true})
return unreadStyle(indication)
}
func (m *Model) channelNodeStyle(channel discord.Channel) tview.Style {
func (m Model) channelNodeStyle(channel discord.Channel) tview.Style {
unread := unreadStyle(m.state.ChannelIsUnread(channel.ID, ningen.UnreadOpts{IncludeMutedCategories: true}))
if channel.Type != discord.DirectMessage || len(channel.DMRecipients) != 1 {
return unread
@ -197,7 +197,7 @@ func (m *Model) channelNodeStyle(channel discord.Channel) tview.Style {
return tview.MergeStyle(m.cfg.Theme.GuildsTree.StatusStyle(presence.Status), unread)
}
func (m *Model) createGuildNode(parent *tree.Node, guild discord.Guild) {
func (m Model) createGuildNode(parent *tree.Node, guild discord.Guild) {
guildNode := tree.NewNode(guild.Name).
SetReference(guild.ID).
SetExpandable(true).
@ -208,7 +208,7 @@ func (m *Model) createGuildNode(parent *tree.Node, guild discord.Guild) {
m.nodes[discord.Snowflake(guild.ID)] = guildNode
}
func (m *Model) createChannelNode(parent *tree.Node, channel discord.Channel) {
func (m Model) createChannelNode(parent *tree.Node, channel discord.Channel) {
if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && channel.Type != discord.GuildCategory && !m.state.HasPermissions(channel.ID, discord.PermissionViewChannel) {
return
}
@ -234,7 +234,7 @@ func (m *Model) createChannelNode(parent *tree.Node, channel discord.Channel) {
m.nodes[discord.Snowflake(channel.ID)] = channelNode
}
func (m *Model) setNodeLineStyle(node *tree.Node, style tview.Style) {
func (m Model) setNodeLineStyle(node *tree.Node, style tview.Style) {
line := node.Line()
for i := range line {
line[i].Style = style
@ -242,7 +242,7 @@ func (m *Model) setNodeLineStyle(node *tree.Node, style tview.Style) {
node.SetLine(line)
}
func (m *Model) createChannelNodes(node *tree.Node, channels []discord.Channel) {
func (m Model) createChannelNodes(node *tree.Node, channels []discord.Channel) {
// Preserve exact ordering semantics:
// 1) top-level non-categories (in input order),
// 2) categories that have at least one child in the source slice (in input order),
@ -358,7 +358,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
}
// isUnread reports whether node is an unread channel, or a guild or the direct messages whose channels are not shown yet and has one.
func (m *Model) isUnread(node *tree.Node) bool {
func (m Model) isUnread(node *tree.Node) bool {
if len(node.Children()) > 0 {
return false
}
@ -396,7 +396,7 @@ func adjacentNode(root, current *tree.Node, match func(*tree.Node) bool, previou
return nodes[after%len(nodes)]
}
func (m *Model) findNodeByReference(reference any) *tree.Node {
func (m Model) findNodeByReference(reference any) *tree.Node {
switch ref := reference.(type) {
case discord.GuildID:
return m.nodes[discord.Snowflake(ref)]
@ -418,7 +418,7 @@ func (m *Model) findNodeByReference(reference any) *tree.Node {
}
}
func (m *Model) findNodeByChannelID(channelID discord.ChannelID) *tree.Node {
func (m Model) findNodeByChannelID(channelID discord.ChannelID) *tree.Node {
channel, err := m.state.Cabinet.Channel(channelID)
if err != nil {
slog.Error("failed to get channel", "channel_id", channelID, "err", err)
@ -441,7 +441,7 @@ func (m *Model) findNodeByChannelID(channelID discord.ChannelID) *tree.Node {
return node
}
func (m *Model) expandPathToNode(node *tree.Node) {
func (m Model) expandPathToNode(node *tree.Node) {
if node == nil {
return
}

View file

@ -44,7 +44,7 @@ func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd {
return m.selectNode(node)
}
func (m *Model) selectNode(node *tree.Node) tview.Cmd {
func (m Model) selectNode(node *tree.Node) tview.Cmd {
if len(node.Children()) != 0 {
node.SetExpanded(!node.Expanded())
return nil

View file

@ -135,7 +135,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
}
// rebuild recreates the tree from the member list of the selected channel.
func (m *Model) rebuild() {
func (m Model) rebuild() {
m.root.ClearChildren()
channel := m.channel
switch {
@ -155,7 +155,7 @@ func (m *Model) rebuild() {
})
}
func (m *Model) addGuildMembers(channel *discord.Channel) {
func (m Model) addGuildMembers(channel *discord.Channel) {
list, err := m.state.MemberState.GetMemberList(channel.GuildID, channel.ID)
if err != nil {
// The list has yet to arrive.
@ -181,7 +181,7 @@ func (m *Model) addGuildMembers(channel *discord.Channel) {
})
}
func (m *Model) addRecipients(recipients []discord.User) {
func (m Model) addRecipients(recipients []discord.User) {
recipients = slices.Clone(recipients)
// Discord leaves the current user out of the recipients.
if me, err := m.state.Cabinet.Me(); err == nil {
@ -200,7 +200,7 @@ func (m *Model) addRecipients(recipients []discord.User) {
}
}
func (m *Model) addGroup(guildID discord.GuildID, group gateway.GuildMemberListGroup) *tree.Node {
func (m Model) addGroup(guildID discord.GuildID, group gateway.GuildMemberListGroup) *tree.Node {
name := "Members"
var style tview.Style
switch group.ID {
@ -232,7 +232,7 @@ func (m *Model) addGroup(guildID discord.GuildID, group gateway.GuildMemberListG
}
// addMember adds a member as Discord shows it: a status dot, then the name in the color of their highest colored role, dimmed when offline.
func (m *Model) addMember(group *tree.Node, guildID discord.GuildID, member discord.Member, index int) {
func (m Model) addMember(group *tree.Node, guildID discord.GuildID, member discord.Member, index int) {
name := cmp.Or(member.Nick, member.User.DisplayName, member.User.Username)
var nameStyle tview.Style
color, ok := state.MemberColor(&member, func(id discord.RoleID) *discord.Role {
@ -254,13 +254,13 @@ func (m *Model) addMember(group *tree.Node, guildID discord.GuildID, member disc
const statusMarker = "●"
// setStatus styles the status dot and name of a member line for status.
func (m *Model) setStatus(line richtext.Line, status discord.Status) {
func (m Model) setStatus(line richtext.Line, status discord.Status) {
line[0].Style = m.cfg.Theme.MembersTree.StatusStyle(status)
line[2].Style = line[2].Style.Dim(status == discord.OfflineStatus)
}
// reuse takes the node of id out of the index, so that it is reused once, or else returns a new node. Either way the node has no children.
func (m *Model) reuse(id string) *tree.Node {
func (m Model) reuse(id string) *tree.Node {
node, ok := m.nodes[id]
if !ok {
return tree.NewNode("")
@ -269,7 +269,7 @@ func (m *Model) reuse(id string) *tree.Node {
return node.ClearChildren()
}
func (m *Model) refreshStatus(userID discord.UserID) {
func (m Model) refreshStatus(userID discord.UserID) {
if node, ok := m.nodes[userID.String()]; ok {
line := node.Line()
m.setStatus(line, m.status(m.channel.GuildID, userID))
@ -277,7 +277,7 @@ func (m *Model) refreshStatus(userID discord.UserID) {
}
}
func (m *Model) status(guildID discord.GuildID, userID discord.UserID) discord.Status {
func (m Model) status(guildID discord.GuildID, userID discord.UserID) discord.Status {
presence, err := m.state.Cabinet.Presence(guildID, userID)
if err != nil {
return discord.OfflineStatus
@ -299,7 +299,7 @@ func statusRank(status discord.Status) int {
}
}
func (m *Model) parent(node *tree.Node) *tree.Node {
func (m Model) parent(node *tree.Node) *tree.Node {
path := m.root.PathTo(node)
if len(path) < 3 {
return nil

View file

@ -15,7 +15,7 @@ type SelectedMsg struct {
}
// requestSelectedMembers requests the chunk of the member list after the selected member, so that the members below it load before they are reached.
func (m *Model) requestSelectedMembers() tview.Cmd {
func (m Model) requestSelectedMembers() tview.Cmd {
node := m.selectionState.CurrentNode()
if node == nil {
return nil
@ -28,7 +28,7 @@ func (m *Model) requestSelectedMembers() tview.Cmd {
}
// requestMembers requests the member list of the selected guild channel up to chunk.
func (m *Model) requestMembers(chunk int) tview.Cmd {
func (m Model) requestMembers(chunk int) tview.Cmd {
channel := m.channel
if !m.Shown() || !channel.GuildID.IsValid() {
return nil

View file

@ -80,33 +80,33 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
}
// View shows the messages in a box titled with the channel and footed with footer, such as who is typing.
func (ml Model) View(focused bool, footer string) tview.Widget {
return ui.Box(ml.listView(focused), &ml.cfg.Theme, focused).Title(ml.title()).Footer(footer)
func (m Model) View(focused bool, footer string) tview.Widget {
return ui.Box(m.listView(focused), &m.cfg.Theme, focused).Title(m.title()).Footer(footer)
}
// title returns the selected channel and its topic, or "Messages" when none is selected.
func (ml *Model) title() string {
if ml.channel == nil {
func (m Model) title() string {
if m.channel == nil {
return "Messages"
}
title := ui.ChannelToString(*ml.channel, ml.cfg.Icons, ml.state)
if topic := ml.channel.Topic; topic != "" {
title := ui.ChannelToString(*m.channel, m.cfg.Icons, m.state)
if topic := m.channel.Topic; topic != "" {
title += " - " + topic
}
return title
}
func (ml *Model) listView(focused bool) list.Widget {
cfg := ml.cfg.Theme.ScrollBar
func (m Model) listView(focused bool) list.Widget {
cfg := m.cfg.Theme.ScrollBar
bar := scrollbar.New().
SymbolSet(cfg.SymbolSet.SymbolSet).
Style(cfg.TrackStyle.Style).
ThumbStyle(cfg.ThumbStyle.Style)
return list.New(ml.selectionState, len(ml.items), ml.buildItem).
SelectedStyle(ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style).
return list.New(m.selectionState, len(m.items), m.buildItem).
SelectedStyle(m.cfg.Theme.MessagesList.SelectedMessageStyle.Style).
ScrollBar(bar, cfg.Visibility.ScrollBarVisibility).
TrackEnd(true).
Keybind(scrollKeybind(ml.cfg.Keybinds.MessagesList.ScrollKeybinds)).
Keybind(scrollKeybind(m.cfg.Keybinds.MessagesList.ScrollKeybinds)).
Focused(focused).
OnChange(func(a list.Change) tview.Msg { return listMsg(a) })
}
@ -128,127 +128,127 @@ func scrollKeybind(kbs config.ScrollKeybinds) func(tview.KeyMsg) list.Action {
}
}
func (ml *Model) cursor() int {
return ml.selectionState.Cursor()
func (m Model) cursor() int {
return m.selectionState.Cursor()
}
func (ml *Model) setCursor(index int) {
ml.selectionState.SetCursor(index)
func (m *Model) setCursor(index int) {
m.selectionState.SetCursor(index)
}
// SetChannel shows messages of channel, newest first as Discord sends them, scrolled to the newest, and requests the members who wrote them.
func (ml *Model) SetChannel(channel *discord.Channel, messages []discord.Message) tview.Cmd {
ml.channel = channel
ml.reset()
ml.setMessages(messages)
func (m *Model) SetChannel(channel *discord.Channel, messages []discord.Message) tview.Cmd {
m.channel = channel
m.reset()
m.setMessages(messages)
if channel.GuildID.IsValid() {
return tview.Batch(ml.requestGuildMembers(channel.GuildID, messages), ml.loadPreviews())
return tview.Batch(m.requestGuildMembers(channel.GuildID, messages), m.loadPreviews())
}
return ml.loadPreviews()
return m.loadPreviews()
}
// ShowNewest clears the selection and scrolls to the newest message, which then stays in view as messages arrive until the user scrolls up.
func (ml *Model) ShowNewest() {
ml.clearSelection()
ml.selectionState.ScrollToEnd()
func (m *Model) ShowNewest() {
m.clearSelection()
m.selectionState.ScrollToEnd()
}
// UpdateMessage replaces the shown message with the same ID.
func (ml *Model) UpdateMessage(message discord.Message) tview.Cmd {
ml.setMessage(ml.indexOf(message.ChannelID, message.ID), message)
return ml.loadPreviews()
func (m *Model) UpdateMessage(message discord.Message) tview.Cmd {
m.setMessage(m.indexOf(message.ChannelID, message.ID), message)
return m.loadPreviews()
}
// RefreshMessage shows the cached state of a message, such as after a reaction.
func (ml *Model) RefreshMessage(channelID discord.ChannelID, id discord.MessageID) {
if message, err := ml.state.Cabinet.Message(channelID, id); err == nil {
ml.setMessage(ml.indexOf(channelID, id), *message)
func (m *Model) RefreshMessage(channelID discord.ChannelID, id discord.MessageID) {
if message, err := m.state.Cabinet.Message(channelID, id); err == nil {
m.setMessage(m.indexOf(channelID, id), *message)
}
}
// DeleteMessage removes the shown message with id.
func (ml *Model) DeleteMessage(channelID discord.ChannelID, id discord.MessageID) {
ml.deleteMessage(ml.indexOf(channelID, id))
func (m *Model) DeleteMessage(channelID discord.ChannelID, id discord.MessageID) {
m.deleteMessage(m.indexOf(channelID, id))
}
// indexOf returns the index of the message with id in the channel, or -1 if it is not shown.
func (ml *Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
if ml.channel == nil || ml.channel.ID != channelID {
func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
if m.channel == nil || m.channel.ID != channelID {
return -1
}
return slices.IndexFunc(ml.items, func(item messageItem) bool { return !item.separator && item.message.ID == id })
return slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == id })
}
func (ml *Model) reset() {
ml.items = nil
ml.selectionState = list.NewSelectionState()
ml.selectionState.ScrollToEnd()
func (m *Model) reset() {
m.items = nil
m.selectionState = list.NewSelectionState()
m.selectionState.ScrollToEnd()
}
func (ml *Model) setMessages(messages []discord.Message) {
ml.items = make([]messageItem, 0, len(messages))
func (m *Model) setMessages(messages []discord.Message) {
m.items = make([]messageItem, 0, len(messages))
for _, message := range slices.Backward(messages) {
ml.items = append(ml.items, messageItem{message: message})
m.items = append(m.items, messageItem{message: message})
}
ml.rebuildItems()
m.rebuildItems()
}
func (ml *Model) AddMessage(message discord.Message) tview.Cmd {
func (m *Model) AddMessage(message discord.Message) tview.Cmd {
// Appending keeps the items before it as they are, so only the separator of a new day is added rather than all rebuilt.
if ml.cfg.DateSeparator.Enabled && (len(ml.items) == 0 || !sameLocalDate(ml.items[len(ml.items)-1].message.Timestamp, message.Timestamp)) {
ml.items = append(ml.items, messageItem{separator: true})
if m.cfg.DateSeparator.Enabled && (len(m.items) == 0 || !sameLocalDate(m.items[len(m.items)-1].message.Timestamp, message.Timestamp)) {
m.items = append(m.items, messageItem{separator: true})
}
ml.items = append(ml.items, messageItem{message: message})
return ml.loadPreviews()
m.items = append(m.items, messageItem{message: message})
return m.loadPreviews()
}
func (ml *Model) setMessage(index int, message discord.Message) {
if index < 0 || index >= len(ml.items) || ml.items[index].separator {
func (m *Model) setMessage(index int, message discord.Message) {
if index < 0 || index >= len(m.items) || m.items[index].separator {
return
}
ml.items[index] = messageItem{message: message, previews: ml.items[index].previews}
ml.rebuildItems()
m.items[index] = messageItem{message: message, previews: m.items[index].previews}
m.rebuildItems()
}
func (ml *Model) deleteMessage(index int) {
if index < 0 || index >= len(ml.items) || ml.items[index].separator {
func (m *Model) deleteMessage(index int) {
if index < 0 || index >= len(m.items) || m.items[index].separator {
return
}
cursor := ml.cursor()
cursor := m.cursor()
if cursor == index {
cursor = ml.messageIndex(index, -1)
cursor = m.messageIndex(index, -1)
if cursor == -1 {
cursor = ml.messageIndex(index, 1)
cursor = m.messageIndex(index, 1)
}
}
if cursor > index {
cursor--
}
ml.items = slices.Delete(ml.items, index, index+1)
ml.setCursor(cursor)
ml.rebuildItems()
m.items = slices.Delete(m.items, index, index+1)
m.setCursor(cursor)
m.rebuildItems()
}
func (ml *Model) clearSelection() {
ml.setCursor(-1)
func (m *Model) clearSelection() {
m.setCursor(-1)
}
func (ml *Model) buildItem(index int) tview.Widget {
if index < 0 || index >= len(ml.items) {
func (m Model) buildItem(index int) tview.Widget {
if index < 0 || index >= len(m.items) {
return nil
}
item := &ml.items[index]
item := &m.items[index]
if item.separator {
date := ml.items[index+1].message.Timestamp.Time().In(time.Local).Format(ml.cfg.DateSeparator.Format)
return dateSeparator{date: date, fill: ml.cfg.DateSeparator.Character, style: ml.cfg.Theme.MessagesList.MessageStyle.Style.Dim(true)}
date := m.items[index+1].message.Timestamp.Time().In(time.Local).Format(m.cfg.DateSeparator.Format)
return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Style.Dim(true)}
}
if item.view == nil {
message := item.message
view := &messageView{render: func(width int) richtext.Text {
return ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style, width)
return m.renderMessage(message, m.cfg.Theme.MessagesList.MessageStyle.Style, width)
}}
for _, s := range previewSources(message) {
if preview, ok := item.previews[s.proxy]; ok {
@ -333,22 +333,22 @@ func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) {
func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }
func (ml *Model) renderMessage(message discord.Message, baseStyle tview.Style, width int) richtext.Text {
func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, width int) richtext.Text {
builder := new(richtext.Builder)
ml.writeMessage(builder, message, baseStyle, width)
m.writeMessage(builder, message, baseStyle, width)
return builder.Finish()
}
// rebuildItems replaces date separators while retaining message views and selection.
func (ml *Model) rebuildItems() {
items := make([]messageItem, 0, len(ml.items))
cursor, selected := ml.cursor(), -1
func (m *Model) rebuildItems() {
items := make([]messageItem, 0, len(m.items))
cursor, selected := m.cursor(), -1
var previous discord.Timestamp
for i, item := range ml.items {
for i, item := range m.items {
if item.separator {
continue
}
if ml.cfg.DateSeparator.Enabled && (len(items) == 0 || !sameLocalDate(previous, item.message.Timestamp)) {
if m.cfg.DateSeparator.Enabled && (len(items) == 0 || !sameLocalDate(previous, item.message.Timestamp)) {
items = append(items, messageItem{separator: true})
}
if i == cursor {
@ -357,8 +357,8 @@ func (ml *Model) rebuildItems() {
items = append(items, item)
previous = item.message.Timestamp
}
ml.items = items
ml.setCursor(selected)
m.items = items
m.setCursor(selected)
}
func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool {
@ -368,29 +368,29 @@ func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool {
}
// messageIndex finds the next message in direction (-1 or 1), excluding start.
func (ml *Model) messageIndex(start, direction int) int {
for i := start + direction; i >= 0 && i < len(ml.items); i += direction {
if !ml.items[i].separator {
func (m Model) messageIndex(start, direction int) int {
for i := start + direction; i >= 0 && i < len(m.items); i += direction {
if !m.items[i].separator {
return i
}
}
return -1
}
func (ml *Model) onRowCursorChanged(index int) {
if index < 0 || index >= len(ml.items) || !ml.items[index].separator {
func (m *Model) onRowCursorChanged(index int) {
if index < 0 || index >= len(m.items) || !m.items[index].separator {
return
}
target := ml.messageIndex(index, -1)
target := m.messageIndex(index, -1)
if target == -1 {
target = ml.messageIndex(index, 1)
target = m.messageIndex(index, 1)
}
ml.setCursor(target)
m.setCursor(target)
}
func (ml *Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
if ml.cfg.HideBlockedUsers {
isBlocked := ml.state.UserIsBlocked(message.Author.ID)
func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
if m.cfg.HideBlockedUsers {
isBlocked := m.state.UserIsBlocked(message.Author.ID)
if isBlocked {
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
return
@ -400,26 +400,26 @@ func (ml *Model) writeMessage(builder *richtext.Builder, message discord.Message
switch message.Type {
case discord.DefaultMessage:
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
ml.drawForwardedMessage(builder, message, baseStyle)
m.drawForwardedMessage(builder, message, baseStyle)
} else {
ml.drawDefaultMessage(builder, message, baseStyle, width)
m.drawDefaultMessage(builder, message, baseStyle, width)
}
case discord.GuildMemberJoinMessage:
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
ml.drawAuthor(builder, message, baseStyle)
m.drawTimestamps(builder, message.Timestamp, baseStyle)
m.drawAuthor(builder, message, baseStyle)
builder.Write("joined the server.", baseStyle)
case discord.InlinedReplyMessage:
ml.drawReplyMessage(builder, message, baseStyle, width)
m.drawReplyMessage(builder, message, baseStyle, width)
case discord.ChannelPinnedMessage:
ml.drawPinnedMessage(builder, message, baseStyle)
m.drawPinnedMessage(builder, message, baseStyle)
default:
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
ml.drawAuthor(builder, message, baseStyle)
m.drawTimestamps(builder, message.Timestamp, baseStyle)
m.drawAuthor(builder, message, baseStyle)
}
ml.drawReactions(builder, message.Reactions, baseStyle)
m.drawReactions(builder, message.Reactions, baseStyle)
}
func (ml *Model) drawReactions(builder *richtext.Builder, reactions []discord.Reaction, baseStyle tview.Style) {
func (m Model) drawReactions(builder *richtext.Builder, reactions []discord.Reaction, baseStyle tview.Style) {
if len(reactions) == 0 {
return
}
@ -434,34 +434,34 @@ func (ml *Model) drawReactions(builder *richtext.Builder, reactions []discord.Re
if reaction.Emoji.IsCustom() {
name = ":" + name + ":"
}
style := ml.cfg.Theme.MessagesList.ReactionStyle.Style
style := m.cfg.Theme.MessagesList.ReactionStyle.Style
if reaction.Me {
style = ml.cfg.Theme.MessagesList.OwnReactionStyle.Style
style = m.cfg.Theme.MessagesList.OwnReactionStyle.Style
}
builder.Write(name+" "+strconv.Itoa(reaction.Count), tview.MergeStyle(baseStyle, style))
}
}
func (ml *Model) formatTimestamp(ts discord.Timestamp) string {
return ts.Time().In(time.Local).Format(ml.cfg.Timestamps.Format)
func (m Model) formatTimestamp(ts discord.Timestamp) string {
return ts.Time().In(time.Local).Format(m.cfg.Timestamps.Format)
}
func (ml *Model) drawTimestamps(builder *richtext.Builder, ts discord.Timestamp, baseStyle tview.Style) {
func (m Model) drawTimestamps(builder *richtext.Builder, ts discord.Timestamp, baseStyle tview.Style) {
dimStyle := baseStyle.Dim(true)
builder.Write(ml.formatTimestamp(ts)+" ", dimStyle)
builder.Write(m.formatTimestamp(ts)+" ", dimStyle)
}
func (ml *Model) drawAuthor(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
func (m Model) drawAuthor(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
name := message.Author.DisplayOrUsername()
foreground := tcell.ColorDefault
if member := ml.memberForMessage(message); member != nil {
if member := m.memberForMessage(message); member != nil {
if member.Nick != "" {
name = member.Nick
}
color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
r, _ := ml.state.Cabinet.Role(message.GuildID, id)
r, _ := m.state.Cabinet.Role(message.GuildID, id)
return r
})
if ok {
@ -474,13 +474,13 @@ func (ml *Model) drawAuthor(builder *richtext.Builder, message discord.Message,
builder.Write(" ", baseStyle)
}
func (ml *Model) memberForMessage(message discord.Message) *discord.Member {
func (m Model) 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.state.Cabinet.Member(message.GuildID, message.Author.ID)
member, err := m.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
@ -490,9 +490,9 @@ func (ml *Model) memberForMessage(message discord.Message) *discord.Member {
// drawContent renders the message body and returns the parsed markdown AST together with the source bytes it indexes into, so callers can reuse them instead of re-parsing the same content (see drawEmbeds).
// root is nil when markdown rendering is disabled.
func (ml *Model) drawContent(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) (ast.Node, []byte) {
content, root, source := ml.renderContent(message, baseStyle, false)
if ml.cfg.Markdown.Enabled && !builder.LineEmpty() {
func (m Model) drawContent(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) (ast.Node, []byte) {
content, root, source := m.renderContent(message, baseStyle, false)
if m.cfg.Markdown.Enabled && !builder.LineEmpty() {
startsWithCodeBlock := false
if root != nil {
if first := root.FirstChild(); first != nil {
@ -516,12 +516,12 @@ func (ml *Model) drawContent(builder *richtext.Builder, message discord.Message,
return root, source
}
func (ml *Model) renderContent(message discord.Message, baseStyle tview.Style, forceMarkdown bool) (richtext.Text, ast.Node, []byte) {
func (m Model) renderContent(message discord.Message, baseStyle tview.Style, forceMarkdown bool) (richtext.Text, ast.Node, []byte) {
// Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both.
if forceMarkdown || ml.cfg.Markdown.Enabled {
if forceMarkdown || m.cfg.Markdown.Enabled {
c := []byte(message.Content)
root := md.ParseWithMessage(c, *ml.state.Cabinet, &message)
return ml.renderer.RenderText(c, root, baseStyle), root, c
root := md.ParseWithMessage(c, *m.state.Cabinet, &message)
return m.renderer.RenderText(c, root, baseStyle), root, c
}
b := new(richtext.Builder)
@ -529,7 +529,7 @@ func (ml *Model) renderContent(message discord.Message, baseStyle tview.Style, f
return b.Finish(), nil, nil
}
func (ml *Model) drawSnapshotContent(builder *richtext.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tview.Style) {
func (m Model) drawSnapshotContent(builder *richtext.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tview.Style) {
// Convert discord.MessageSnapshotMessage to discord.Message with common fields.
message := discord.Message{
Type: snapshot.Type,
@ -546,28 +546,28 @@ func (ml *Model) drawSnapshotContent(builder *richtext.Builder, parent discord.M
ChannelID: parent.ChannelID,
GuildID: parent.GuildID,
}
ml.drawContent(builder, message, baseStyle)
m.drawContent(builder, message, baseStyle)
}
func (ml *Model) drawDefaultMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
if ml.cfg.Timestamps.Enabled {
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
if m.cfg.Timestamps.Enabled {
m.drawTimestamps(builder, message.Timestamp, baseStyle)
}
ml.drawAuthor(builder, message, baseStyle)
contentRoot, contentSource := ml.drawContent(builder, message, baseStyle)
m.drawAuthor(builder, message, baseStyle)
contentRoot, contentSource := m.drawContent(builder, message, baseStyle)
if message.EditedTimestamp.IsValid() {
dimStyle := baseStyle.Dim(true)
builder.Write(" (edited)", dimStyle)
}
ml.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource)
m.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource)
attachmentStyle := tview.MergeStyle(baseStyle, ml.cfg.Theme.MessagesList.AttachmentStyle.Style)
attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style)
for _, a := range message.Attachments {
builder.NewLine()
if ml.cfg.Attachments.ShowLinks {
if m.cfg.Attachments.ShowLinks {
builder.Write(a.Filename+":", attachmentStyle)
builder.NewLine()
builder.Write(a.URL, attachmentStyle.Url(a.URL))
@ -577,7 +577,7 @@ func (ml *Model) drawDefaultMessage(builder *richtext.Builder, message discord.M
}
}
func (ml *Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) {
func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) {
if len(message.Embeds) == 0 {
return
}
@ -595,7 +595,7 @@ func (ml *Model) drawEmbeds(builder *richtext.Builder, message discord.Message,
contentURLs[u] = struct{}{}
}
lineStyles := embedLineStyles(baseStyle, ml.cfg.Theme.MessagesList.Embeds)
lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds)
defaultBarStyle := baseStyle.Dim(true)
prefixText := " ▎ "
prefixWidth := uniseg.StringWidth(prefixText)
@ -623,7 +623,7 @@ func (ml *Model) drawEmbeds(builder *richtext.Builder, message discord.Message,
msg.Content = line.Text
lineStyle := lineStyles[line.Kind]
// Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled.
rendered, _, _ := ml.renderContent(msg, lineStyle, line.Kind == embedLineDescription)
rendered, _, _ := m.renderContent(msg, lineStyle, line.Kind == embedLineDescription)
for _, renderedLine := range rendered {
if line.URL != "" {
renderedLine = lineWithURL(renderedLine, line.URL)
@ -799,174 +799,174 @@ func isMarkdownEscapable(c byte) bool {
}
}
func (ml *Model) drawForwardedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
dimStyle := baseStyle.Dim(true)
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
ml.drawAuthor(builder, message, baseStyle)
builder.Write(ml.cfg.Theme.MessagesList.ForwardedIndicator+" ", dimStyle)
ml.drawSnapshotContent(builder, message, message.MessageSnapshots[0].Message, baseStyle)
builder.Write(" ("+ml.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle)
m.drawTimestamps(builder, message.Timestamp, baseStyle)
m.drawAuthor(builder, message, baseStyle)
builder.Write(m.cfg.Theme.MessagesList.ForwardedIndicator+" ", dimStyle)
m.drawSnapshotContent(builder, message, message.MessageSnapshots[0].Message, baseStyle)
builder.Write(" ("+m.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle)
}
func (ml *Model) drawReplyMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
func (m Model) drawReplyMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
dimStyle := baseStyle.Dim(true)
builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", dimStyle)
builder.Write(m.cfg.Theme.MessagesList.ReplyIndicator+" ", dimStyle)
if m := message.ReferencedMessage; m != nil {
m.GuildID = message.GuildID
ml.drawAuthor(builder, *m, dimStyle)
ml.drawContent(builder, *m, dimStyle)
if reference := message.ReferencedMessage; reference != nil {
reference.GuildID = message.GuildID
m.drawAuthor(builder, *reference, dimStyle)
m.drawContent(builder, *reference, dimStyle)
} else {
builder.Write("Original message was deleted", dimStyle)
}
builder.NewLine()
ml.drawDefaultMessage(builder, message, baseStyle, width)
m.drawDefaultMessage(builder, message, baseStyle, width)
}
func (ml *Model) drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
func (m Model) drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
builder.Write(message.Author.DisplayOrUsername()+" pinned a message.", baseStyle)
}
func (ml *Model) selectedMessage() (*discord.Message, bool) {
cursor := ml.cursor()
if cursor < 0 || cursor >= len(ml.items) || ml.items[cursor].separator {
func (m Model) selectedMessage() (*discord.Message, bool) {
cursor := m.cursor()
if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator {
return nil, false
}
return &ml.items[cursor].message, true
return &m.items[cursor].message, true
}
// Update returns ml changed in response to msg and a command to run, or nil.
func (ml Model) Update(msg tview.Msg) (Model, tview.Cmd) {
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
switch msg := msg.(type) {
case tview.KeyMsg:
switch {
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
ml.clearSelection()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind):
return ml, ml.selectUp()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind):
ml.selectDown()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.SelectTop.Keybind):
return ml, ml.selectTop()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.SelectBottom.Keybind):
ml.selectBottom()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.SelectReply.Keybind):
ml.selectReply()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.YankID.Keybind):
return ml, ml.yankMessageID()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.YankContent.Keybind):
return ml, ml.yankContent()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.YankURL.Keybind):
return ml, ml.yankURL()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Open.Keybind):
return ml, ml.open()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.OpenInBrowser.Keybind):
return ml, ml.openInBrowser()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Download.Keybind):
return ml, ml.download()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Reply.Keybind):
return ml, ml.reply(false)
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.ReplyMention.Keybind):
return ml, ml.reply(true)
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Edit.Keybind):
return ml, ml.editSelectedMessage()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Delete.Keybind):
return ml, ml.deleteSelectedMessage()
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind):
return ml, ml.confirmDelete()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Cancel.Keybind):
m.clearSelection()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SelectUp.Keybind):
return m, m.selectUp()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SelectDown.Keybind):
m.selectDown()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SelectTop.Keybind):
return m, m.selectTop()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SelectBottom.Keybind):
m.selectBottom()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SelectReply.Keybind):
m.selectReply()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.YankID.Keybind):
return m, m.yankMessageID()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.YankContent.Keybind):
return m, m.yankContent()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.YankURL.Keybind):
return m, m.yankURL()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Open.Keybind):
return m, m.open()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.OpenInBrowser.Keybind):
return m, m.openInBrowser()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Download.Keybind):
return m, m.download()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Reply.Keybind):
return m, m.reply(false)
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.ReplyMention.Keybind):
return m, m.reply(true)
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Edit.Keybind):
return m, m.editSelectedMessage()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Delete.Keybind):
return m, m.deleteSelectedMessage()
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind):
return m, m.confirmDelete()
}
case olderMessagesLoadedMsg:
selectedChannel := ml.channel
selectedChannel := m.channel
if selectedChannel == nil || selectedChannel.ID != msg.ChannelID {
return ml, nil
return m, nil
}
prevCursor := ml.cursor()
prevCursor := m.cursor()
older := make([]messageItem, len(msg.Older))
for i, message := range msg.Older {
older[i] = messageItem{message: message}
}
ml.items = slices.Concat(older, ml.items)
m.items = slices.Concat(older, m.items)
if prevCursor >= 0 {
ml.setCursor(prevCursor + len(older))
m.setCursor(prevCursor + len(older))
}
ml.rebuildItems()
m.rebuildItems()
if selectedChannel.GuildID.IsValid() {
return ml, tview.Batch(ml.requestGuildMembers(selectedChannel.GuildID, msg.Older), ml.loadPreviews())
return m, tview.Batch(m.requestGuildMembers(selectedChannel.GuildID, msg.Older), m.loadPreviews())
}
return ml, ml.loadPreviews()
return m, m.loadPreviews()
case previewLoadedMsg:
index := slices.IndexFunc(ml.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID })
if index < 0 || ml.items[index].previews == nil {
return ml, nil
index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID })
if index < 0 || m.items[index].previews == nil {
return m, nil
}
preview, cmd := msg.preview, tview.Cmd(nil)
if ml.kitty {
ml.kittyImages++
preview = preview.Kitty(ml.kittyImages%63488 + 1)
if m.kitty {
m.kittyImages++
preview = preview.Kitty(m.kittyImages%63488 + 1)
cmd = preview.Transmit()
}
ml.items[index].previews[msg.proxy] = preview
m.items[index].previews[msg.proxy] = preview
// Invalidate the view so it is laid out with the preview.
ml.items[index].view = nil
return ml, cmd
m.items[index].view = nil
return m, cmd
case tview.ResizeMsg:
cols, rows := msg.Size()
width, height := msg.PixelSize()
if cols > 0 && rows > 0 {
ml.cellWidth, ml.cellHeight = uint(width/cols), uint(height/rows)
m.cellWidth, m.cellHeight = uint(width/cols), uint(height/rows)
}
case tview.TerminalInfoMsg:
switch ml.cfg.Attachments.Protocol {
switch m.cfg.Attachments.Protocol {
case "kitty":
ml.kitty = true
m.kitty = true
case "halfblocks":
ml.kitty = false
m.kitty = false
default:
// WezTerm and Konsole support kitty graphics but not Unicode placeholders.
ml.kitty = msg.Name == "kitty" || msg.Name == "ghostty"
m.kitty = msg.Name == "kitty" || msg.Name == "ghostty"
}
case listMsg:
top := ml.atTop()
ml.selectionState.Apply(list.Change(msg))
ml.onRowCursorChanged(ml.cursor())
if !top && ml.atTop() {
return ml, ml.fetchOlderMessages()
top := m.atTop()
m.selectionState.Apply(list.Change(msg))
m.onRowCursorChanged(m.cursor())
if !top && m.atTop() {
return m, m.fetchOlderMessages()
}
}
return ml, nil
return m, nil
}
// atTop reports whether the oldest message shown is selected.
func (ml *Model) atTop() bool {
return ml.cursor() >= 0 && ml.cursor() == ml.messageIndex(-1, 1)
func (m Model) atTop() bool {
return m.cursor() >= 0 && m.cursor() == m.messageIndex(-1, 1)
}
func (ml *Model) selectDown() {
if ml.cursor() == -1 {
ml.selectBottom()
} else if next := ml.messageIndex(ml.cursor(), 1); next >= 0 {
ml.setCursor(next)
func (m *Model) selectDown() {
if m.cursor() == -1 {
m.selectBottom()
} else if next := m.messageIndex(m.cursor(), 1); next >= 0 {
m.setCursor(next)
}
}
func (ml *Model) selectTop() tview.Cmd {
if ml.atTop() {
func (m *Model) selectTop() tview.Cmd {
if m.atTop() {
return nil
}
ml.setCursor(ml.messageIndex(-1, 1))
return ml.fetchOlderMessages()
m.setCursor(m.messageIndex(-1, 1))
return m.fetchOlderMessages()
}
func (ml *Model) selectBottom() {
ml.setCursor(ml.messageIndex(len(ml.items), -1))
func (m *Model) selectBottom() {
m.setCursor(m.messageIndex(len(m.items), -1))
}
func (ml *Model) selectReply() {
messages := ml.items
cursor := ml.cursor()
func (m *Model) selectReply() {
messages := m.items
cursor := m.cursor()
if cursor < 0 || cursor >= len(messages) {
return
}
@ -976,7 +976,7 @@ func (ml *Model) selectReply() {
return !m.separator && m.message.ID == ref.ID
})
if refIdx != -1 {
ml.setCursor(refIdx)
m.setCursor(refIdx)
}
}
}
@ -1065,45 +1065,45 @@ func downloadAttachment(attachment discord.Attachment, destination string) error
}
// SelectLastOwn selects the last message in the selected channel that the user can edit, and returns it.
func (ml *Model) SelectLastOwn() (discord.Message, bool) {
channel := ml.channel
func (m *Model) SelectLastOwn() (discord.Message, bool) {
channel := m.channel
if channel == nil {
return discord.Message{}, false
}
for i, item := range slices.Backward(ml.items) {
for i, item := range slices.Backward(m.items) {
message := item.message
if item.separator || message.ChannelID != channel.ID || !ui.IsMe(ml.state, message.Author.ID) ||
if item.separator || message.ChannelID != channel.ID || !ui.IsMe(m.state, message.Author.ID) ||
!message.ID.IsValid() || message.Content == "" || len(message.MessageSnapshots) > 0 ||
(message.Type != discord.DefaultMessage && message.Type != discord.InlinedReplyMessage) {
continue
}
ml.setCursor(i)
m.setCursor(i)
return message, true
}
return discord.Message{}, false
}
func (ml *Model) canDeleteMessage(message discord.Message) bool {
return ui.IsMe(ml.state, message.Author.ID) ||
(message.GuildID.IsValid() && ml.state.HasPermissions(message.ChannelID, discord.PermissionManageMessages))
func (m Model) canDeleteMessage(message discord.Message) bool {
return ui.IsMe(m.state, message.Author.ID) ||
(message.GuildID.IsValid() && m.state.HasPermissions(message.ChannelID, discord.PermissionManageMessages))
}
func (ml *Model) InvalidateRendered() {
for i := range ml.items {
ml.items[i].view = nil
func (m Model) InvalidateRendered() {
for i := range m.items {
m.items[i].view = nil
}
}
func (ml Model) ShortHelp() []keybind.Keybind {
cfg := ml.cfg.Keybinds.MessagesList
func (m Model) ShortHelp() []keybind.Keybind {
cfg := m.cfg.Keybinds.MessagesList
help := []keybind.Keybind{
cfg.SelectUp.Keybind,
cfg.SelectDown.Keybind,
cfg.Cancel.Keybind,
}
if selectedMessage, ok := ml.selectedMessage(); ok {
if !ui.IsMe(ml.state, selectedMessage.Author.ID) {
if selectedMessage, ok := m.selectedMessage(); ok {
if !ui.IsMe(m.state, selectedMessage.Author.ID) {
help = append(help, cfg.Reply.Keybind)
}
if len(selectedMessage.Attachments) != 0 || len(messageURLs(*selectedMessage)) != 0 {
@ -1114,11 +1114,11 @@ func (ml Model) ShortHelp() []keybind.Keybind {
return help
}
func (ml Model) FullHelp() [][]keybind.Keybind {
cfg := ml.cfg.Keybinds.MessagesList
func (m Model) FullHelp() [][]keybind.Keybind {
cfg := m.cfg.Keybinds.MessagesList
var actions, manage, attachments []keybind.Keybind
if message, ok := ml.selectedMessage(); ok {
mine := ui.IsMe(ml.state, message.Author.ID)
if message, ok := m.selectedMessage(); ok {
mine := ui.IsMe(m.state, message.Author.ID)
if !mine {
actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind)
}
@ -1128,7 +1128,7 @@ func (ml Model) FullHelp() [][]keybind.Keybind {
if mine {
manage = append(manage, cfg.Edit.Keybind)
}
if ml.canDeleteMessage(*message) {
if m.canDeleteMessage(*message) {
manage = append(manage, cfg.DeleteConfirm.Keybind)
if len(cfg.Delete.Keys()) != 0 {
manage = append(manage, cfg.Delete.Keybind)

View file

@ -47,35 +47,35 @@ type ShowAttachmentsMsg picker.Items
// listMsg moves or scrolls the messages list.
type listMsg list.Change
func (ml *Model) selectUp() tview.Cmd {
cursor := ml.cursor()
func (m *Model) selectUp() tview.Cmd {
cursor := m.cursor()
if cursor == -1 {
ml.selectBottom()
} else if previous := ml.messageIndex(cursor, -1); previous >= 0 {
ml.setCursor(previous)
m.selectBottom()
} else if previous := m.messageIndex(cursor, -1); previous >= 0 {
m.setCursor(previous)
// Older messages are fetched once as the cursor reaches the top, rather than on each key press there.
if ml.atTop() {
return ml.fetchOlderMessages()
if m.atTop() {
return m.fetchOlderMessages()
}
}
return nil
}
func (ml *Model) fetchOlderMessages() tview.Cmd {
selectedChannel := ml.channel
func (m Model) fetchOlderMessages() tview.Cmd {
selectedChannel := m.channel
if selectedChannel == nil {
return nil
}
channelID := selectedChannel.ID
first := ml.messageIndex(-1, 1)
first := m.messageIndex(-1, 1)
if first == -1 {
return nil
}
before := ml.items[first].message.ID
limit := uint(ml.cfg.MessagesLimit)
before := m.items[first].message.ID
limit := uint(m.cfg.MessagesLimit)
return func() tview.Msg {
messages, err := ml.state.MessagesBefore(channelID, before, limit)
messages, err := m.state.MessagesBefore(channelID, before, limit)
if err != nil {
slog.Error("failed to fetch older messages", "err", err)
return nil
@ -90,8 +90,8 @@ func (ml *Model) fetchOlderMessages() tview.Cmd {
}
}
func (ml *Model) yankMessageID() tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
func (m Model) yankMessageID() tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok {
return nil
}
@ -104,8 +104,8 @@ func (ml *Model) yankMessageID() tview.Cmd {
}
}
func (ml *Model) yankContent() tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
func (m Model) yankContent() tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok {
return nil
}
@ -118,8 +118,8 @@ func (ml *Model) yankContent() tview.Cmd {
}
}
func (ml *Model) yankURL() tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
func (m Model) yankURL() tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok {
return nil
}
@ -132,16 +132,16 @@ func (ml *Model) yankURL() tview.Cmd {
}
}
func (ml *Model) open() tview.Cmd {
return ml.openWith(ml.openAttachment)
func (m Model) open() tview.Cmd {
return m.openWith(m.openAttachment)
}
func (ml *Model) openInBrowser() tview.Cmd {
return ml.openWith(func(attachment discord.Attachment) tview.Cmd { return openURL(attachment.URL) })
func (m Model) openInBrowser() tview.Cmd {
return m.openWith(func(attachment discord.Attachment) tview.Cmd { return openURL(attachment.URL) })
}
func (ml *Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
func (m Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok {
return nil
}
@ -151,7 +151,7 @@ func (ml *Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tvi
case total == 0:
return nil
case total > 1:
return ml.showAttachmentsList(urls, selectedMessage.Attachments, openAttachment)
return m.showAttachmentsList(urls, selectedMessage.Attachments, openAttachment)
case len(urls) == 1:
return openURL(urls[0])
}
@ -159,24 +159,24 @@ func (ml *Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tvi
return openAttachment(selectedMessage.Attachments[0])
}
func (ml *Model) download() tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
func (m Model) download() tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok || len(selectedMessage.Attachments) == 0 {
return nil
}
if len(selectedMessage.Attachments) == 1 {
attachment := selectedMessage.Attachments[0]
return ml.confirmAttachment(attachment, saveAttachment(attachment))
return m.confirmAttachment(attachment, saveAttachment(attachment))
}
items := make(picker.Items, len(selectedMessage.Attachments))
for i, attachment := range selectedMessage.Attachments {
items[i] = picker.Item{Text: attachment.Filename, Reference: ml.confirmAttachment(attachment, saveAttachment(attachment))}
items[i] = picker.Item{Text: attachment.Filename, Reference: m.confirmAttachment(attachment, saveAttachment(attachment))}
}
return ml.showAttachmentsPicker(items)
return m.showAttachmentsPicker(items)
}
func (ml *Model) showAttachmentsList(urls []string, attachments []discord.Attachment, openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd {
func (m Model) showAttachmentsList(urls []string, attachments []discord.Attachment, openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd {
var items picker.Items
for _, attachment := range attachments {
items = append(items, picker.Item{Text: attachment.Filename, Reference: openAttachment(attachment)})
@ -184,19 +184,19 @@ func (ml *Model) showAttachmentsList(urls []string, attachments []discord.Attach
for _, url := range urls {
items = append(items, picker.Item{Text: url, Reference: openURL(url)})
}
return ml.showAttachmentsPicker(items)
return m.showAttachmentsPicker(items)
}
func (ml *Model) showAttachmentsPicker(items picker.Items) tview.Cmd {
func (m Model) showAttachmentsPicker(items picker.Items) tview.Cmd {
return func() tview.Msg { return ShowAttachmentsMsg(items) }
}
func (ml *Model) openAttachment(attachment discord.Attachment) tview.Cmd {
return ml.confirmAttachment(attachment, openDownloadedAttachment(attachment))
func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd {
return m.confirmAttachment(attachment, openDownloadedAttachment(attachment))
}
func (ml *Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd {
if !ml.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) {
func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd {
if !m.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) {
return ui.ShowModal(
"This attachment type is not allowed and may be unsafe. Continue anyway?",
ui.ModalButton{Label: "No"},
@ -266,14 +266,14 @@ func openURL(url string) tview.Cmd {
}
}
func (ml *Model) reply(mention bool) tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
func (m Model) reply(mention bool) tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok {
return nil
}
name := selectedMessage.Author.DisplayOrUsername()
if member := ml.memberForMessage(*selectedMessage); member != nil && member.Nick != "" {
if member := m.memberForMessage(*selectedMessage); member != nil && member.Nick != "" {
name = member.Nick
}
@ -281,13 +281,13 @@ func (ml *Model) reply(mention bool) tview.Cmd {
return func() tview.Msg { return ReplyMsg{Message: message, Name: name, Mention: mention} }
}
func (ml *Model) editSelectedMessage() tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
func (m Model) editSelectedMessage() tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok {
return nil
}
if !ui.IsMe(ml.state, selectedMessage.Author.ID) {
if !ui.IsMe(m.state, selectedMessage.Author.ID) {
slog.Error("failed to edit message; not the author", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID)
return nil
}
@ -296,47 +296,47 @@ func (ml *Model) editSelectedMessage() tview.Cmd {
return func() tview.Msg { return EditMsg(message) }
}
func (ml *Model) confirmDelete() tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
if !ok || !ml.canDeleteMessage(*selectedMessage) {
func (m Model) confirmDelete() tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok || !m.canDeleteMessage(*selectedMessage) {
return nil
}
message := *selectedMessage
return ui.ShowModal(
"Are you sure you want to delete this message?",
ui.ModalButton{Label: "Yes", Cmd: ml.requestDelete(message)},
ui.ModalButton{Label: "Yes", Cmd: m.requestDelete(message)},
ui.ModalButton{Label: "No"},
)
}
func (ml *Model) deleteSelectedMessage() tview.Cmd {
selectedMessage, ok := ml.selectedMessage()
func (m Model) deleteSelectedMessage() tview.Cmd {
selectedMessage, ok := m.selectedMessage()
if !ok {
return nil
}
return ml.requestDelete(*selectedMessage)
return m.requestDelete(*selectedMessage)
}
func (ml *Model) requestDelete(message discord.Message) tview.Cmd {
func (m Model) requestDelete(message discord.Message) tview.Cmd {
return func() tview.Msg {
if !ml.canDeleteMessage(message) {
if !m.canDeleteMessage(message) {
slog.Error("failed to delete message; missing relevant permissions", "channel_id", message.ChannelID, "message_id", message.ID)
return nil
}
if err := ml.state.DeleteMessage(message.ChannelID, message.ID, ""); err != nil {
if err := m.state.DeleteMessage(message.ChannelID, message.ID, ""); err != nil {
slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err)
return nil
}
if err := ml.state.MessageRemove(message.ChannelID, message.ID); err != nil {
if err := m.state.MessageRemove(message.ChannelID, message.ID); err != nil {
slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err)
}
return nil
}
}
func (ml *Model) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) tview.Cmd {
func (m Model) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) tview.Cmd {
usersToFetch := make([]discord.UserID, 0, len(messages))
seen := make(map[discord.UserID]struct{}, len(messages))
@ -346,7 +346,7 @@ func (ml *Model) requestGuildMembers(guildID discord.GuildID, messages []discord
continue
}
if member, _ := ml.state.Cabinet.Member(guildID, message.Author.ID); member == nil {
if member, _ := m.state.Cabinet.Member(guildID, message.Author.ID); member == nil {
userID := message.Author.ID
if _, ok := seen[userID]; !ok {
seen[userID] = struct{}{}
@ -360,7 +360,7 @@ func (ml *Model) requestGuildMembers(guildID discord.GuildID, messages []discord
}
return func() tview.Msg {
if err := ml.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{
if err := m.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{
GuildIDs: []discord.GuildID{guildID},
UserIDs: usersToFetch,
}); err != nil {

View file

@ -63,24 +63,24 @@ func (s previewSource) cols(cellWidth, cellHeight uint) int {
}
// previewCell returns the size of a cell in image pixels: the terminal's for kitty, and one pixel by two for half blocks.
func (ml *Model) previewCell() (width, height uint) {
func (m Model) previewCell() (width, height uint) {
switch {
case !ml.kitty:
case !m.kitty:
return 1, 2
case ml.cellWidth == 0 || ml.cellHeight == 0:
case m.cellWidth == 0 || m.cellHeight == 0:
return kittyPixels, kittyPixels * 2
}
return ml.cellWidth, ml.cellHeight
return m.cellWidth, m.cellHeight
}
func (ml *Model) loadPreviews() tview.Cmd {
if !ml.cfg.Attachments.Preview {
func (m Model) loadPreviews() tview.Cmd {
if !m.cfg.Attachments.Preview {
return nil
}
cellWidth, cellHeight := ml.previewCell()
cellWidth, cellHeight := m.previewCell()
var cmds []tview.Cmd
for i := range ml.items {
item := &ml.items[i]
for i := range m.items {
item := &m.items[i]
for _, s := range previewSources(item.message) {
if _, ok := item.previews[s.proxy]; ok {
continue

View file

@ -80,7 +80,7 @@ type typingExpiredMsg struct {
}
// addTyper shows userID as typing and returns a command that ends it after the typing duration.
func (m *Model) addTyper(userID discord.UserID) tview.Cmd {
func (m Model) addTyper(userID discord.UserID) tview.Cmd {
m.typers[userID] = time.Now().Add(composer.TypingDuration)
return func() tview.Msg {
time.Sleep(composer.TypingDuration)

View file

@ -38,7 +38,7 @@ func (m *Model) addMessageOrNotify(message *gateway.MessageCreateEvent) tview.Cm
return tview.Batch(cmd, m.notify(*message))
}
func (m *Model) notify(message gateway.MessageCreateEvent) tview.Cmd {
func (m Model) notify(message gateway.MessageCreateEvent) tview.Cmd {
return func() tview.Msg {
if !m.cfg.Notifications.Enabled || m.cfg.Status == discord.DoNotDisturbStatus {
return nil
@ -84,12 +84,12 @@ func (m *Model) notify(message gateway.MessageCreateEvent) tview.Cmd {
}
}
func (m *Model) refreshMemberNames(event *gateway.GuildMembersChunkEvent) tview.Cmd {
func (m Model) refreshMemberNames(event *gateway.GuildMembersChunkEvent) tview.Cmd {
m.messagesList.InvalidateRendered()
return m.composer.CacheMemberSearch(event)
}
func (m *Model) showTypingIndicator(event *gateway.TypingStartEvent) tview.Cmd {
func (m Model) showTypingIndicator(event *gateway.TypingStartEvent) tview.Cmd {
if channel := m.selectedChannel; channel == nil || channel.ID != event.ChannelID || ui.IsMe(m.state, event.UserID) {
return nil
}