diff --git a/app/assets/stylesheets/actiontext.css b/app/assets/stylesheets/actiontext.css index 7130777..06c4b59 100644 --- a/app/assets/stylesheets/actiontext.css +++ b/app/assets/stylesheets/actiontext.css @@ -88,6 +88,25 @@ lexxy-toolbar { .lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after { display: none; } + + /* Dropdown panels: a solid surface with a uniform radius. Lexxy derives + the panel's radius and gap from --lexxy-toolbar-gap, which Campfire + repurposes for button spacing */ + .lexxy-editor__toolbar-dropdown [data-dropdown-panel] { + --lexxy-color-canvas: var(--color-bg); + --lexxy-color-ink-lightest: var(--color-message-bg); + --lexxy-color-selected: var(--color-selected); + --lexxy-color-selected-hover: var(--color-message-bg); + + border: 1px solid var(--color-border-dark); + border-radius: 0.5em; + gap: 0.25em; + padding: 0.25em; + } + + .lexxy-editor__toolbar-dropdown > .lexxy-editor__toolbar-button[aria-expanded="true"] { + border-radius: var(--lexxy-radius); + } } /* Attachment markup contains formatting whitespace that must not turn into diff --git a/app/helpers/content_filters/remove_solo_unfurled_link_text.rb b/app/helpers/content_filters/remove_solo_unfurled_link_text.rb index 85568ab..f873f32 100644 --- a/app/helpers/content_filters/remove_solo_unfurled_link_text.rb +++ b/app/helpers/content_filters/remove_solo_unfurled_link_text.rb @@ -1,4 +1,7 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter + TWITTER_DOMAINS = %w[ x.com twitter.com ] + TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" } + def applicable? normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text) end @@ -12,9 +15,6 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter end private - TWITTER_DOMAINS = %w[ x.com twitter.com ] - TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" } - def solo_unfurled_url ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1 end diff --git a/app/helpers/content_filters/sanitize_tags.rb b/app/helpers/content_filters/sanitize_tags.rb index 7801f12..93c6fc9 100644 --- a/app/helpers/content_filters/sanitize_tags.rb +++ b/app/helpers/content_filters/sanitize_tags.rb @@ -1,4 +1,8 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter + ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol + p pre samp small span strong sub sup time tt ul var ] + ContentFilters::EDITOR_FORMATTING_TAGS + + [ ActionText::Attachment.tag_name, "figure", "figcaption" ] + def applicable? true end @@ -8,10 +12,6 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter end private - ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol - p pre samp small span strong sub sup time tt ul var ] + ContentFilters::EDITOR_FORMATTING_TAGS + - [ ActionText::Attachment.tag_name, "figure", "figcaption" ] - def not_allowed_tags_css_selector ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("") end diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb index 3b66eba..67c6d99 100644 --- a/app/helpers/rich_text_helper.rb +++ b/app/helpers/rich_text_helper.rb @@ -1,4 +1,6 @@ module RichTextHelper + LEGACY_EMBED_SELECTOR = "action-text-attachment[content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}'][href]" + def rich_text_data_actions # submitByKeyboard runs in the capture phase so it can submit on Enter # before the editor turns the keystroke into a newline @@ -9,4 +11,17 @@ module RichTextHelper tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id), "remote-filtering": true, "empty-results": "No matches" end + + # Trix-era opengraph embeds carry their details as node attributes, which + # the editor doesn't round-trip. Rendering them into the content attribute + # lets the editor preserve them like any embed it created itself. + def editable_body(message) + fragment = ActionText::Fragment.wrap(message.body.body_before_type_cast) + + transformed = fragment.replace(LEGACY_EMBED_SELECTOR) do |node| + node.tap { |n| n["content"] = render_action_text_attachment(ActionText::Attachment.from_node(n)) } + end + + ActionText::RichText.new(body: transformed.to_html) + end end diff --git a/app/views/messages/edit.html.erb b/app/views/messages/edit.html.erb index 4f356e9..dff93a7 100644 --- a/app/views/messages/edit.html.erb +++ b/app/views/messages/edit.html.erb @@ -17,6 +17,7 @@ controller: "form", action: "lexxy:file-accept->form#preventAttachment keydown.esc->form#cancel keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent" } do |form| %>
<%= form.rich_text_area :body, + value: editable_body(@message), rows: 1, class: "input lexxy-content", aria: { multiline: "true", label: "Edit message" }, diff --git a/lib/rails_ext/actiontext_opengraph_embeds.rb b/lib/rails_ext/actiontext_opengraph_embeds.rb index 67147f3..a106d42 100644 --- a/lib/rails_ext/actiontext_opengraph_embeds.rb +++ b/lib/rails_ext/actiontext_opengraph_embeds.rb @@ -33,12 +33,13 @@ class ActionText::Attachment::OpengraphEmbed def attributes_from_content(content) fragment = Nokogiri::HTML.fragment(content) - link = fragment.at_css(".og-embed__title a") + title = fragment.at_css(".og-embed__title") + link = title&.at_css("a") { href: link&.[]("href"), url: fragment.at_css(".og-embed__image img")&.[]("src"), - filename: link&.text&.strip, + filename: (link || title)&.text&.strip, description: fragment.at_css(".og-embed__description")&.text&.strip } end diff --git a/test/helpers/rich_text_helper_test.rb b/test/helpers/rich_text_helper_test.rb new file mode 100644 index 0000000..bc72d33 --- /dev/null +++ b/test/helpers/rich_text_helper_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class RichTextHelperTest < ActionView::TestCase + include ActionText::ContentHelper + test "editable_body renders legacy opengraph embeds into the content attribute" do + body = %(
https://example.com/
) + message = Message.create! room: rooms(:pets), body: body, client_message_id: "0017", creator: users(:jason) + + node = editable_body(message).body.fragment.find_all("action-text-attachment").first + content = Nokogiri::HTML.fragment(node["content"]) + + assert_equal "Example title", content.at_css(".og-embed__title a").text.strip + assert_equal "https://example.com/", content.at_css(".og-embed__title a")["href"] + assert_equal "Example description", content.at_css(".og-embed__description").text.strip + assert_equal "https://example.com/image.png", content.at_css(".og-embed__image img")["src"] + end + + test "editable_body leaves bodies without legacy embeds unchanged" do + message = Message.create! room: rooms(:pets), body: "

Plain text

", client_message_id: "0018", creator: users(:jason) + + assert_equal message.body.body.to_html, editable_body(message).body.to_html + end +end diff --git a/test/system/composer_test.rb b/test/system/composer_test.rb index 3b6dce0..315d899 100644 --- a/test/system/composer_test.rb +++ b/test/system/composer_test.rb @@ -68,6 +68,24 @@ class ComposerTest < ApplicationSystemTestCase assert_equal [ users(:jason) ], message.reload.mentionees end + test "editing a legacy trix message keeps its mention and embed" do + body = %(
Hey #{mention_attachment_for(:jason)} check
) + message = Message.create! room: rooms(:designers), body: body, client_message_id: "legacy", creator: users(:jz) + + join_room rooms(:designers) + + within_message message do + reveal_message_actions + find(".message__edit-btn").click + assert_edit_editor_text "Jason" + click_on "Save changes" + end + + assert_selector last_message_selector(".mention"), text: "Jason" + assert_selector last_message_selector(%(.og-embed__title a[href="https://example.com/"])), text: "Example title" + assert_equal [ users(:jason) ], message.reload.mentionees + end + test "replying quotes the original message with attribution" do within_message messages(:third) do reveal_message_actions