diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 97c5055..c46f0bf 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -237,14 +237,16 @@ func TestDiscordTUIRowsIncludePaneMetadata(t *testing.T) { AuthorID: "u1", AuthorName: "Peter", Content: "hello from desktop", + DisplayContent: "hello from Vincent", CreatedAt: time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC), ReplyToMessage: "m0", HasAttachments: true, Pinned: true, }}) require.Len(t, rows, 1) - require.Equal(t, "hello from desktop", rows[0].Title) - require.Equal(t, "hello from desktop", rows[0].Detail) + require.Equal(t, "hello from Vincent", rows[0].Title) + require.Equal(t, "hello from Vincent", rows[0].Detail) + require.Equal(t, "hello from Vincent", rows[0].Text) require.Equal(t, "Direct messages", rows[0].Scope) require.Equal(t, "Vincent K", rows[0].Container) require.Contains(t, rows[0].Tags, "dm") diff --git a/internal/cli/tui_commands.go b/internal/cli/tui_commands.go index f108dcb..d61bba9 100644 --- a/internal/cli/tui_commands.go +++ b/internal/cli/tui_commands.go @@ -117,7 +117,8 @@ func (r *runtime) archiveSourceLocation() string { func discordTUIRows(rows []store.MessageRow) []tui.Row { items := make([]tui.Row, 0, len(rows)) for _, row := range rows { - title := strings.TrimSpace(row.Content) + content := discordDisplayContent(row) + title := strings.TrimSpace(content) if title == "" { title = row.MessageID } @@ -137,8 +138,8 @@ func discordTUIRows(rows []store.MessageRow) []tui.Row { Container: discordContainerLabel(row), Author: discordAuthorLabel(row), Title: title, - Text: row.Content, - Detail: row.Content, + Text: content, + Detail: content, URL: discordMessageURL(row), CreatedAt: formatTime(row.CreatedAt), Tags: tags, @@ -156,6 +157,13 @@ func discordTUIRows(rows []store.MessageRow) []tui.Row { return items } +func discordDisplayContent(row store.MessageRow) string { + if content := strings.TrimSpace(row.DisplayContent); content != "" { + return content + } + return row.Content +} + func discordMessageURL(row store.MessageRow) string { guildID := strings.TrimSpace(row.GuildID) channelID := strings.TrimSpace(row.ChannelID) diff --git a/internal/store/mentions_test.go b/internal/store/mentions_test.go index 47eafa8..08b5f21 100644 --- a/internal/store/mentions_test.go +++ b/internal/store/mentions_test.go @@ -116,3 +116,44 @@ func TestAttachmentTextAndMentionsAreQueryable(t *testing.T) { require.NoError(t, err) require.Len(t, filtered, 1) } + +func TestListMessagesResolvesMentionNamesForDisplay(t *testing.T) { + t.Parallel() + + ctx := context.Background() + s, err := Open(ctx, filepath.Join(t.TempDir(), "discrawl.db")) + require.NoError(t, err) + defer func() { _ = s.Close() }() + + require.NoError(t, s.UpsertChannel(ctx, ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "maintainers", RawJSON: `{}`})) + + createdAt := time.Now().UTC().Format(time.RFC3339Nano) + rawContent := "ping <@u2> <@!u3> <@&r1> in <#c1>" + require.NoError(t, s.UpsertMessages(ctx, []MessageMutation{{ + Record: MessageRecord{ + ID: "m1", + GuildID: "g1", + ChannelID: "c1", + ChannelName: "maintainers", + AuthorID: "u1", + AuthorName: "Peter", + MessageType: 0, + CreatedAt: createdAt, + Content: rawContent, + NormalizedContent: rawContent, + RawJSON: `{}`, + }, + Mentions: []MentionEventRecord{ + {MessageID: "m1", GuildID: "g1", ChannelID: "c1", AuthorID: "u1", TargetType: "user", TargetID: "u2", TargetName: "Shadow", EventAt: createdAt}, + {MessageID: "m1", GuildID: "g1", ChannelID: "c1", AuthorID: "u1", TargetType: "user", TargetID: "u3", TargetName: "Vincent", EventAt: createdAt}, + {MessageID: "m1", GuildID: "g1", ChannelID: "c1", AuthorID: "u1", TargetType: "role", TargetID: "r1", TargetName: "Maintainers", EventAt: createdAt}, + {MessageID: "m1", GuildID: "g1", ChannelID: "c1", AuthorID: "u1", TargetType: "channel", TargetID: "c1", TargetName: "maintainers", EventAt: createdAt}, + }, + }})) + + messages, err := s.ListMessages(ctx, MessageListOptions{Channel: "maintainers", Limit: 10}) + require.NoError(t, err) + require.Len(t, messages, 1) + require.Equal(t, rawContent, messages[0].Content) + require.Equal(t, "ping @Shadow @Vincent @Maintainers in #maintainers", messages[0].DisplayContent) +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 097ff8a..47b5da5 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -37,6 +37,7 @@ type MessageRow struct { AuthorID string `json:"author_id"` AuthorName string `json:"author_name"` Content string `json:"content"` + DisplayContent string `json:"display_content,omitempty"` CreatedAt time.Time `json:"created_at"` ReplyToMessage string `json:"reply_to_message_id,omitempty"` Source string `json:"source,omitempty"` @@ -161,11 +162,72 @@ func (s *Store) ListMessages(ctx context.Context, opts MessageListOptions) ([]Me row.CreatedAt = parseTime(created) row.HasAttachments = hasAttachments == 1 row.Pinned = pinned == 1 + row.DisplayContent = row.Content out = append(out, row) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + return out, s.resolveMessageDisplayMentions(ctx, out) } func normalizeChannelFilter(raw string) string { return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "#")) } + +func (s *Store) resolveMessageDisplayMentions(ctx context.Context, rows []MessageRow) error { + if len(rows) == 0 { + return nil + } + ids := make([]any, 0, len(rows)) + indexByID := make(map[string]int, len(rows)) + for index, row := range rows { + id := strings.TrimSpace(row.MessageID) + if id == "" { + continue + } + ids = append(ids, id) + indexByID[id] = index + } + if len(ids) == 0 { + return nil + } + query := `select message_id, target_type, target_id, target_name from mention_events where message_id in (` + placeholders(len(ids)) + `)` + mentionRows, err := s.db.QueryContext(ctx, query, ids...) + if err != nil { + return err + } + defer func() { _ = mentionRows.Close() }() + for mentionRows.Next() { + var messageID, targetType, targetID, targetName string + if err := mentionRows.Scan(&messageID, &targetType, &targetID, &targetName); err != nil { + return err + } + index, ok := indexByID[messageID] + if !ok { + continue + } + rows[index].DisplayContent = replaceDiscordMention(rows[index].DisplayContent, targetType, targetID, targetName) + } + return mentionRows.Err() +} + +func replaceDiscordMention(content, targetType, targetID, targetName string) string { + targetID = strings.TrimSpace(targetID) + if targetID == "" { + return content + } + label := strings.TrimSpace(targetName) + if label == "" { + label = targetID + } + switch strings.TrimSpace(targetType) { + case "role": + return strings.ReplaceAll(content, "<@&"+targetID+">", "@"+label) + case "channel": + return strings.ReplaceAll(content, "<#"+targetID+">", "#"+label) + default: + content = strings.ReplaceAll(content, "<@"+targetID+">", "@"+label) + return strings.ReplaceAll(content, "<@!"+targetID+">", "@"+label) + } +}