diff --git a/channel.go b/channel.go index 887a260..646259b 100644 --- a/channel.go +++ b/channel.go @@ -227,7 +227,7 @@ func (ch *Channel) JoinServer(ctx context.Context, ct cchat.MessagesContainer) ( continue } - ct.UpdateMessage(NewMessageUpdateAuthor(msg, member, *g)) + ct.UpdateMessage(NewMessageUpdateAuthor(msg, member, *g, ch.session)) } } })) @@ -472,7 +472,7 @@ func (ch *Channel) TypingSubscribe(ti cchat.TypingIndicator) (func(), error) { if t.ChannelID != ch.id || t.UserID == ch.session.userID { return } - if typer, err := NewTyper(ch.session.Store, t); err == nil { + if typer, err := NewTyper(ch.session, t); err == nil { ti.AddTyper(typer) } }), nil diff --git a/channel_completion.go b/channel_completion.go index e339860..a4e96cd 100644 --- a/channel_completion.go +++ b/channel_completion.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/diamondburned/arikawa/discord" - "github.com/diamondburned/arikawa/state" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-discord/urlutils" "github.com/diamondburned/cchat/text" @@ -12,13 +11,13 @@ import ( const MaxCompletion = 15 -func completionUserEntry(s state.Store, u discord.User, g *discord.Guild) cchat.CompletionEntry { +func completionUserEntry(s *Session, u discord.User, g *discord.Guild) cchat.CompletionEntry { if g != nil { - m, err := s.Member(g.ID, u.ID) + m, err := s.Store.Member(g.ID, u.ID) if err == nil { return cchat.CompletionEntry{ Raw: u.Mention(), - Text: RenderMemberName(*m, *g), + Text: RenderMemberName(*m, *g, s), Secondary: text.Rich{Content: u.Username + "#" + u.Discriminator}, IconURL: u.AvatarURL(), } @@ -51,7 +50,7 @@ func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntr // Record the current author and add the entry to the list. authors[msg.Author.ID] = struct{}{} - entries = append(entries, completionUserEntry(ch.session.Store, msg.Author, g)) + entries = append(entries, completionUserEntry(ch.session, msg.Author, g)) if len(entries) >= MaxCompletion { return @@ -108,7 +107,7 @@ func (ch *Channel) completeMentions(word string) (entries []cchat.CompletionEntr if contains(match, mem.User.Username, mem.Nick) { entries = append(entries, cchat.CompletionEntry{ Raw: mem.User.Mention(), - Text: RenderMemberName(mem, *g), + Text: RenderMemberName(mem, *g, ch.session), Secondary: text.Rich{Content: mem.User.Username + "#" + mem.User.Discriminator}, IconURL: mem.User.AvatarURL(), }) diff --git a/message.go b/message.go index 212e148..56e3f4e 100644 --- a/message.go +++ b/message.go @@ -63,7 +63,7 @@ type Author struct { avatar string } -func NewUser(u discord.User) Author { +func NewUser(u discord.User, s *Session) Author { var name = text.Rich{Content: u.Username} if u.Bot { name.Content += " " @@ -73,9 +73,9 @@ func NewUser(u discord.User) Author { } // Append a clickable user popup. - name.Segments = append(name.Segments, - segments.UserSegment(0, len(name.Content), u), - ) + useg := segments.UserSegment(0, len(name.Content), u) + useg.WithState(s.State) + name.Segments = append(name.Segments, useg) return Author{ id: u.ID, @@ -84,15 +84,15 @@ func NewUser(u discord.User) Author { } } -func NewGuildMember(m discord.Member, g discord.Guild) Author { +func NewGuildMember(m discord.Member, g discord.Guild, s *Session) Author { return Author{ id: m.User.ID, - name: RenderMemberName(m, g), + name: RenderMemberName(m, g, s), avatar: AvatarURL(m.User.AvatarURL()), } } -func RenderMemberName(m discord.Member, g discord.Guild) text.Rich { +func RenderMemberName(m discord.Member, g discord.Guild, s *Session) text.Rich { var name = text.Rich{ Content: m.User.Username, } @@ -118,9 +118,9 @@ func RenderMemberName(m discord.Member, g discord.Guild) text.Rich { } // Append a clickable user popup. - name.Segments = append(name.Segments, - segments.MemberSegment(0, len(name.Content), g, m), - ) + useg := segments.MemberSegment(0, len(name.Content), g, m) + useg.WithState(s.State) + name.Segments = append(name.Segments, useg) return name } @@ -163,10 +163,12 @@ func NewMessageUpdateContent(msg discord.Message, s *Session) Message { } } -func NewMessageUpdateAuthor(msg discord.Message, member discord.Member, g discord.Guild) Message { +func NewMessageUpdateAuthor( + msg discord.Message, member discord.Member, g discord.Guild, s *Session) Message { + return Message{ messageHeader: newHeader(msg), - author: NewGuildMember(member, g), + author: NewGuildMember(member, g, s), } } @@ -176,7 +178,7 @@ func NewMessageCreate(c *gateway.MessageCreateEvent, s *Session) Message { // This should not error. g, err := s.Store.Guild(c.GuildID) if err != nil { - return NewMessage(c.Message, s, NewUser(c.Author)) + return NewMessage(c.Message, s, NewUser(c.Author, s)) } if c.Member == nil { @@ -184,10 +186,10 @@ func NewMessageCreate(c *gateway.MessageCreateEvent, s *Session) Message { } if c.Member == nil { s.MemberState.RequestMember(c.GuildID, c.Author.ID) - return NewMessage(c.Message, s, NewUser(c.Author)) + return NewMessage(c.Message, s, NewUser(c.Author, s)) } - return NewMessage(c.Message, s, NewGuildMember(*c.Member, *g)) + return NewMessage(c.Message, s, NewGuildMember(*c.Member, *g, s)) } // NewBacklogMessage uses the session to create a message fetched from the @@ -197,27 +199,52 @@ func NewBacklogMessage(m discord.Message, s *Session, g discord.Guild) Message { // If the message doesn't have a guild, then we don't need all the // complicated member fetching process. if !m.GuildID.Valid() { - return NewMessage(m, s, NewUser(m.Author)) + return NewMessage(m, s, NewUser(m.Author, s)) } mem, err := s.Store.Member(m.GuildID, m.Author.ID) if err != nil { s.MemberState.RequestMember(m.GuildID, m.Author.ID) - return NewMessage(m, s, NewUser(m.Author)) + return NewMessage(m, s, NewUser(m.Author, s)) } - return NewMessage(m, s, NewGuildMember(*mem, g)) + return NewMessage(m, s, NewGuildMember(*mem, g, s)) } func NewDirectMessage(m discord.Message, s *Session) Message { - return NewMessage(m, s, NewUser(m.Author)) + return NewMessage(m, s, NewUser(m.Author, s)) } func NewMessage(m discord.Message, s *Session, author Author) Message { + // Render the message content. + var content = segments.ParseMessage(&m, s.Store) + + // Request members in mentions if we're in a guild. + if m.GuildID.Valid() { + for _, segment := range content.Segments { + if mention, ok := segment.(*segments.MentionSegment); ok { + // If this is not a user mention, then skip. + if mention.GuildUser == nil { + continue + } + + // If we already have a member, then skip. We could check this + // using the timestamp, as we might have a user set into the + // member field + if m := mention.GuildUser.Member; m != nil && m.Joined.Valid() { + continue + } + + // Request the member. + s.MemberState.RequestMember(m.GuildID, mention.GuildUser.ID) + } + } + } + return Message{ messageHeader: newHeader(m), author: author, - content: segments.ParseMessage(&m, s.Store), + content: content, } } diff --git a/segments/mention.go b/segments/mention.go index c3df3d5..8ab5518 100644 --- a/segments/mention.go +++ b/segments/mention.go @@ -7,7 +7,9 @@ import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/state" + "github.com/diamondburned/cchat-discord/urlutils" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/ningen" "github.com/diamondburned/ningen/md" "github.com/yuin/goldmark/ast" ) @@ -18,11 +20,13 @@ type NameSegment struct { guild discord.Guild member discord.Member + state *ningen.State // optional } var ( - _ text.Segment = (*NameSegment)(nil) - _ text.Mentioner = (*NameSegment)(nil) + _ text.Segment = (*NameSegment)(nil) + _ text.Mentioner = (*NameSegment)(nil) + _ text.MentionerAvatar = (*NameSegment)(nil) ) func UserSegment(start, end int, u discord.User) NameSegment { @@ -42,12 +46,23 @@ func MemberSegment(start, end int, guild discord.Guild, m discord.Member) NameSe } } +// WithState assigns a ningen state into the given name segment. This allows the +// popovers to have additional information such as user notes. +func (m *NameSegment) WithState(state *ningen.State) { + m.state = state +} + func (m NameSegment) Bounds() (start, end int) { return m.start, m.end } func (m NameSegment) MentionInfo() text.Rich { - return userInfo(m.guild, m.member) + return userInfo(m.guild, m.member, m.state) +} + +// Avatar returns the large avatar URL. +func (m NameSegment) Avatar() string { + return m.member.User.AvatarURL() } type MentionSegment struct { @@ -59,9 +74,10 @@ type MentionSegment struct { } var ( - _ text.Segment = (*MentionSegment)(nil) - _ text.Colorer = (*MentionSegment)(nil) - _ text.Mentioner = (*MentionSegment)(nil) + _ text.Segment = (*MentionSegment)(nil) + _ text.Colorer = (*MentionSegment)(nil) + _ text.Mentioner = (*MentionSegment)(nil) + _ text.MentionerAvatar = (*MentionSegment)(nil) ) func (r *TextRenderer) mention(n *md.Mention, enter bool) ast.WalkStatus { @@ -133,33 +149,29 @@ func (m MentionSegment) MentionInfo() text.Rich { return text.Rich{} } +// Avatar returns the user avatar if any, else it returns an empty URL. +func (m MentionSegment) Avatar() string { + if m.GuildUser != nil { + return m.GuildUser.AvatarURL() + } + + return "" +} + func (m MentionSegment) channelInfo() text.Rich { - content := strings.Builder{} - content.WriteByte('#') - content.WriteString(m.Channel.Name) - + var topic = m.Channel.Topic if m.Channel.NSFW { - content.WriteString(" (NSFW)") + topic = "(NSFW)\n" + topic } - if m.Channel.Topic != "" { - content.WriteByte('\n') - content.WriteString(m.Channel.Topic) + if topic == "" { + return text.Rich{} } - return text.Rich{ - Content: content.String(), - } + return Parse([]byte(topic)) } func (m MentionSegment) userInfo() text.Rich { - // // We should have a member if there's nil. Sometimes when the members aren't - // // prefetched, the markdown parser can miss them. We can check this again. - // if m.GuildUser.Member == nil && m.guild.Valid() { - // // Best effort; fine if it's nil. - // m.GuildUser.Member, _ = m.store.Member(m.guild, m.GuildUser.ID) - // } - if m.GuildUser.Member == nil { m.GuildUser.Member = &discord.Member{ User: m.GuildUser.User, @@ -172,52 +184,67 @@ func (m MentionSegment) userInfo() text.Rich { g = &discord.Guild{} } - return userInfo(*g, *m.GuildUser.Member) + return userInfo(*g, *m.GuildUser.Member, nil) } -func userInfo(guild discord.Guild, member discord.Member) text.Rich { +func (m MentionSegment) roleInfo() text.Rich { + // // We don't have much to write here. + // var segment = text.Rich{ + // Content: m.GuildRole.Name, + // } + + // // Maybe add a color if we have any. + // if c := m.GuildRole.Color.Uint32(); c > 0 { + // segment.Segments = []text.Segment{ + // NewColored(len(m.GuildRole.Name), m.GuildRole.Color.Uint32()), + // } + // } + + return text.Rich{} +} + +type LargeActivityImage struct { + start int + url string + text string +} + +func NewLargeActivityImage(start int, ac discord.Activity) LargeActivityImage { + var text = ac.Assets.LargeText + if text == "" { + text = "Activity Image" + } + + return LargeActivityImage{ + start: start, + url: urlutils.AssetURL(ac.ApplicationID, ac.Assets.LargeImage), + text: ac.Assets.LargeText, + } +} + +func (i LargeActivityImage) Bounds() (start, end int) { return i.start, i.start } +func (i LargeActivityImage) Image() string { return i.url } +func (i LargeActivityImage) ImageSize() (w, h int) { return 60, 60 } +func (i LargeActivityImage) ImageText() string { return i.text } + +func userInfo(guild discord.Guild, member discord.Member, state *ningen.State) text.Rich { var content bytes.Buffer var segment text.Rich - // Make a large avatar if there's one. - if member.User.Avatar != "" { - segmentadd(&segment, AvatarSegment{ - start: 0, - url: member.User.AvatarURL(), // full URL - text: "Avatar", - size: 72, // large - }) - // Space out. - content.WriteByte(' ') - } - - // Write the nickname if there's one; else, write the username only. + // Write the username if the user has a nickname. if member.Nick != "" { - content.WriteString(member.Nick) - content.WriteByte(' ') - - start, end := writestringbuf(&content, fmt.Sprintf( - "(%s#%s)", - member.User.Username, - member.User.Discriminator, - )) - - segmentadd(&segment, InlineSegment{ - start: start, - end: end, - attributes: text.AttrDimmed, - }) - } else { + content.WriteString("Username: ") content.WriteString(member.User.Username) content.WriteByte('#') content.WriteString(member.User.Discriminator) + content.WriteString("\n\n") } // Write extra information if any, but only if we have the guild state. if len(member.RoleIDs) > 0 && guild.ID.Valid() { // Write a prepended new line, as role writes will always prepend a new // line. This is to prevent a trailing new line. - content.WriteString("\n\n--- Roles ---") + formatSectionf(&segment, &content, "Roles") for _, id := range member.RoleIDs { rl, ok := findRole(guild.Roles, id) @@ -234,13 +261,125 @@ func userInfo(guild discord.Guild, member discord.Member) text.Rich { segmentadd(&segment, NewColoredSegment(start, end, rl.Color.Uint32())) } } + + // End section. + content.WriteString("\n\n") } - // Assign the written content into the text segment and return it. - segment.Content = content.String() + // These information can only be obtained from the state. As such, we check + // if the state is given. + if state != nil { + // Does the user have rich presence? If so, write. + if p, err := state.Presence(guild.ID, member.User.ID); err == nil { + for _, ac := range p.Activities { + formatActivity(&segment, &content, ac) + content.WriteString("\n\n") + } + } else if guild.ID.Valid() { + // If we're still in a guild, then we can ask Discord for that + // member with their presence attached. + state.MemberState.RequestMember(guild.ID, member.User.ID) + } + + // Write the user's note if any. + if note := state.NoteState.Note(member.User.ID); note != "" { + formatSectionf(&segment, &content, "Note") + content.WriteRune('\n') + + start, end := writestringbuf(&content, note) + segmentadd(&segment, InlineSegment{start, end, text.AttrMonospace}) + + content.WriteString("\n\n") + } + } + + // Assign the written content into the text segment and return it after + // trimming the trailing new line. + segment.Content = strings.TrimSuffix(content.String(), "\n") return segment } +func formatSectionf(segment *text.Rich, content *bytes.Buffer, f string, argv ...interface{}) { + // Treat f as a regular string at first. + var str = fmt.Sprintf("%s", f) + + // If there are argvs, then treat f as a format string. + if len(argv) > 0 { + str = fmt.Sprintf(str, argv...) + } + + start, end := writestringbuf(content, str) + segmentadd(segment, InlineSegment{start, end, text.AttrBold | text.AttrUnderline}) +} + +func formatActivity(segment *text.Rich, content *bytes.Buffer, ac discord.Activity) { + switch ac.Type { + case discord.GameActivity: + formatSectionf(segment, content, "Playing %s", ac.Name) + content.WriteByte('\n') + + case discord.ListeningActivity: + formatSectionf(segment, content, "Listening to %s", ac.Name) + content.WriteByte('\n') + + case discord.StreamingActivity: + formatSectionf(segment, content, "Streaming on %s", ac.Name) + content.WriteByte('\n') + + case discord.CustomActivity: + formatSectionf(segment, content, "Status") + content.WriteByte('\n') + + if ac.Emoji != nil { + if !ac.Emoji.ID.Valid() { + content.WriteString(ac.Emoji.Name) + } else { + segmentadd(segment, EmojiSegment{ + start: content.Len(), + name: ac.Emoji.Name, + emojiURL: ac.Emoji.EmojiURL() + "&size=64", + large: ac.State == "", + }) + } + + content.WriteByte(' ') + } + + default: + formatSectionf(segment, content, "Status") + content.WriteByte('\n') + } + + // Insert an image if there's any. + if ac.Assets != nil && ac.Assets.LargeImage != "" { + segmentadd(segment, NewLargeActivityImage(content.Len(), ac)) + content.WriteString(" ") + } + + if ac.Details != "" { + start, end := writestringbuf(content, ac.Details) + segmentadd(segment, InlineSegment{start, end, text.AttrBold}) + content.WriteByte('\n') + } + + if ac.State != "" { + content.WriteString(ac.State) + } +} + +func getPresence(state *ningen.State, guildID, userID discord.Snowflake) *discord.Activity { + p, err := state.Presence(guildID, userID) + if err != nil { + return nil + } + + if len(p.Activities) > 0 { + return &p.Activities[0] + } + + return p.Game +} + func findRole(roles []discord.Role, id discord.Snowflake) (discord.Role, bool) { for _, role := range roles { if role.ID == id { @@ -249,19 +388,3 @@ func findRole(roles []discord.Role, id discord.Snowflake) (discord.Role, bool) { } return discord.Role{}, false } - -func (m MentionSegment) roleInfo() text.Rich { - // We don't have much to write here. - var segment = text.Rich{ - Content: m.GuildRole.Name, - } - - // Maybe add a color if we have any. - if c := m.GuildRole.Color.Uint32(); c > 0 { - segment.Segments = []text.Segment{ - NewColored(len(m.GuildRole.Name), m.GuildRole.Color.Uint32()), - } - } - - return segment -} diff --git a/typer.go b/typer.go index 00af23e..6b993e3 100644 --- a/typer.go +++ b/typer.go @@ -5,7 +5,6 @@ import ( "github.com/diamondburned/arikawa/discord" "github.com/diamondburned/arikawa/gateway" - "github.com/diamondburned/arikawa/state" "github.com/diamondburned/cchat" "github.com/pkg/errors" ) @@ -24,27 +23,27 @@ func NewTyperAuthor(author Author, ev *gateway.TypingStartEvent) Typer { } } -func NewTyper(store state.Store, ev *gateway.TypingStartEvent) (*Typer, error) { +func NewTyper(s *Session, ev *gateway.TypingStartEvent) (*Typer, error) { if ev.GuildID.Valid() { - g, err := store.Guild(ev.GuildID) + g, err := s.Store.Guild(ev.GuildID) if err != nil { return nil, err } if ev.Member == nil { - ev.Member, err = store.Member(ev.GuildID, ev.UserID) + ev.Member, err = s.Store.Member(ev.GuildID, ev.UserID) if err != nil { return nil, err } } return &Typer{ - Author: NewGuildMember(*ev.Member, *g), + Author: NewGuildMember(*ev.Member, *g, s), time: ev.Timestamp, }, nil } - c, err := store.Channel(ev.ChannelID) + c, err := s.Store.Channel(ev.ChannelID) if err != nil { return nil, err } @@ -52,7 +51,7 @@ func NewTyper(store state.Store, ev *gateway.TypingStartEvent) (*Typer, error) { for _, user := range c.DMRecipients { if user.ID == ev.UserID { return &Typer{ - Author: NewUser(user), + Author: NewUser(user, s), time: ev.Timestamp, }, nil } diff --git a/urlutils/urlutils.go b/urlutils/urlutils.go index 3e3cd8f..8a3f6da 100644 --- a/urlutils/urlutils.go +++ b/urlutils/urlutils.go @@ -5,6 +5,8 @@ import ( "path" "strconv" "strings" + + "github.com/diamondburned/arikawa/discord" ) // AvatarURL wraps the URL with URL queries for the avatar. @@ -55,3 +57,11 @@ func ExtIs(URL string, exts []string) bool { return false } + +// AssetURL generates the image URL from the given asset image ID. +func AssetURL(appID discord.Snowflake, imageID string) string { + if strings.HasPrefix(imageID, "spotify:") { + return "https://i.scdn.co/image/" + strings.TrimPrefix(imageID, "spotify:") + } + return "https://cdn.discordapp.com/app-assets/" + appID.String() + "/" + imageID + ".png" +}