refactor(ui): give command methods value receivers
This commit is contained in:
parent
c3cb114e22
commit
0dc3f21501
12 changed files with 583 additions and 583 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue