mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-19 04:36:30 +09:00
Merge pull request #364 from basecamp/autolink-text-only
Link URLs only in a message's text
This commit is contained in:
6 files changed
+83
-10
No files matched your search
@@ -5,7 +5,7 @@ module ContentFilters
|
||||
#
|
||||
# * SanitizeTags strips disallowed markup from the message body
|
||||
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
|
||||
# * auto_link re-sanitizes the final html (MessagesHelper#message_presentation)
|
||||
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
|
||||
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
|
||||
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
module MessagesHelper
|
||||
# auto_link re-sanitizes with Rails' default safe list, which lacks the
|
||||
# formatting the rich text editor produces
|
||||
# Rendered text is re-sanitized before linking. Rails' default safe list
|
||||
# lacks the formatting the rich text editor produces
|
||||
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
|
||||
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
|
||||
|
||||
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
|
||||
LINKABLE_TEXT = /:|@|www\./i
|
||||
|
||||
def message_area_tag(room, &)
|
||||
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
||||
controller: "messages presence drop-target",
|
||||
@@ -101,21 +104,36 @@ module MessagesHelper
|
||||
|
||||
private
|
||||
def text_message_presentation(body)
|
||||
render = -> do
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(body)),
|
||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
||||
end
|
||||
render = -> { auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(body)) }
|
||||
|
||||
# Embedded attachments render database-backed metadata and signed URLs.
|
||||
# Plain text HTML depends only on its content, even after a foreign edit.
|
||||
html = body.to_html
|
||||
if controller.perform_caching && !html.include?("<action-text-attachment") && html.bytesize <= ResponseCache::MAX_ENTRY_BYTES
|
||||
FragmentCache.store.fetch([ "text-presentation-v1", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
|
||||
FragmentCache.store.fetch([ "text-presentation-v2", I18n.locale, Digest::SHA256.hexdigest(html) ]) { render.call }
|
||||
else
|
||||
render.call
|
||||
end
|
||||
end
|
||||
|
||||
# auto_link finds URLs and email addresses with regular expressions, which
|
||||
# can't tell an attribute value from text. Hand it one text node at a time,
|
||||
# escaped as the serializer writes text, so a link can only land between tags.
|
||||
def auto_link_text(html)
|
||||
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
|
||||
|
||||
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
|
||||
if node.text.match?(LINKABLE_TEXT)
|
||||
text = node.to_html
|
||||
linked = auto_link(text, html: { target: "_blank" })
|
||||
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
|
||||
end
|
||||
end
|
||||
|
||||
# A <pre> starting with a newline needs an extra one written out to survive the next parse
|
||||
fragment.to_html(preserve_newline: true).html_safe
|
||||
end
|
||||
|
||||
def messages_actions
|
||||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||||
end
|
||||
|
||||
@@ -28,7 +28,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#stripMentionAttachments(node) {
|
||||
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
|
||||
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
|
||||
return node
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
|
||||
|
||||
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
|
||||
<% cache [ message_fragment_cache_key(message), "presentation-v7" ] do %>
|
||||
<% cache [ message_fragment_cache_key(message), "presentation-v8" ] do %>
|
||||
<%= message_tag message do %>
|
||||
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
|
||||
|
||||
|
||||
@@ -37,6 +37,47 @@ class MessagesHelperTest < ActionView::TestCase
|
||||
assert_match /<strong>bold<\/strong>/, presentation
|
||||
end
|
||||
|
||||
test "message_presentation links URLs and email addresses in text" do
|
||||
message = Message.create! room: rooms(:pets), body: "<div>See https://example.com/a?b=1&c=2, or write to me@example.com.</div>", client_message_id: "0015", creator: users(:jason)
|
||||
|
||||
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
|
||||
assert_equal [ "https://example.com/a?b=1&c=2", "mailto:me@example.com" ], links.map { it["href"] }
|
||||
assert_equal [ "_blank", "_blank" ], links.map { it["target"] }
|
||||
assert_equal [ "https://example.com/a?b=1&c=2", "me@example.com" ], links.map(&:text)
|
||||
end
|
||||
|
||||
test "message_presentation links an email address with an apostrophe whole" do
|
||||
message = Message.create! room: rooms(:pets), body: "<div>Write to o'brien@example.com</div>", client_message_id: "0015", creator: users(:jason)
|
||||
|
||||
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
|
||||
assert_equal [ [ "mailto:o%27brien@example.com", "o'brien@example.com" ] ], links.map { [ it["href"], it.text ] }
|
||||
end
|
||||
|
||||
test "message_presentation leaves URLs and email addresses in attribute values as they are" do
|
||||
[ "class", "title" ].product([ "https://example.com", "www.example.com", "me@example.com" ]).each do |attribute, linkable|
|
||||
value = "a> #{linkable} <em>b</em> "
|
||||
message = Message.create! room: rooms(:pets), body: %(<div><span #{attribute}="#{value}">c</span> https://example.org</div>), client_message_id: "0015", creator: users(:jason)
|
||||
|
||||
presentation = Nokogiri::HTML5.fragment(view.message_presentation(message))
|
||||
assert_equal [ value ], presentation.css("span[#{attribute}]").map { it[attribute] }, "#{attribute} with #{linkable}"
|
||||
assert_empty presentation.css("em"), "#{attribute} with #{linkable}"
|
||||
assert_equal [ "https://example.org" ], presentation.css("a").map { it["href"] }, "#{attribute} with #{linkable}"
|
||||
end
|
||||
end
|
||||
|
||||
test "linking text doesn't eat a code block's leading blank line" do
|
||||
linked = view.send(:auto_link_text, "<pre>\n\n\nfirst https://example.com</pre>".html_safe)
|
||||
|
||||
assert_equal "\nfirst https://example.com", Nokogiri::HTML5.fragment(linked).at_css("pre").text
|
||||
end
|
||||
|
||||
test "message_presentation leaves an existing link's text alone" do
|
||||
message = Message.create! room: rooms(:pets), body: '<div><a href="https://example.com/">https://example.org/</a></div>', client_message_id: "0015", creator: users(:jason)
|
||||
|
||||
links = Nokogiri::HTML5.fragment(view.message_presentation(message)).css("a")
|
||||
assert_equal [ [ "https://example.com/", "https://example.org/" ] ], links.map { [ it["href"], it.text ] }
|
||||
end
|
||||
|
||||
test "message_presentation shows an image's thumbnail made when it was posted" do
|
||||
presentation = view.message_presentation(attachment_message("moon.jpg", "image/jpeg", processed: true))
|
||||
|
||||
|
||||
@@ -174,6 +174,20 @@ class ComposerTest < ApplicationSystemTestCase
|
||||
assert_selector last_message_selector("cite"), text: "JZ"
|
||||
end
|
||||
|
||||
test "replying quotes a mention's text as text" do
|
||||
message = rooms(:designers).messages.create! creator: users(:kevin), client_message_id: "reply-mention",
|
||||
body: %(<div><span class="mention"><em>Kevin</em></span> says hi</div>)
|
||||
join_room rooms(:designers)
|
||||
|
||||
within_message message do
|
||||
reveal_message_actions
|
||||
find("[aria-label='Reply']").click
|
||||
end
|
||||
|
||||
assert_composer_text "<em>Kevin</em> says hi"
|
||||
assert_no_selector "#composer lexxy-editor em"
|
||||
end
|
||||
|
||||
test "arrow up edits my last message when the composer is empty" do
|
||||
composer_editor.click
|
||||
press_in_composer :up
|
||||
|
||||
Reference in new issue
Block a user