From 3b40348e8390d372f6c90ec40b0bbea2afb3fee0 Mon Sep 17 00:00:00 2001 From: Ella Wren Date: Mon, 19 Jan 2026 16:18:25 +0000 Subject: [PATCH 01/11] feat: add dependencies --- Gemfile | 1 + Gemfile.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Gemfile b/Gemfile index 6820e1d2b..655f1f1ee 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,7 @@ gem "net-http-persistent" gem "kredis" gem "platform_agent" gem "thruster" +gem "redcarpet" group :development, :test do gem "debug" diff --git a/Gemfile.lock b/Gemfile.lock index 60e9fb2dc..a685bbf4e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -288,6 +288,7 @@ GEM erb psych (>= 4.0.0) tsort + redcarpet (3.6.1) redis (5.4.1) redis-client (>= 0.22.0) redis-client (0.25.2) @@ -428,6 +429,7 @@ DEPENDENCIES puma (~> 6.6) rails! rails_autolink + redcarpet redis (~> 5.4) resque (~> 2.7.0) resque-pool (~> 0.7.1) From ffbc54cea597c77562e9aea2f97c569c7dac306b Mon Sep 17 00:00:00 2001 From: Ella Wren Date: Mon, 19 Jan 2026 16:38:09 +0000 Subject: [PATCH 02/11] feat: add markdown support --- app/helpers/content_filters.rb | 2 +- .../content_filters/markdown_filter.rb | 64 +++++++++++++++++++ app/helpers/messages_helper.rb | 17 ++++- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 app/helpers/content_filters/markdown_filter.rb diff --git a/app/helpers/content_filters.rb b/app/helpers/content_filters.rb index 926a726af..1719f8258 100644 --- a/app/helpers/content_filters.rb +++ b/app/helpers/content_filters.rb @@ -1,3 +1,3 @@ module ContentFilters - TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags) + TextMessagePresentationFilters = ActionText::Content::Filters.new(MarkdownFilter, RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags) end diff --git a/app/helpers/content_filters/markdown_filter.rb b/app/helpers/content_filters/markdown_filter.rb new file mode 100644 index 000000000..7262269ea --- /dev/null +++ b/app/helpers/content_filters/markdown_filter.rb @@ -0,0 +1,64 @@ +class ContentFilters::MarkdownFilter < ActionText::Content::Filter + MARKDOWN_PATTERNS = [ + /\*\*[^*]+\*\*/, # Bold: **text** + /__[^_]+__/, # Bold alt: __text__ + /\*[^*]+\*/, # Italic: *text* + /_[^_]+_/, # Italic alt: _text_ + /`[^`]+`/, # Inline code: `code` + /```[\s\S]+?```/, # Code blocks: ```code``` + /^[#]{1,6}\s/m, # Header levels 1-6: # Header + /^\*\s/m, # Unordered lists: * item + /^-\s/m, # Unordered lists alt: - item + /^\d+\.\s/m, # Ordered lists: 1. item + /\[.+?\]\(.+?\)/, # Links: [text](url) + /~~.+?~~/, # Strikethrough: ~~text~~ + /^>\s/m, # Blockquotes: > quote + /^---+$/m # Horizontal rule: --- + ].freeze + + def applicable? + has_markdown? + end + + def apply + # Convert markdown to HTML using Redcarpet + markdown_html = markdown_renderer.render(plain_text_content) + + # Replace the entire fragment with the rendered markdown + fragment.update do |source| + source.inner_html = markdown_html + end + end + + private + def has_markdown? + content = plain_text_content + MARKDOWN_PATTERNS.any? { |pattern| content.match?(pattern) } + end + + def plain_text_content + fragment.to_plain_text + end + + def markdown_renderer + @markdown_renderer ||= Redcarpet::Markdown.new( + Redcarpet::Render::HTML.new( + filter_html: true, + safe_links_only: true, + no_styles: true, + link_attributes: { target: "_blank", rel: "noopener noreferrer" } + ), + autolink: true, + disable_indented_code_blocks: false, + fenced_code_blocks: true, + footnotes: false, + highlight: false, + quote: true, + no_intra_emphasis: true, + space_after_headers: true, + strikethrough: true, + tables: true, + underline: false + ) + end +end diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index 0d5a35c11..e5c5ba4d2 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -57,7 +57,15 @@ def message_presentation(message) when "sound" message_sound_presentation(message) else - auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" } + filtered_content = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + + # Only apply auto_link if the message doesn't have markdown + # (markdown filter already processes links) + if message_has_markdown?(message) + h(filtered_content) + else + auto_link h(filtered_content), html: { target: "_blank" } + end end rescue Exception => e Sentry.capture_exception(e, extra: { message: message }) @@ -66,6 +74,13 @@ def message_presentation(message) "" end + def message_has_markdown?(message) + return false unless message.body.present? + + content = message.plain_text_body + ContentFilters::MarkdownFilter::MARKDOWN_PATTERNS.any? { |pattern| content.match?(pattern) } + end + private def messages_actions "turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage" From 93602edd4edd48c9cefca648e15216092096812b Mon Sep 17 00:00:00 2001 From: Ella Wren Date: Wed, 21 Jan 2026 17:34:49 +0000 Subject: [PATCH 03/11] feat: alter markdown patterns and improve messages helper --- app/helpers/content_filters/markdown_filter.rb | 12 ++++++++---- app/helpers/messages_helper.rb | 7 +++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/helpers/content_filters/markdown_filter.rb b/app/helpers/content_filters/markdown_filter.rb index 7262269ea..7bb74f4f9 100644 --- a/app/helpers/content_filters/markdown_filter.rb +++ b/app/helpers/content_filters/markdown_filter.rb @@ -1,9 +1,10 @@ class ContentFilters::MarkdownFilter < ActionText::Content::Filter + # Markdown pattern detection + # NOTE: Underscore-based emphasis (__bold__ and _italic_) is intentionally not supported + # to avoid false positives with code identifiers like __init__ and my_variable_name MARKDOWN_PATTERNS = [ /\*\*[^*]+\*\*/, # Bold: **text** - /__[^_]+__/, # Bold alt: __text__ /\*[^*]+\*/, # Italic: *text* - /_[^_]+_/, # Italic alt: _text_ /`[^`]+`/, # Inline code: `code` /```[\s\S]+?```/, # Code blocks: ```code``` /^[#]{1,6}\s/m, # Header levels 1-6: # Header @@ -16,6 +17,10 @@ class ContentFilters::MarkdownFilter < ActionText::Content::Filter /^---+$/m # Horizontal rule: --- ].freeze + def self.has_markdown?(text) + MARKDOWN_PATTERNS.any? { |pattern| text.match?(pattern) } + end + def applicable? has_markdown? end @@ -32,8 +37,7 @@ def apply private def has_markdown? - content = plain_text_content - MARKDOWN_PATTERNS.any? { |pattern| content.match?(pattern) } + self.class.has_markdown?(plain_text_content) end def plain_text_content diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index e5c5ba4d2..1a51388e5 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -60,9 +60,9 @@ def message_presentation(message) filtered_content = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) # Only apply auto_link if the message doesn't have markdown - # (markdown filter already processes links) + # (markdown filter already processes links and sanitizes HTML) if message_has_markdown?(message) - h(filtered_content) + filtered_content.html_safe else auto_link h(filtered_content), html: { target: "_blank" } end @@ -77,8 +77,7 @@ def message_presentation(message) def message_has_markdown?(message) return false unless message.body.present? - content = message.plain_text_body - ContentFilters::MarkdownFilter::MARKDOWN_PATTERNS.any? { |pattern| content.match?(pattern) } + ContentFilters::MarkdownFilter.has_markdown?(message.plain_text_body) end private From dc96799665a78e491b8619da795beacd0b9ee76d Mon Sep 17 00:00:00 2001 From: Ella Wren Date: Wed, 21 Jan 2026 17:35:39 +0000 Subject: [PATCH 04/11] test: add markdown filter tests --- test/helpers/content_filters_test.rb | 199 +++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/test/helpers/content_filters_test.rb b/test/helpers/content_filters_test.rb index 00b270910..0a3b69654 100644 --- a/test/helpers/content_filters_test.rb +++ b/test/helpers/content_filters_test.rb @@ -70,6 +70,205 @@ class ContentFiltersTest < ActionView::TestCase assert_match expected, filtered.to_html end + # Markdown filter tests + + test "markdown filter is not applicable to plain text without markdown" do + message = Message.create! room: rooms(:pets), body: "Just plain text here", client_message_id: "0031", creator: users(:jason) + + filter = ContentFilters::MarkdownFilter.new(message.body.body) + refute filter.applicable? + end + + test "markdown filter is applicable when markdown patterns are detected" do + message = Message.create! room: rooms(:pets), body: "This has **bold** text", client_message_id: "0032", creator: users(:jason) + + filter = ContentFilters::MarkdownFilter.new(message.body.body) + assert filter.applicable? + end + + test "has_markdown? class method detects markdown patterns" do + assert ContentFilters::MarkdownFilter.has_markdown?("This is **bold**") + assert ContentFilters::MarkdownFilter.has_markdown?("This is *italic*") + assert ContentFilters::MarkdownFilter.has_markdown?("This has `code`") + assert ContentFilters::MarkdownFilter.has_markdown?("[link](url)") + assert ContentFilters::MarkdownFilter.has_markdown?("# Header") + + refute ContentFilters::MarkdownFilter.has_markdown?("Just plain text") + refute ContentFilters::MarkdownFilter.has_markdown?("Email: user@example.com") + refute ContentFilters::MarkdownFilter.has_markdown?("Python __init__ method") + refute ContentFilters::MarkdownFilter.has_markdown?("Use my_variable_name") + end + + # Tests for MARKDOWN_PATTERNS (in order of pattern definition) + + test "renders bold text with double asterisks" do + message = Message.create! room: rooms(:pets), body: "This is **bold** text", client_message_id: "0016", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_match /bold<\/strong>/, filtered.to_html + end + + test "renders italic text with single asterisks" do + message = Message.create! room: rooms(:pets), body: "This is *italic* text", client_message_id: "0018", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_match /italic<\/em>/, filtered.to_html + end + + test "renders inline code with backticks" do + message = Message.create! room: rooms(:pets), body: "Use the `print()` function", client_message_id: "0019", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_match /print\(\)<\/code>/, filtered.to_html + end + + test "renders code blocks with triple backticks" do + code_block = "```\ndef hello():\n print('world')\n```" + message = Message.create! room: rooms(:pets), body: code_block, client_message_id: "0020", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_match //, filtered.to_html + assert_match /def hello/, filtered.to_html + end + + test "renders headers" do + message = Message.create! room: rooms(:pets), body: "# Heading 1\n## Heading 2", client_message_id: "0023", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_match /

Heading 1<\/h1>/, filtered.to_html + assert_match /

Heading 2<\/h2>/, filtered.to_html + end + + test "renders unordered lists with asterisks" do + message = Message.create! room: rooms(:pets), body: "* Item 1\n* Item 2\n* Item 3", client_message_id: "0035", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_match /