mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-09-13 12:02:03 +09:00
Replace Trix with Lexxy
This commit is contained in:
@@ -26,6 +26,9 @@ gem "importmap-rails", github: "rails/importmap-rails"
|
||||
gem "turbo-rails", github: "hotwired/turbo-rails"
|
||||
gem "stimulus-rails"
|
||||
|
||||
# Rich text
|
||||
gem "lexxy", "~> 0.9.24"
|
||||
|
||||
# Media handling
|
||||
gem "image_processing", ">= 1.2"
|
||||
|
||||
|
||||
@@ -199,6 +199,8 @@ GEM
|
||||
activesupport (>= 6.0.0)
|
||||
redis (>= 4.2, < 6)
|
||||
language_server-protocol (3.17.0.5)
|
||||
lexxy (0.9.24)
|
||||
rails (>= 8.0.2)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.2)
|
||||
@@ -424,6 +426,7 @@ DEPENDENCIES
|
||||
importmap-rails!
|
||||
jbuilder
|
||||
kredis
|
||||
lexxy (~> 0.9.24)
|
||||
mocha
|
||||
net-http-persistent
|
||||
ostruct
|
||||
|
||||
@@ -1,165 +1,141 @@
|
||||
trix-toolbar {
|
||||
display: none;
|
||||
inset: auto 0 0 0;
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
|
||||
so everything follows Campfire's light/dark color scheme */
|
||||
.lexxy-content {
|
||||
--lexxy-color-canvas: transparent;
|
||||
--lexxy-color-text: var(--color-text);
|
||||
--lexxy-color-text-subtle: var(--color-text);
|
||||
--lexxy-color-link: var(--color-link);
|
||||
--lexxy-color-ink: var(--color-text);
|
||||
--lexxy-color-ink-inverted: var(--color-text-reversed);
|
||||
--lexxy-color-ink-medium: var(--color-text);
|
||||
--lexxy-color-ink-lighter: var(--color-border-darker);
|
||||
--lexxy-color-ink-lightest: var(--color-bg);
|
||||
--lexxy-color-selected: var(--color-selected, var(--color-bg));
|
||||
--lexxy-color-code-bg: var(--color-bg);
|
||||
|
||||
.trix-dialog {
|
||||
bottom: 3rem !important;
|
||||
top: auto !important;
|
||||
}
|
||||
|
||||
.trix-button--icon {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.trix-dialog {
|
||||
background-color: var(--color-bg) !important;
|
||||
border-radius: 1rem !important;
|
||||
border: 2px solid var(--color-text) !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--color-text) !important;
|
||||
gap: var(--inline-space);
|
||||
padding: 0 !important;
|
||||
overflow: clip;
|
||||
|
||||
.trix-input {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
color: var(--color-text) !important;
|
||||
font-size: max(16px, 1em) !important;
|
||||
padding: var(--input-padding, 0.5em 0.8em) !important;
|
||||
}
|
||||
|
||||
.trix-button {
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.trix-dialog--link {
|
||||
.trix-dialog__link-fields {
|
||||
align-items: center;
|
||||
padding-right: 0.3em;
|
||||
}
|
||||
|
||||
[value="Link"],
|
||||
[value="Unlink"] {
|
||||
width: 2.6em;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
text-indent: -9999%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[value="Link"] { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.948 5.258a4.337 4.337 0 0 0-6.108 0L11.217 6.87a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l1.623-1.613a2.323 2.323 0 0 1 3.271 0 2.29 2.29 0 0 1 0 3.251l-2.393 2.38a3.021 3.021 0 0 1-4.255 0l-.05-.049a1.007 1.007 0 0 0-1.418 0 .993.993 0 0 0 0 1.41l.05.049a5.036 5.036 0 0 0 7.091 0l2.394-2.38a4.275 4.275 0 0 0 0-6.072Zm-13.683 13.6a4.337 4.337 0 0 0 6.108 0l1.262-1.255a.993.993 0 0 0 0-1.41 1.007 1.007 0 0 0-1.418 0L9.954 17.45a2.323 2.323 0 0 1-3.27 0 2.29 2.29 0 0 1 0-3.251l2.344-2.331a2.579 2.579 0 0 1 3.631 0c.392.39 1.027.39 1.419 0a.993.993 0 0 0 0-1.41 4.593 4.593 0 0 0-6.468 0l-2.345 2.33a4.275 4.275 0 0 0 0 6.072Z' clip-rule='evenodd'/%3E%3C/svg%3E"); }
|
||||
|
||||
[value="Unlink"] {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.954 5.264a3.58 3.58 0 0 0-5.04 0l-2.157 2.144a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l2.157-2.144a1.565 1.565 0 0 1 2.204 0c.607.603.609 1.58.005 2.186l-2.348 2.355a.993.993 0 0 0 .006 1.41 1.007 1.007 0 0 0 1.419-.007l2.347-2.354a3.528 3.528 0 0 0-.01-5ZM5.257 18.852a3.59 3.59 0 0 0 5.041.015l2.6-2.554a.993.993 0 0 0 .01-1.41 1.007 1.007 0 0 0-1.419-.009l-2.6 2.554c-.615.603-1.603.6-2.214-.006a1.554 1.554 0 0 1-.012-2.195l2.539-2.58a.993.993 0 0 0-.016-1.41 1.007 1.007 0 0 0-1.418.015l-2.539 2.58a3.54 3.54 0 0 0 .028 5Z' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-width='2' d='M10.233 5.78V3.769M5.968 9.802H4.21M7.343 7.107 5.968 5.781M14.348 17.523v2.013M18.613 13.502h1.758M17.238 16.196l1.375 1.327'/%3E%3C/svg%3E%0A");
|
||||
border-top-right-radius: 1em !important;
|
||||
border-bottom-right-radius: 1em !important;
|
||||
}
|
||||
}
|
||||
/* Campfire's message lines stack without extra block margins */
|
||||
--lexxy-content-margin: 0;
|
||||
}
|
||||
|
||||
trix-editor {
|
||||
lexxy-editor {
|
||||
&.input {
|
||||
--lexxy-editor-padding: 0;
|
||||
|
||||
/* One line high when empty. Lexxy derives this from attr(rows lh, 8lh),
|
||||
whose resolution differs between Chrome versions — pin it instead of
|
||||
risking the 8-line fallback */
|
||||
--lexxy-editor-rows: 1lh;
|
||||
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.attachment--content {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
|
||||
.attachment__toolbar {
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trix-button-group--file-tools,
|
||||
.trix-button-group--history-tools {
|
||||
display: none !important;
|
||||
/* The mentions menu needs a solid surface — the editor's transparent canvas
|
||||
would let messages show through it */
|
||||
.lexxy-prompt-menu {
|
||||
--lexxy-color-canvas: var(--color-bg);
|
||||
|
||||
border: 1px solid var(--color-border-dark);
|
||||
}
|
||||
|
||||
.trix-button-group,
|
||||
.trix-button {
|
||||
border: 0 !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0.3em !important;
|
||||
box-shadow: none;
|
||||
/* Same for the code block language picker: with a transparent canvas the
|
||||
native dropdown renders white-on-white in dark mode */
|
||||
lexxy-editor lexxy-code-language-picker select {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
|
||||
&::before {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&::before {
|
||||
filter: invert(100);
|
||||
}
|
||||
}
|
||||
|
||||
&.trix-active {
|
||||
background-color: var(--color-text) !important;
|
||||
|
||||
&::before {
|
||||
filter: invert(100);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&::before {
|
||||
filter: invert(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.trix-button--remove {
|
||||
background-color: var(--color-negative);
|
||||
border-radius: 1.8em !important;
|
||||
|
||||
&::before {
|
||||
filter: invert(100);
|
||||
background-size: 70%;
|
||||
background-image: url("minus.svg");
|
||||
}
|
||||
option {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
||||
.trix-button-group {
|
||||
display: inline;
|
||||
/* The toolbar only shows when rich text mode is toggled on (see composer.css) */
|
||||
lexxy-toolbar {
|
||||
/* Wide, airy button spacing like the Trix toolbar */
|
||||
--lexxy-toolbar-gap: 1.3em;
|
||||
|
||||
/* Match the compact size of the Trix toolbar buttons */
|
||||
--lexxy-toolbar-button-size: 1.7em;
|
||||
--lexxy-toolbar-icon-size: 1.125em;
|
||||
|
||||
display: none;
|
||||
border-block-end: 0;
|
||||
min-width: 0;
|
||||
z-index: 3;
|
||||
|
||||
/* Tools Campfire doesn't offer, matching the Trix toolbar: underline,
|
||||
color highlights, tables, dividers and undo/redo (history tools were
|
||||
hidden under Trix as well) */
|
||||
button[name="underline"],
|
||||
button[name="table"],
|
||||
button[name="divider"],
|
||||
button[name="undo"],
|
||||
button[name="redo"],
|
||||
lexxy-highlight-dropdown {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* No group separator pipes, like the Trix toolbar */
|
||||
.lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] {
|
||||
* {
|
||||
text-decoration: none;
|
||||
}
|
||||
/* Attachment markup contains formatting whitespace that must not turn into
|
||||
line breaks under the editor's white-space: pre-wrap */
|
||||
lexxy-editor action-text-attachment {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
trix-editor & {
|
||||
action-text-attachment[content-type~="application/vnd.campfire.mention"] {
|
||||
display: inline-flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
|
||||
lexxy-editor & {
|
||||
display: block;
|
||||
inline-size: fit-content;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.trix-button:not(.trix-button--remove) {
|
||||
@media (any-hover: hover) {
|
||||
&:where(:not(:active):hover) {
|
||||
filter: brightness(0.9);
|
||||
background-color: var(--color-bg);
|
||||
/* No selection background — it peeks through the card's rounded corners.
|
||||
The lexxy-editor prefix out-specifies Lexxy's own selection rule. */
|
||||
lexxy-editor &.node--selected {
|
||||
--lexxy-attachment-bg-color: transparent;
|
||||
}
|
||||
|
||||
/* A prominent remove button on the card's top edge, horizontally
|
||||
centered, like the Trix one. The lexxy-editor prefix out-specifies
|
||||
Lexxy's own placement rules. */
|
||||
lexxy-editor & lexxy-node-delete-button {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 50%;
|
||||
inset-inline-end: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
.lexxy-floating-controls__group {
|
||||
--button-size: 2.2em;
|
||||
|
||||
background-color: var(--color-negative);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.33;
|
||||
}
|
||||
}
|
||||
|
||||
trix-editor,
|
||||
.trix-content {
|
||||
.lexxy-content {
|
||||
pre {
|
||||
background-color: var(--color-bg) !important;
|
||||
border-radius: 0.5em;
|
||||
@@ -179,11 +155,13 @@ trix-editor,
|
||||
blockquote {
|
||||
border: 0 solid var(--color-border-darker) !important;
|
||||
border-inline-start-width: var(--blockquote-border) !important;
|
||||
font-style: normal;
|
||||
margin: 0 var(--blockquote-margin) !important;
|
||||
padding-inline-start: var(--blockquote-padding) !important;
|
||||
}
|
||||
|
||||
cite {
|
||||
display: block;
|
||||
margin-inline-start: calc(var(--blockquote-padding));
|
||||
font-style: normal;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
||||
action-text-attachment[content-type~='application/vnd.campfire.mention'] {
|
||||
display: inline-flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -100,16 +100,42 @@
|
||||
}
|
||||
|
||||
.composer--rich-text {
|
||||
trix-toolbar {
|
||||
display: block;
|
||||
lexxy-toolbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
trix-editor.input {
|
||||
lexxy-editor.input {
|
||||
margin-block-start: var(--block-space-half);
|
||||
}
|
||||
}
|
||||
|
||||
.composer--edit trix-toolbar {
|
||||
/* In the message composer the toolbar goes below the text, on the same
|
||||
row as the surrounding action buttons, like the Trix toolbar did.
|
||||
The edit composer keeps it on top. */
|
||||
#composer.composer--rich-text lexxy-editor.input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Sit the toolbar row on the same baseline as the composer's round
|
||||
buttons, which pull themselves down with negative margins */
|
||||
margin-block-end: -0.25em;
|
||||
|
||||
.lexxy-editor__content {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
lexxy-toolbar {
|
||||
margin-block-start: 0.4em;
|
||||
|
||||
/* Dropdowns open upward — the toolbar sits at the bottom of the screen */
|
||||
[data-dropdown-panel] {
|
||||
inset-block-start: auto;
|
||||
inset-block-end: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.composer--edit lexxy-toolbar {
|
||||
border-block-end: 1px solid var(--color-border);
|
||||
margin-inline: -0.8em;
|
||||
padding-inline: 0.3em;
|
||||
@@ -117,6 +143,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The editor's container-type prevents it from sizing the message bubble by
|
||||
its content, so give the bubble the full available width while editing */
|
||||
.message__body:has(.composer--edit) {
|
||||
inline-size: 100%;
|
||||
|
||||
.message__body-content--editing {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.typing-indicator {
|
||||
inset-block-start: var(--indicator-position, 0.15rem);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
trix-editor,
|
||||
.trix-content {
|
||||
|
||||
.lexxy-content {
|
||||
.og-embed {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
@@ -35,10 +35,11 @@ trix-editor,
|
||||
|
||||
.og-embed__title {
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
|
||||
/* Underline the link itself so the line is link-colored */
|
||||
a {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +54,11 @@ trix-editor,
|
||||
inline-size: 15dvw;
|
||||
}
|
||||
|
||||
/* block-size/border-radius guard against Lexxy's 1em round avatar
|
||||
styling for custom attachment images */
|
||||
> * {
|
||||
block-size: auto;
|
||||
border-radius: 0;
|
||||
object-position: top right;
|
||||
inline-size: 100%;
|
||||
object-fit: contain;
|
||||
|
||||
@@ -537,7 +537,7 @@ img.message__attachment {
|
||||
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
|
||||
text-shadow: 0 0 3px var(--color-text-reversed);
|
||||
|
||||
.trix-content {
|
||||
.lexxy-content {
|
||||
font-size: 2.4em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
class Autocompletable::UsersController < ApplicationController
|
||||
def index
|
||||
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render layout: false } # <lexxy-prompt-item> elements for the mentions prompt
|
||||
format.json
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def find_autocompletable_users
|
||||
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
|
||||
query.present? ? users_scope.active.filtered_by(query) : users_scope.active
|
||||
end
|
||||
|
||||
# The rich text editor's mentions prompt filters with `filter`, the
|
||||
# autocomplete inputs with `query`
|
||||
def query
|
||||
params[:filter].presence || params[:query].presence
|
||||
end
|
||||
|
||||
def users_scope
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
module ContentFilters
|
||||
# Formatting the rich text editor produces that Rails' sanitizers don't
|
||||
# allow by default. A message passes through three sanitization layers on
|
||||
# its way to the screen, and each builds on this list:
|
||||
#
|
||||
# * 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)
|
||||
EDITOR_FORMATTING_TAGS = %w[ s u mark ]
|
||||
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
|
||||
|
||||
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
|
||||
end
|
||||
|
||||
@@ -4,7 +4,13 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
||||
end
|
||||
|
||||
def apply
|
||||
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
||||
if fragment.find_all("div").any?
|
||||
# Trix-era bodies: one <div> wrapping the link text and the embed
|
||||
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
||||
else
|
||||
# Lexxy bodies: the link sits in its own <p>, the embed follows it
|
||||
fragment.replace("p") { |node| node.at_css("action-text-attachment") ? node : nil }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -12,7 +18,7 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
||||
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
||||
|
||||
def solo_unfurled_url
|
||||
unfurled_links.first["href"] if unfurled_links.size == 1
|
||||
ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1
|
||||
end
|
||||
|
||||
def unfurled_links
|
||||
|
||||
@@ -9,7 +9,8 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
||||
|
||||
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 ] + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]
|
||||
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("")
|
||||
|
||||
@@ -15,7 +15,9 @@ class ContentFilters::StyleUnfurledTwitterAvatars < ActionText::Content::Filter
|
||||
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
||||
|
||||
def unfurled_twitter_avatars
|
||||
fragment.find_all("#{opengraph_css_selector}[url*='#{TWITTER_AVATAR_URL_PREFIX}']")
|
||||
fragment.find_all(opengraph_css_selector).select do |node|
|
||||
ActionText::Attachment::OpengraphEmbed.from_node(node)&.url.to_s.start_with?(TWITTER_AVATAR_URL_PREFIX)
|
||||
end
|
||||
end
|
||||
|
||||
def opengraph_css_selector
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
module MessagesHelper
|
||||
# auto_link re-sanitizes with Rails' default safe list, which 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
|
||||
|
||||
def message_area_tag(room, &)
|
||||
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
||||
controller: "messages presence drop-target",
|
||||
@@ -57,7 +62,8 @@ module MessagesHelper
|
||||
when "sound"
|
||||
message_sound_presentation(message)
|
||||
else
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
|
||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
|
||||
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
|
||||
end
|
||||
rescue Exception => e
|
||||
Sentry.capture_exception(e, extra: { message: message })
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
module RichTextHelper
|
||||
def rich_text_data_actions
|
||||
default_actions =
|
||||
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
|
||||
# submitByKeyboard runs in the capture phase so it can submit on Enter
|
||||
# before the editor turns the keystroke into a newline
|
||||
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
|
||||
end
|
||||
|
||||
autocomplete_actions =
|
||||
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
|
||||
|
||||
[ default_actions, autocomplete_actions ].join(" ")
|
||||
def mention_prompt_tag(room)
|
||||
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id),
|
||||
"remote-filtering": true, "empty-results": "No matches"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -73,12 +73,12 @@ module RoomsHelper
|
||||
def composer_data_actions
|
||||
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
|
||||
|
||||
trix_attachment_actions =
|
||||
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
||||
attachment_actions =
|
||||
"lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
||||
|
||||
remaining_actions =
|
||||
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
|
||||
|
||||
[ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ")
|
||||
[ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails"
|
||||
import "trix"
|
||||
import "@rails/actiontext"
|
||||
import "initializers"
|
||||
import "controllers"
|
||||
|
||||
@@ -44,21 +44,20 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
replaceMessageContent(content) {
|
||||
const editor = this.textTarget.editor
|
||||
|
||||
editor.recordUndoEntry("Format reply")
|
||||
editor.setSelectedRange([0, editor.getDocument().toString().length])
|
||||
editor.deleteInDirection("forward")
|
||||
editor.insertHTML(content)
|
||||
editor.setSelectedRange([editor.getDocument().toString().length - 1])
|
||||
this.textTarget.value = content
|
||||
this.textTarget.focus()
|
||||
this.textTarget.selection.placeCursorAtTheEnd()
|
||||
}
|
||||
|
||||
submitByKeyboard(event) {
|
||||
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
|
||||
|
||||
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
||||
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
|
||||
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
|
||||
const metaEnter = event.metaKey || event.ctrlKey
|
||||
const plainEnter = !event.shiftKey && !event.isComposing
|
||||
|
||||
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
||||
event.stopPropagation()
|
||||
this.submit(event)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +125,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#validInput() {
|
||||
return this.textTarget.textContent.trim().length > 0
|
||||
return !this.textTarget.isBlank
|
||||
}
|
||||
|
||||
async #submitFiles() {
|
||||
|
||||
@@ -89,7 +89,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
async editMyLastMessage() {
|
||||
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
|
||||
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
|
||||
|
||||
if (editorEmpty && this.#paginator.upToDate) {
|
||||
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
reply() {
|
||||
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><br>`
|
||||
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><p><br></p>`
|
||||
this.composerOutlet.replaceMessageContent(content)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
get #bodyContent() {
|
||||
const body = this.bodyTarget.querySelector(".trix-content").cloneNode(true)
|
||||
const body = this.bodyTarget.querySelector(".lexxy-content").cloneNode(true)
|
||||
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import MentionsAutocompleteHandler from "lib/autocomplete/mentions_autocomplete_handler"
|
||||
import { debounce } from "helpers/timing_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { url: String }
|
||||
|
||||
initialize() {
|
||||
this.handlers = []
|
||||
this.search = debounce(this.search.bind(this), 300)
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.element == document.activeElement) {
|
||||
this.#installHandlers()
|
||||
}
|
||||
}
|
||||
|
||||
focus(event) {
|
||||
this.#installHandlers()
|
||||
}
|
||||
|
||||
search(event) {
|
||||
const content = this.editor.getDocument().toString()
|
||||
const position = this.editor.getPosition()
|
||||
this.handlers.forEach(handler => handler.updateWithContentAndPosition(content, position))
|
||||
}
|
||||
|
||||
blur(event) {
|
||||
this.#uninstallHandlers()
|
||||
}
|
||||
|
||||
#installHandlers() {
|
||||
this.#uninstallHandlers()
|
||||
this.handlers = [ new MentionsAutocompleteHandler(this.element, this.urlValue) ]
|
||||
}
|
||||
|
||||
#uninstallHandlers() {
|
||||
this.handlers.forEach(handler => handler.destroy())
|
||||
this.handlers = []
|
||||
}
|
||||
|
||||
get editor() {
|
||||
return this.element.editor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { post } from "@rails/request.js"
|
||||
import { truncateString } from "helpers/string_helpers"
|
||||
import { escapeHTML } from "helpers/dom_helpers"
|
||||
|
||||
const OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
|
||||
|
||||
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
|
||||
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
||||
|
||||
// Unfurls URLs pasted into the rich text editor into OpenGraph preview attachments
|
||||
export default class extends Controller {
|
||||
#abortController
|
||||
|
||||
disconnect() {
|
||||
this.#abortController?.abort()
|
||||
}
|
||||
|
||||
async unfurl(event) {
|
||||
if (!this.element.permitsAttachmentContentType(OPENGRAPH_EMBED_CONTENT_TYPE)) return
|
||||
|
||||
const { url, insertBelowLink } = event.detail
|
||||
|
||||
const metadata = await this.#fetchOpengraphMetadata(url)
|
||||
|
||||
if (metadata) {
|
||||
insertBelowLink(this.#opengraphEmbedHTML(metadata), { attachment: { contentType: OPENGRAPH_EMBED_CONTENT_TYPE } })
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchOpengraphMetadata(url) {
|
||||
this.#abortController?.abort()
|
||||
this.#abortController = new AbortController()
|
||||
|
||||
try {
|
||||
const response = await post("/unfurl_link", {
|
||||
body: { url },
|
||||
contentType: "application/json",
|
||||
signal: this.#abortController.signal
|
||||
})
|
||||
|
||||
if (response.ok && response.statusCode !== 204) {
|
||||
const { title, url: href, image, description } = await response.json
|
||||
if (title && href) return { title, href, image, description }
|
||||
}
|
||||
} catch {
|
||||
// Ignore aborted or failed requests, like the previous implementation did
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
#opengraphEmbedHTML({ title, href, image, description }) {
|
||||
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(image) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ""}">
|
||||
<div class="og-embed gap">
|
||||
<div class="og-embed__content">
|
||||
<div class="og-embed__title">
|
||||
<a href="${escapeHTML(href)}" rel="noreferrer" target="_blank">${escapeHTML(truncateString(title, 280))}</a>
|
||||
</div>
|
||||
<div class="og-embed__description">${escapeHTML(truncateString(description, 560))}</div>
|
||||
</div>
|
||||
${image ? `<div class="og-embed__image"><img src="${escapeHTML(image)}" class="image center" alt="" /></div>` : ""}
|
||||
</div>
|
||||
</actiontext-opengraph-embed>`
|
||||
}
|
||||
|
||||
#isTwitterAvatar(image) {
|
||||
return !!image?.startsWith(TWITTER_AVATAR_URL_PREFIX)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import Unfurler from "lib/rich_text/unfurl/unfurler"
|
||||
import * as Lexxy from "lexxy"
|
||||
import CampfireRichTextExtension from "lib/rich_text/campfire_extension"
|
||||
|
||||
// Support a `cite` block for attribution links
|
||||
Trix.config.blockAttributes.cite = {
|
||||
tagName: "cite",
|
||||
inheritable: false,
|
||||
}
|
||||
Lexxy.configure({
|
||||
global: {
|
||||
// Keep the content type of mention attachments (application/vnd.campfire.mention)
|
||||
// that Campfire has used since its Trix days
|
||||
attachmentContentTypeNamespace: "campfire",
|
||||
extensions: [ CampfireRichTextExtension ]
|
||||
},
|
||||
default: {
|
||||
// Campfire sends files as separate messages via the composer's own
|
||||
// attach button, never through the editor
|
||||
toolbar: { attachments: false },
|
||||
|
||||
const unfurler = new Unfurler()
|
||||
unfurler.install()
|
||||
// Trix offered a single heading level, rendered as h1
|
||||
headings: [ "h1" ]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
|
||||
import { PUNCTUATION_PATTERN } from "lib/autocomplete/constants"
|
||||
|
||||
export default class extends BaseAutocompleteHandler {
|
||||
get pattern() {
|
||||
return new RegExp(`^@(.*?)(${PUNCTUATION_PATTERN.source}*)$`)
|
||||
}
|
||||
|
||||
insertAutocompletable(autocompletable, range, terminator, options = {}) {
|
||||
const attachment = this.#createAttachmentForAutocompletable(autocompletable)
|
||||
this.#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range, options)
|
||||
}
|
||||
|
||||
// Override to set selector's position relative to the cursor in the editor
|
||||
getOffsetsAtPosition(position) {
|
||||
return this.#getOffsetsFromEditorAtPosition(this.#editor, position)
|
||||
}
|
||||
|
||||
#createAttachmentForAutocompletable(mentionable) {
|
||||
const mention = `
|
||||
<span class="mention" sgid=${mentionable.sgid}>
|
||||
<img src="${mentionable.avatar_url}" class="avatar" alt="${mentionable.name}">
|
||||
${mentionable.name}
|
||||
</span>
|
||||
`
|
||||
|
||||
return new Trix.Attachment({
|
||||
content: mention,
|
||||
contentType: "application/vnd.campfire.mention",
|
||||
sgid: mentionable.sgid
|
||||
})
|
||||
}
|
||||
|
||||
#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range) {
|
||||
if (range) { this.#editor.setSelectedRange(range) }
|
||||
this.#editor.insertAttachment(attachment)
|
||||
this.#editor.insertString(terminator)
|
||||
}
|
||||
|
||||
get #editor() {
|
||||
return this.element.editor
|
||||
}
|
||||
|
||||
#getOffsetsFromEditorAtPosition(editor, position) {
|
||||
const rect = this.#editor.getClientRectAtPosition(position)
|
||||
return rect ? rect : {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as Lexxy from "lexxy"
|
||||
import CiteNode from "lib/rich_text/cite_node"
|
||||
|
||||
export default class CampfireRichTextExtension extends Lexxy.Extension {
|
||||
get allowedElements() {
|
||||
return [
|
||||
"cite",
|
||||
"figure",
|
||||
"figcaption",
|
||||
"actiontext-opengraph-embed",
|
||||
{ tag: "div", attributes: [ "sgid" ] },
|
||||
{ tag: "img", attributes: [ "alt" ] },
|
||||
{ tag: "a", attributes: [ "rel", "target" ] }
|
||||
]
|
||||
}
|
||||
|
||||
get lexicalExtension() {
|
||||
return this.defineExtension({
|
||||
name: "campfire/rich-text",
|
||||
nodes: [ CiteNode ]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as Lexxy from "lexxy"
|
||||
|
||||
const { ElementNode } = Lexxy.Lexical
|
||||
|
||||
export default class CiteNode extends ElementNode {
|
||||
static getType() {
|
||||
return "cite"
|
||||
}
|
||||
|
||||
static clone(node) {
|
||||
return new CiteNode(node.__key)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode) {
|
||||
return new CiteNode().updateFromJSON(serializedNode)
|
||||
}
|
||||
|
||||
static importDOM() {
|
||||
return {
|
||||
cite: () => ({ conversion: () => ({ node: new CiteNode() }), priority: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
exportJSON() {
|
||||
return { ...super.exportJSON(), type: "cite" }
|
||||
}
|
||||
|
||||
createDOM() {
|
||||
return document.createElement("cite")
|
||||
}
|
||||
|
||||
updateDOM() {
|
||||
return false
|
||||
}
|
||||
|
||||
exportDOM() {
|
||||
return { element: document.createElement("cite") }
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { post } from "@rails/request.js"
|
||||
import { truncateString } from "helpers/string_helpers"
|
||||
|
||||
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
|
||||
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
||||
|
||||
export default class OpengraphEmbedOperation {
|
||||
constructor(paste) {
|
||||
this.paste = paste
|
||||
this.editor = this.paste.editor
|
||||
this.url = this.paste.string
|
||||
|
||||
this.abortController = new AbortController()
|
||||
}
|
||||
|
||||
perform() {
|
||||
return this.#createOpenGraphMetadataRequest()
|
||||
.then(response => response.json)
|
||||
.then(this.#insertOpengraphAttachment.bind(this))
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.abortController.abort()
|
||||
}
|
||||
|
||||
#createOpenGraphMetadataRequest() {
|
||||
return post("/unfurl_link", {
|
||||
body: { url: this.url },
|
||||
contentType: "application/json",
|
||||
signal: this.abortController.signal
|
||||
})
|
||||
}
|
||||
|
||||
#insertOpengraphAttachment(response) {
|
||||
if (this.#shouldInsertOpengraphPreview) {
|
||||
const currentRange = this.editor.getSelectedRange()
|
||||
this.editor.setSelectedRange(this.editor.getSelectedRange())
|
||||
this.editor.recordUndoEntry("Insert Opengraph preview for Pasted URL")
|
||||
this.editor.insertAttachment(this.#createOpengraphAttachment(response))
|
||||
this.editor.setSelectedRange(currentRange)
|
||||
}
|
||||
}
|
||||
|
||||
get #shouldInsertOpengraphPreview() {
|
||||
return this.editor.getDocument().toString().includes(this.url)
|
||||
}
|
||||
|
||||
#createOpengraphAttachment(response) {
|
||||
const { title, url, image, description } = response
|
||||
const html = this.#generateOpengraphEmbedHTML({ title, url, image, description })
|
||||
|
||||
return new Trix.Attachment({
|
||||
contentType: "application/vnd.actiontext.opengraph-embed",
|
||||
content: html,
|
||||
filename: title,
|
||||
href: url,
|
||||
url: image,
|
||||
caption: description
|
||||
})
|
||||
}
|
||||
|
||||
#generateOpengraphEmbedHTML(embed) {
|
||||
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(embed) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ''}">
|
||||
<div class="og-embed">
|
||||
<div class="og-embed__content">
|
||||
<div class="og-embed__title">${truncateString(embed.title, 560)}</div>
|
||||
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
|
||||
</div>
|
||||
<div class="og-embed__image">
|
||||
<img src="${embed.image}" class="image" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</actiontext-opengraph-embed>`
|
||||
}
|
||||
|
||||
#isTwitterAvatar(embed) {
|
||||
return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
export default class Paste {
|
||||
constructor(range, editor, document) {
|
||||
this.range = range
|
||||
this.editor = editor
|
||||
this.document = document
|
||||
if (this.document == null) { this.document = this.editor.getDocument() }
|
||||
this.string = this.document.getStringAtRange(this.range)
|
||||
}
|
||||
|
||||
isURL() {
|
||||
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/.test(this.string)
|
||||
}
|
||||
|
||||
getPathname() {
|
||||
const a = document.createElement("a")
|
||||
a.href = this.string
|
||||
return a.pathname
|
||||
}
|
||||
|
||||
isLinked() {
|
||||
const {href} = this.getCommonAttributes()
|
||||
return (href != null) && (href !== this.string)
|
||||
}
|
||||
|
||||
getCommonAttributes() {
|
||||
return this.document.getCommonAttributesAtRange(this.range)
|
||||
}
|
||||
|
||||
getSignificantPaste() {
|
||||
return new this.constructor(this.getSignificantRange(), this.editor, this.document)
|
||||
}
|
||||
|
||||
getSignificantRange() {
|
||||
const significantString = this.string.trim()
|
||||
const startOffset = this.range[0] + this.string.indexOf(significantString)
|
||||
const endOffset = startOffset + significantString.length
|
||||
return [startOffset, endOffset]
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import OpengraphEmbedOperation from "lib/rich_text/unfurl/lib/opengraph_embed_operation"
|
||||
import Paste from "lib/rich_text/unfurl/lib/paste"
|
||||
|
||||
const performOperation = (function() {
|
||||
let operation = null
|
||||
let requestId = null
|
||||
|
||||
return function(operationToPerform) {
|
||||
operation?.abort()
|
||||
cancelAnimationFrame(requestId)
|
||||
|
||||
requestId = requestAnimationFrame(function() {
|
||||
operation = operationToPerform
|
||||
operation.perform().then(() => operation = null)
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
export default class Unfurler {
|
||||
install() {
|
||||
this.#addEventListeners()
|
||||
}
|
||||
|
||||
#addEventListeners() {
|
||||
addEventListener("trix-initialize", function(event) {
|
||||
if (this.#editorElementPermitsAttribute(event.target, "href")) {
|
||||
return event.target.addEventListener("trix-paste", this.#didPaste.bind(this))
|
||||
}
|
||||
}.bind(this))
|
||||
}
|
||||
|
||||
#didPaste(event) {
|
||||
const {range} = event.paste
|
||||
const {editor} = event.target
|
||||
|
||||
if (range != null) {
|
||||
const paste = new Paste(range, editor).getSignificantPaste()
|
||||
|
||||
if (paste.isURL()) {
|
||||
if (this.#editorElementPermitsOpengraphAttachment(event.target)) {
|
||||
performOperation(new OpengraphEmbedOperation(paste))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#editorElementPermitsAttribute(element, attributeName) {
|
||||
if (element.hasAttribute("data-permitted-attributes")) {
|
||||
return Array.from(element.getAttribute("data-permitted-attributes").split(" ")).includes(attributeName)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
#editorElementPermitsOpengraphAttachment(element) {
|
||||
const permittedAttachmentTypes = element.getAttribute("data-permitted-attachment-types")
|
||||
return permittedAttachmentTypes && permittedAttachmentTypes.includes("application/vnd.actiontext.opengraph-embed")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export default class ClientMessage {
|
||||
body,
|
||||
messageTimestamp: Math.floor(now.getTime()),
|
||||
messageDatetime: now.toISOString(),
|
||||
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
|
||||
messageClasses: this.#containsOnlyEmoji(this.#plainTextFromNode(node)) ? "message--emoji" : "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,20 +58,19 @@ export default class ClientMessage {
|
||||
}
|
||||
|
||||
#matchPlayCommand(node) {
|
||||
return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
||||
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
||||
}
|
||||
|
||||
#stripWrapperElement(node) {
|
||||
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
|
||||
#plainTextFromNode(node) {
|
||||
return this.#isRichText(node) ? node.toString()?.trim() : node
|
||||
}
|
||||
|
||||
|
||||
#isRichText(node) {
|
||||
return typeof(node) != "string"
|
||||
}
|
||||
|
||||
#richTextContent(node) {
|
||||
return `<div class="trix-content">${node.innerHTML}</div>`
|
||||
return `<div class="lexxy-content">${node.value}</div>`
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,20 @@ export default class MessageFormatter {
|
||||
}
|
||||
|
||||
#highlightCodeBlock(block) {
|
||||
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
|
||||
this.#normalizeLineBreaks(block)
|
||||
if (!this.#isPlainText(block)) return
|
||||
|
||||
const language = block.dataset.language
|
||||
if (language && window.hljs.getLanguage(language)) {
|
||||
block.classList.add(`language-${language}`)
|
||||
}
|
||||
|
||||
window.hljs.highlightElement(block)
|
||||
}
|
||||
|
||||
// Lexxy breaks code block lines with <br>, Trix-era blocks used newlines
|
||||
#normalizeLineBreaks(block) {
|
||||
block.querySelectorAll("br").forEach(br => br.replaceWith("\n"))
|
||||
}
|
||||
|
||||
#isPlainText(element) {
|
||||
|
||||
@@ -5,10 +5,6 @@ module User::Mentionable
|
||||
"users/mention"
|
||||
end
|
||||
|
||||
def to_trix_content_attachment_partial_path
|
||||
"users/mention"
|
||||
end
|
||||
|
||||
def attachable_plain_text_representation(caption)
|
||||
"@#{name}"
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="og-embed__title">
|
||||
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
||||
</div>
|
||||
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
|
||||
<div class="og-embed__description"><%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…").html_safe %></div>
|
||||
</div>
|
||||
<% if opengraph_embed.url %>
|
||||
<div class="og-embed__image">
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<lexxy-prompt-item search="<%= user.name %>" sgid="<%= user.attachable_sgid %>">
|
||||
<template type="menu">
|
||||
<span class="autocomplete__item flex align-center gap unpad">
|
||||
<%= avatar_tag user %>
|
||||
<span class="autocompletable__name"><%= user.name %></span>
|
||||
</span>
|
||||
</template>
|
||||
<template type="editor">
|
||||
<%= render "users/mention", user: user %>
|
||||
</template>
|
||||
</lexxy-prompt-item>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %>
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class="trix-content">
|
||||
<div class="lexxy-content">
|
||||
<%= yield -%>
|
||||
</div>
|
||||
|
||||
@@ -14,17 +14,17 @@
|
||||
<% else %>
|
||||
<div class="composer--edit composer--rich-text">
|
||||
<%= form_with model: @message, url: room_message_path(@room, @message), id: dom_id(@message, :form), data: {
|
||||
controller: "form", action: "trix-file-accept->form#preventAttachment keydown.esc->form#cancel keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent" } do |form| %>
|
||||
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| %>
|
||||
<div class="full-width input input--actor min-width fill-white">
|
||||
<%= form.rich_text_area :body,
|
||||
rows: 1,
|
||||
class: "input",
|
||||
class: "input lexxy-content",
|
||||
aria: { multiline: "true", label: "Edit message" },
|
||||
autofocus: true,
|
||||
data: {
|
||||
controller: "rich-autocomplete",
|
||||
action: rich_text_data_actions,
|
||||
rich_autocomplete_url_value: autocompletable_users_path(room_id: @room.id) } %>
|
||||
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
|
||||
data: { action: rich_text_data_actions } do %>
|
||||
<%= mention_prompt_tag @room %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= link_to "Close editor and discard changes", room_message_path(@room, @message), data: { form_target: "cancel" }, hidden: true %>
|
||||
|
||||
@@ -19,15 +19,16 @@
|
||||
<div class="flex flex-column flex-item-grow min-width gap">
|
||||
<%= form.rich_text_area :body,
|
||||
rows: 1,
|
||||
class: "input",
|
||||
class: "input lexxy-content",
|
||||
style: "order: -1",
|
||||
aria: { multiline: "true", label: "Write a message" },
|
||||
"permitted-attachment-types": "application/vnd.campfire.mention application/vnd.actiontext.opengraph-embed",
|
||||
data: {
|
||||
controller: "rich-autocomplete",
|
||||
action: rich_text_data_actions,
|
||||
rich_autocomplete_url_value: autocompletable_users_path(room_id: room.id),
|
||||
permitted_attachment_types: "application/vnd.actiontext.opengraph-embed",
|
||||
composer_target: "text" } %>
|
||||
controller: "unfurl",
|
||||
action: "#{rich_text_data_actions} lexxy:insert-link->unfurl#unfurl",
|
||||
composer_target: "text" } do %>
|
||||
<%= mention_prompt_tag room %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<label class="btn btn--borderless txt-small flex-item-no-shrink composer__attachment-btn input--file">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="mention" sgid="<%= user.attachable_sgid %>">
|
||||
<%= avatar_tag user %>
|
||||
<%= user.name %>
|
||||
</div>
|
||||
<%# A span rather than a div: mentions render inline inside the <p> paragraphs
|
||||
the editor produces, and block elements would make the browser split the
|
||||
paragraph when parsing %>
|
||||
<span class="mention" sgid="<%= user.attachable_sgid %>"><%= avatar_tag user %> <%= user.name %></span>
|
||||
|
||||
+1
-2
@@ -5,8 +5,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
||||
pin "@hotwired/turbo-rails", to: "turbo.js"
|
||||
pin "@rails/actioncable", to: "actioncable.esm.js"
|
||||
pin "@rails/request.js", to: "@rails--request.js" # @0.0.8
|
||||
pin "trix", to: "trix.esm.min.js" # @2.1.19
|
||||
pin "@rails/actiontext", to: "actiontext.js"
|
||||
pin "lexxy", to: "lexxy.js"
|
||||
pin "highlight.js", to: "highlight.js/core.js"
|
||||
|
||||
pin_all_from "app/javascript/initializers", under: "initializers"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# The rich text editor produces formatting that Rails' default Action Text
|
||||
# sanitizer strips on render. The additions are declared once in
|
||||
# ContentFilters, shared by every sanitization layer.
|
||||
Rails.application.config.to_prepare do
|
||||
defaults = Class.new.include(ActionText::ContentHelper).new
|
||||
|
||||
ActionText::ContentHelper.allowed_tags =
|
||||
(ActionText::ContentHelper.allowed_tags || defaults.sanitizer_allowed_tags) | ContentFilters::EDITOR_FORMATTING_TAGS
|
||||
ActionText::ContentHelper.allowed_attributes =
|
||||
(ActionText::ContentHelper.allowed_attributes || defaults.sanitizer_allowed_attributes) | ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
|
||||
end
|
||||
@@ -14,12 +14,32 @@ class ActionText::Attachment::OpengraphEmbed
|
||||
end
|
||||
|
||||
private
|
||||
# Trix serialized the embed's details as attributes of the
|
||||
# <action-text-attachment> node. Lexxy only serializes the sgid, content
|
||||
# and content-type, so newer attachments carry the details in their
|
||||
# content markup instead.
|
||||
def attributes_from_node(node)
|
||||
if node["href"].present?
|
||||
{
|
||||
href: node["href"],
|
||||
url: node["url"],
|
||||
filename: node["filename"],
|
||||
description: node["caption"]
|
||||
}
|
||||
else
|
||||
attributes_from_content(node["content"].to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def attributes_from_content(content)
|
||||
fragment = Nokogiri::HTML.fragment(content)
|
||||
link = fragment.at_css(".og-embed__title a")
|
||||
|
||||
{
|
||||
href: node["href"],
|
||||
url: node["url"],
|
||||
filename: node["filename"],
|
||||
description: node["caption"]
|
||||
href: link&.[]("href"),
|
||||
url: fragment.at_css(".og-embed__image img")&.[]("src"),
|
||||
filename: link&.text&.strip,
|
||||
description: fragment.at_css(".og-embed__description")&.text&.strip
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -37,8 +57,4 @@ class ActionText::Attachment::OpengraphEmbed
|
||||
def to_partial_path
|
||||
"action_text/attachables/opengraph_embed"
|
||||
end
|
||||
|
||||
def to_trix_content_attachment_partial_path
|
||||
"action_text/attachables/opengraph_embed"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,4 +6,5 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
||||
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
|
||||
|
||||
include SystemTestHelper
|
||||
include RichTextEditorHelper
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class ContentFiltersTest < ActionView::TestCase
|
||||
include MessagesHelper, ERB::Util
|
||||
test "entire message contains an unfurled URL" do
|
||||
text = "https://basecamp.com/"
|
||||
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_basecamp(text), client_message_id: "0015", creator: users(:jason)
|
||||
@@ -10,6 +11,16 @@ class ContentFiltersTest < ActionView::TestCase
|
||||
assert_match /<div><action-text-attachment/, filtered.to_html
|
||||
end
|
||||
|
||||
test "entire message contains an unfurled URL in a lexxy body" do
|
||||
text = "https://basecamp.com/"
|
||||
body = "<p><a href=\"#{text}\">#{text}</a></p>#{unfurled_link_trix_attachment_for_basecamp}"
|
||||
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
|
||||
|
||||
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
|
||||
assert_no_match %r{>\s*https://basecamp\.com/\s*</a>}, filtered.to_html
|
||||
assert_match /<action-text-attachment/, filtered.to_html
|
||||
end
|
||||
|
||||
test "message includes additional text besides an unfurled URL" do
|
||||
text = "Hello https://basecamp.com/"
|
||||
message = Message.create! room: rooms(:pets), body: unfurled_message_body_for_basecamp(text), client_message_id: "0015", creator: users(:jason)
|
||||
@@ -53,6 +64,17 @@ class ContentFiltersTest < ActionView::TestCase
|
||||
assert_match /<div><action-text-attachment/, filtered.to_html
|
||||
end
|
||||
|
||||
test "message keeps strikethrough, underline and code block formatting" do
|
||||
body = %(<p>Hello <s>struck</s> <u>under</u> <mark>marked</mark></p><pre data-language="ruby">def x<br>end</pre>)
|
||||
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0016", creator: users(:jason)
|
||||
|
||||
html = message_presentation(message)
|
||||
assert_match %r{<s>struck</s>}, html
|
||||
assert_match %r{<u>under</u>}, html
|
||||
assert_match %r{<mark>marked</mark>}, html
|
||||
assert_match %r{<pre data-language="ruby">}, html
|
||||
end
|
||||
|
||||
test "message contains a forbidden tag" do
|
||||
exploit_image_tag = 'Hello <img src="https://ssecurityrise.com/tests/billionlaughs-cache.svg">World'
|
||||
message = Message.create! room: rooms(:pets), body: exploit_image_tag, client_message_id: "0015", creator: users(:jason)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
require "application_system_test_case"
|
||||
|
||||
class ComposerTest < ApplicationSystemTestCase
|
||||
setup do
|
||||
sign_in "jz@37signals.com"
|
||||
join_room rooms(:designers)
|
||||
end
|
||||
|
||||
test "enter sends the message when the toolbar is collapsed" do
|
||||
type_in_composer "A quick reply"
|
||||
press_in_composer :enter
|
||||
|
||||
assert_message_text "A quick reply"
|
||||
assert_composer_empty
|
||||
end
|
||||
|
||||
test "enter adds a newline in rich text mode and meta+enter sends" do
|
||||
toggle_rich_text_toolbar
|
||||
|
||||
type_in_composer "line one"
|
||||
press_in_composer :enter
|
||||
type_in_composer "line two"
|
||||
|
||||
assert_no_message_text "line one"
|
||||
|
||||
press_in_composer [ :control, :enter ]
|
||||
|
||||
assert_message_text /line one\s*line two/
|
||||
assert_composer_empty
|
||||
end
|
||||
|
||||
test "markdown strikethrough survives sanitization" do
|
||||
type_in_composer "Hello ~~Claude~~ World"
|
||||
press_in_composer :enter
|
||||
|
||||
assert_selector last_message_selector("s"), text: "Claude"
|
||||
assert_message_text "Hello Claude World"
|
||||
end
|
||||
|
||||
test "mentioning a user with @ inserts a mention attachment" do
|
||||
type_in_composer "Hey @Jas"
|
||||
pick_mention "Jason"
|
||||
click_send_button
|
||||
|
||||
assert_selector last_message_selector(".mention"), text: "Jason"
|
||||
|
||||
message = wait_for_persisted_message
|
||||
assert_includes message.body.body.to_html, "application/vnd.campfire.mention"
|
||||
assert_equal [ users(:jason) ], message.mentionees
|
||||
end
|
||||
|
||||
test "editing a message with a mention keeps the mention" do
|
||||
type_in_composer "Hey @Jas"
|
||||
pick_mention "Jason"
|
||||
click_send_button
|
||||
|
||||
assert_selector last_message_selector(".mention"), text: "Jason"
|
||||
message = wait_for_persisted_message
|
||||
|
||||
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_equal [ users(:jason) ], message.reload.mentionees
|
||||
end
|
||||
|
||||
test "replying quotes the original message with attribution" do
|
||||
within_message messages(:third) do
|
||||
reveal_message_actions
|
||||
find("[aria-label='Reply']").click
|
||||
end
|
||||
|
||||
assert_composer_text "Third time's a charm."
|
||||
|
||||
click_send_button
|
||||
|
||||
assert_selector last_message_selector("blockquote"), text: "Third time's a charm."
|
||||
assert_selector last_message_selector("cite"), text: "JZ"
|
||||
end
|
||||
|
||||
test "arrow up edits my last message when the composer is empty" do
|
||||
composer_editor.click
|
||||
press_in_composer :up
|
||||
|
||||
assert_selector ".message__body-content--editing"
|
||||
assert_edit_editor_text "Third time's a charm."
|
||||
end
|
||||
|
||||
test "pasting a URL unfurls an opengraph preview" do
|
||||
metadata = Opengraph::Metadata.new(
|
||||
title: "Example Site",
|
||||
url: "https://example.com/article",
|
||||
description: "An example article",
|
||||
image: ""
|
||||
)
|
||||
Opengraph::Metadata.stubs(:from_url).returns(metadata)
|
||||
|
||||
paste_in_composer "https://example.com/article"
|
||||
|
||||
within "#composer" do
|
||||
assert_selector ".og-embed__title", text: "Example Site", wait: 10
|
||||
end
|
||||
|
||||
click_send_button
|
||||
|
||||
assert_selector last_message_selector(".og-embed__title"), text: "Example Site"
|
||||
end
|
||||
|
||||
private
|
||||
def click_send_button
|
||||
find("#composer [data-action='composer#submit']").click
|
||||
end
|
||||
|
||||
def last_message_selector(inner)
|
||||
".message:last-of-type .message__body #{inner}"
|
||||
end
|
||||
|
||||
def assert_no_message_text(text)
|
||||
assert_no_selector ".message__body", text: text
|
||||
end
|
||||
|
||||
# The message insert races with the form POST, so give the server a moment
|
||||
def wait_for_persisted_message(room: rooms(:designers), since: 1.minute.ago)
|
||||
message = nil
|
||||
20.times do
|
||||
message = room.messages.ordered.last
|
||||
break if message.created_at > since
|
||||
sleep 0.25
|
||||
end
|
||||
message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
# Drives the rich text editor in the composer. Encapsulates all editor-specific
|
||||
# selectors and interactions so tests describe behavior, not the editor's DOM.
|
||||
module RichTextEditorHelper
|
||||
# Rails' built-in helper drives Trix; this drives Lexxy
|
||||
def fill_in_rich_text_area(id, with:)
|
||||
find("lexxy-editor##{id}").execute_script("this.value = arguments[0]", with)
|
||||
end
|
||||
|
||||
def composer_editor
|
||||
find("#composer lexxy-editor .lexxy-editor__content")
|
||||
end
|
||||
|
||||
def type_in_composer(text)
|
||||
composer_editor.click
|
||||
composer_editor.send_keys(text)
|
||||
end
|
||||
|
||||
def press_in_composer(*keys)
|
||||
composer_editor.send_keys(*keys)
|
||||
end
|
||||
|
||||
# The rich text button is only displayed for fine-pointer devices, a media
|
||||
# query headless Chrome doesn't satisfy, so click it directly.
|
||||
def toggle_rich_text_toolbar
|
||||
page.execute_script("document.querySelector('#composer .composer__rich-text-btn').click()")
|
||||
end
|
||||
|
||||
# Waits for the suggestion to appear, then commits the selected one with Tab.
|
||||
def pick_mention(name)
|
||||
assert_selector ".lexxy-prompt-menu__item", text: name
|
||||
composer_editor.send_keys :tab
|
||||
end
|
||||
|
||||
def paste_in_composer(text)
|
||||
composer_editor.click
|
||||
|
||||
page.execute_script(<<~JS, text)
|
||||
const content = document.querySelector("#composer lexxy-editor .lexxy-editor__content")
|
||||
const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: new DataTransfer() })
|
||||
event.clipboardData.setData("text/plain", arguments[0])
|
||||
content.dispatchEvent(event)
|
||||
JS
|
||||
end
|
||||
|
||||
def assert_composer_text(text)
|
||||
assert_selector "#composer lexxy-editor .lexxy-editor__content", text: text
|
||||
end
|
||||
|
||||
def assert_composer_empty
|
||||
assert_no_selector "#composer lexxy-editor .lexxy-editor__content", text: /./
|
||||
end
|
||||
|
||||
def assert_edit_editor_text(text)
|
||||
assert_selector ".message__body-content--editing lexxy-editor", text: text
|
||||
end
|
||||
end
|
||||
Vendored
-7
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user