mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-20 05:06:30 +09:00
That's exactly what it was given before, with quotes and apostrophes left as they are, rather than relying on its own sanitize pass to undo h's '. Covers an email address with an apostrophe.
177 lines
7.2 KiB
Ruby
177 lines
7.2 KiB
Ruby
module MessagesHelper
|
||
# 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",
|
||
action: [ messages_actions, drop_target_actions, presence_actions ].join(" "),
|
||
messages_first_of_day_class: "message--first-of-day",
|
||
messages_formatted_class: "message--formatted",
|
||
messages_me_class: "message--me",
|
||
messages_mentioned_class: "message--mentioned",
|
||
messages_threaded_class: "message--threaded",
|
||
messages_page_url_value: room_messages_url(room)
|
||
}, &
|
||
end
|
||
|
||
def messages_tag(room, &)
|
||
tag.div id: dom_id(room, :messages), class: "messages", data: {
|
||
controller: "maintain-scroll refresh-room",
|
||
action: [ maintain_scroll_actions, refresh_room_actions ].join(" "),
|
||
messages_target: "messages",
|
||
refresh_room_loaded_at_value: room.updated_at.to_fs(:epoch),
|
||
refresh_room_url_value: room_refresh_url(room)
|
||
}, &
|
||
end
|
||
|
||
def message_tag(message, &)
|
||
message_timestamp_milliseconds = message.created_at.to_fs(:epoch)
|
||
|
||
tag.div id: dom_id(message),
|
||
class: "message #{"message--emoji" if message.plain_text_body.all_emoji?}",
|
||
data: {
|
||
controller: "reply",
|
||
user_id: message.creator_id,
|
||
message_id: message.id,
|
||
client_message_id: message.client_message_id,
|
||
message_timestamp: message_timestamp_milliseconds,
|
||
message_updated_at: message.updated_at.to_fs(:epoch),
|
||
sort_value: message_timestamp_milliseconds,
|
||
messages_target: "message",
|
||
search_results_target: "message",
|
||
refresh_room_target: "message",
|
||
reply_composer_outlet: "#composer"
|
||
}, &
|
||
rescue Exception => e
|
||
Sentry.capture_exception(e, extra: { message: message })
|
||
Rails.logger.error "Exception while rendering message #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
|
||
|
||
render "messages/unrenderable"
|
||
end
|
||
|
||
def message_timestamp(message, **attributes)
|
||
local_datetime_tag message.created_at, **attributes
|
||
end
|
||
|
||
def message_presentation(message)
|
||
case message.content_type
|
||
when "attachment"
|
||
message_attachment_presentation(message)
|
||
when "sound"
|
||
message_sound_presentation(message)
|
||
else
|
||
text_message_presentation(message.body.body)
|
||
end
|
||
rescue Exception => e
|
||
Sentry.capture_exception(e, extra: { message: message })
|
||
Rails.logger.error "Exception while generating message representation for #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
|
||
|
||
""
|
||
end
|
||
|
||
# These controls contain URLs and static markup, but no viewer or token state.
|
||
# Keep them across database commits; attachment controls still render afresh.
|
||
def cache_message_actions(message, &block)
|
||
return capture(&block) unless controller.perform_caching && !message.attachment?
|
||
|
||
key = fragment_name_with_digest([
|
||
"message-actions-v1", request.base_url, request.script_name, I18n.locale, message.id, message.room_id
|
||
], nil)
|
||
FragmentCache.store.fetch(key) { capture(&block) }
|
||
end
|
||
|
||
def message_fragment_cache_key(message)
|
||
return message unless controller.perform_caching &&
|
||
%i[room creator rich_text_body boosts attachment_attachment].all? { |name| message.association(name).loaded? }
|
||
|
||
room = message.room
|
||
body = message.body.body
|
||
return message if room.direct? || message.attachment? || !body || body.to_html.include?("<action-text-attachment") ||
|
||
message.boosts.any? { |boost| !boost.association(:booster).loaded? }
|
||
|
||
# Collection caching preloads these rows before deriving keys. Read their
|
||
# actual content: foreign SQL writers need not touch Rails timestamps.
|
||
dependencies = [ message.attributes, room.attributes.slice("id", "type", "name"), message.creator.attributes,
|
||
body.to_html, message.boosts.sort_by(&:created_at).map { |boost| [ boost.attributes, boost.booster.attributes ] } ]
|
||
FragmentCache::ContentKey.new(Digest::SHA256.hexdigest(ActiveSupport::JSON.encode(dependencies)))
|
||
end
|
||
|
||
private
|
||
def text_message_presentation(body)
|
||
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-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
|
||
|
||
def maintain_scroll_actions
|
||
"turbo:before-stream-render@document->maintain-scroll#beforeStreamRender"
|
||
end
|
||
|
||
def refresh_room_actions
|
||
"visibilitychange@document->refresh-room#visibilityChanged online@window->refresh-room#online"
|
||
end
|
||
|
||
def presence_actions
|
||
"visibilitychange@document->presence#visibilityChanged"
|
||
end
|
||
|
||
def message_attachment_presentation(message)
|
||
Messages::AttachmentPresentation.new(message, context: self).render
|
||
end
|
||
|
||
def message_sound_presentation(message)
|
||
sound = message.sound
|
||
|
||
tag.div class: "sound", data: { controller: "sound", action: "messages:play->sound#play", sound_url_value: asset_path(sound.asset_path) } do
|
||
play_button + (sound.image ? sound_image_tag(sound.image) : sound.text)
|
||
end
|
||
end
|
||
|
||
def play_button
|
||
tag.button "🔊", class: "btn btn--plain", data: { action: "sound#play" }
|
||
end
|
||
|
||
def sound_image_tag(image)
|
||
image_tag image.asset_path, width: image.width, height: image.height, class: "align--middle"
|
||
end
|
||
|
||
def message_author_title(author)
|
||
[ author.name, author.bio ].compact_blank.join(" – ")
|
||
end
|
||
end
|