All Files ( 78.3% covered at 21.59 hits/line )
114 files in total.
1373 relevant lines,
1075 lines covered and
298 lines missed.
(
78.3%
)
- 1
module ApplicationCable
- 1
class Channel < ActionCable::Channel::Base
end
end
- 1
module ApplicationCable
- 1
class Connection < ActionCable::Connection::Base
- 1
include Authentication::SessionLookup
- 1
identified_by :current_user
- 1
def connect
- 24
self.current_user = find_verified_user
end
- 1
private
- 1
def find_verified_user
- 24
if verified_session = find_session_by_cookie
- 24
verified_session.user
else
reject_unauthorized_connection
end
end
end
end
- 1
class HeartbeatChannel < ApplicationCable::Channel
end
- 1
class PresenceChannel < RoomChannel
- 1
on_subscribe :present, unless: :subscription_rejected?
- 1
on_unsubscribe :absent, unless: :subscription_rejected?
- 1
def present
- 24
membership.present
- 24
broadcast_read_room
end
- 1
def absent
- 24
membership.disconnected
end
- 1
def refresh
membership.refresh_connection
end
- 1
private
- 1
def membership
- 72
@room.memberships.find_by(user: current_user)
end
- 1
def broadcast_read_room
- 24
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
end
end
- 1
class ReadRoomsChannel < ApplicationCable::Channel
- 1
def subscribed
- 24
stream_from "user_#{current_user.id}_reads"
end
end
- 1
class RoomChannel < ApplicationCable::Channel
- 1
def subscribed
- 48
if @room = find_room
- 48
stream_for @room
else
reject
end
end
- 1
private
- 1
def find_room
- 48
current_user.rooms.find_by(id: params[:room_id])
end
end
- 1
class TypingNotificationsChannel < RoomChannel
- 1
def start(data)
- 3
broadcast_to @room, action: :start, user: current_user_attributes
end
- 1
def stop(data)
- 8
broadcast_to @room, action: :stop, user: current_user_attributes
end
- 1
private
- 1
def current_user_attributes
- 11
current_user.slice(:id, :name)
end
end
- 1
class UnreadRoomsChannel < ApplicationCable::Channel
- 1
def subscribed
- 24
stream_from "unread_rooms"
end
end
- 1
class Accounts::LogosController < ApplicationController
- 1
include ActiveStorage::Streaming, ActionView::Helpers::AssetUrlHelper
- 1
allow_unauthenticated_access only: :show
- 1
before_action :ensure_can_administer, only: :destroy
- 1
def show
- 15
if stale?(etag: Current.account)
- 15
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
- 15
if Current.account&.logo&.attached?
logo = Current.account.logo.variant(logo_variant).processed
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
else
- 15
send_stock_icon
end
end
end
- 1
def destroy
Current.account.logo.destroy
redirect_to edit_account_url
end
- 1
private
- 1
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
- 1
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
- 1
def send_png_file(path)
- 15
send_file path, content_type: "image/png", disposition: :inline
end
- 1
def send_stock_icon
- 15
if small_logo?
send_png_file logo_path("app-icon-192.png")
else
- 15
send_png_file logo_path("app-icon.png")
end
end
- 1
def logo_variant
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
end
- 1
def small_logo?
- 15
params[:size] == "small"
end
- 1
def logo_path(filename)
- 15
Rails.root.join("app/assets/images/logos/#{filename}")
end
end
- 1
class ApplicationController < ActionController::Base
- 1
include AllowBrowser, Authentication, Authorization, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
- 1
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
end
- 1
module AllowBrowser
- 1
extend ActiveSupport::Concern
- 1
VERSIONS = { safari: 17.2, chrome: 120, firefox: 121, opera: 104, ie: false }
- 1
included do
- 1
allow_browser versions: VERSIONS, block: -> { render template: "sessions/incompatible_browser" }
end
end
- 1
module Authentication
- 1
extend ActiveSupport::Concern
- 1
include SessionLookup
- 1
included do
- 1
before_action :require_authentication
- 1
before_action :deny_bots
- 1
helper_method :signed_in?
- 275
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
end
- 1
class_methods do
- 1
def allow_unauthenticated_access(**options)
- 2
skip_before_action :require_authentication, **options
end
- 1
def allow_bot_access(**options)
skip_before_action :deny_bots, **options
end
- 1
def require_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
before_action :restore_authentication, :redirect_signed_in_user_to_root, **options
end
end
- 1
private
- 1
def signed_in?
Current.user.present?
end
- 1
def require_authentication
- 244
restore_authentication || bot_authentication || request_authentication
end
- 1
def restore_authentication
- 244
if session = find_session_by_cookie
- 229
resume_session session
end
end
- 1
def bot_authentication
- 15
if params[:bot_key].present? && bot = User.authenticate_bot(params[:bot_key].strip)
Current.user = bot
set_authenticated_by(:bot_key)
end
end
- 1
def request_authentication
- 15
session[:return_to_after_authenticating] = request.url
- 15
redirect_to new_session_url
end
- 1
def redirect_signed_in_user_to_root
redirect_to root_url if signed_in?
end
- 1
def start_new_session_for(user)
- 15
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
- 15
authenticated_as session
end
end
- 1
def resume_session(session)
- 229
session.resume user_agent: request.user_agent, ip_address: request.remote_ip
- 229
authenticated_as session
end
- 1
def authenticated_as(session)
- 244
Current.user = session.user
- 244
set_authenticated_by(:session)
- 244
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax }
end
- 1
def post_authenticating_url
- 15
session.delete(:return_to_after_authenticating) || root_url
end
- 1
def reset_authentication
cookies.delete(:session_token)
end
- 1
def deny_bots
- 274
head :forbidden if authenticated_by.bot_key?
end
- 1
def set_authenticated_by(method)
- 244
@authenticated_by = method.to_s.inquiry
end
- 1
def authenticated_by
- 548
@authenticated_by ||= "".inquiry
end
end
- 1
module Authentication::SessionLookup
- 1
def find_session_by_cookie
- 268
if token = cookies.signed[:session_token]
- 253
Session.find_by(token: token)
end
end
end
- 1
module Authorization
- 1
private
- 1
def ensure_can_administer
head :forbidden unless Current.user.can_administer?
end
end
- 1
module RoomScoped
- 1
extend ActiveSupport::Concern
- 1
included do
- 2
before_action :set_room
end
- 1
private
- 1
def set_room
- 36
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
- 36
@room = @membership.room
end
end
- 1
module SetCurrentRequest
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
before_action do
- 289
Current.request = request
end
end
- 1
def default_url_options
- 2320
{ host: Current.request_host, protocol: Current.request_protocol }.compact_blank
end
end
- 1
module SetPlatform
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
helper_method :platform
end
- 1
private
- 1
def platform
- 875
@platform ||= ApplicationPlatform.new(request.user_agent)
end
end
- 1
module TrackedRoomVisit
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
helper_method :last_room_visited
end
- 1
def remember_last_room_visited
- 35
cookies.permanent[:last_room] = @room.id
end
- 1
def last_room_visited
- 15
Current.user.rooms.find_by(id: cookies[:last_room]) || default_room
end
- 1
private
- 1
def default_room
- 15
Current.user.rooms.original
end
end
- 1
module VersionHeaders
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
before_action :set_version_headers
end
- 1
private
- 1
def set_version_headers
- 289
response.headers["X-Version"] = Rails.application.config.app_version
- 289
response.headers["X-Rev"] = Rails.application.config.git_revision
end
end
- 1
class Messages::BoostsController < ApplicationController
- 1
before_action :set_message
- 1
def index
end
- 1
def new
end
- 1
def create
- 2
@boost = @message.boosts.create!(boost_params)
- 2
broadcast_create
- 2
redirect_to message_boosts_url(@message)
end
- 1
def destroy
- 1
@boost = Current.user.boosts.find(params[:id])
- 1
@boost.destroy!
- 1
broadcast_remove
end
- 1
private
- 1
def set_message
- 9
@message = Current.user.reachable_messages.find(params[:message_id])
end
- 1
def boost_params
- 2
params.require(:boost).permit(:content)
end
- 1
def broadcast_create
- 2
@boost.broadcast_append_to @boost.message.room, :messages,
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
end
- 1
def broadcast_remove
- 1
@boost.broadcast_remove_to @boost.message.room, :messages
end
end
- 1
class MessagesController < ApplicationController
- 1
include ActiveStorage::SetCurrent, RoomScoped
- 1
before_action :set_room, except: :create
- 1
before_action :set_message, only: %i[ show edit update destroy ]
- 1
before_action :ensure_can_administer, only: %i[ edit update destroy ]
- 1
layout false, only: :index
- 1
def index
@messages = find_paged_messages
if @messages.any?
fresh_when @messages
else
head :no_content
end
end
- 1
def create
- 4
set_room
- 4
@message = @room.messages.create_with_attachment!(message_params)
- 4
@message.broadcast_create
- 4
deliver_webhooks_to_bots
rescue ActiveRecord::RecordNotFound
render action: :room_not_found
end
- 1
def show
end
- 1
def edit
end
- 1
def update
- 2
@message.update!(message_params)
- 2
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
- 2
redirect_to room_message_url(@room, @message)
end
- 1
def destroy
- 1
@message.destroy
- 1
@message.broadcast_remove_to @room, :messages
end
- 1
private
- 1
def set_message
- 8
@message = @room.messages.find(params[:id])
end
- 1
def ensure_can_administer
- 6
head :forbidden unless Current.user.can_administer?(@message)
end
- 1
def find_paged_messages
case
when params[:before].present?
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
when params[:after].present?
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
else
@room.messages.with_creator.last_page
end
end
- 1
def message_params
- 6
params.require(:message).permit(:body, :attachment, :client_message_id)
end
- 1
def deliver_webhooks_to_bots
- 4
bots_eligible_for_webhook.excluding(@message.creator).each { |bot| bot.deliver_webhook_later(@message) }
end
- 1
def bots_eligible_for_webhook
- 4
@room.direct? ? @room.users.active_bots : @message.mentionees.active_bots
end
end
- 1
class Rooms::RefreshesController < ApplicationController
- 1
include RoomScoped
- 1
before_action :set_last_updated_at
- 1
def show
- 24
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
- 24
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
end
- 1
private
- 1
def set_last_updated_at
- 24
@last_updated_at = Time.at(0, params[:since].to_i, :millisecond)
end
end
- 1
class RoomsController < ApplicationController
- 1
before_action :set_room, only: %i[ edit update show destroy ]
- 1
before_action :ensure_can_administer, only: %i[ update destroy ]
- 1
before_action :remember_last_room_visited, only: :show
- 1
def index
redirect_to room_url(Current.user.rooms.last)
end
- 1
def show
- 35
@messages = find_messages
end
- 1
def destroy
@room.destroy
broadcast_remove_room
redirect_to root_url
end
- 1
private
- 1
def set_room
- 35
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
- 35
@room = room
else
redirect_to root_url, alert: "Room not found or inaccessible"
end
end
- 1
def ensure_can_administer
head :forbidden unless Current.user.can_administer?(@room)
end
- 1
def find_messages
- 35
messages = @room.messages.with_creator
- 35
if show_first_message = messages.find_by(id: params[:message_id])
@messages = messages.page_around(show_first_message)
else
- 35
@messages = messages.last_page
end
end
- 1
def room_params
params.require(:room).permit(:name)
end
- 1
def broadcast_remove_room
broadcast_remove_to :rooms, target: [ @room, :list ]
end
end
- 1
class SessionsController < ApplicationController
- 1
allow_unauthenticated_access only: %i[ new create ]
- 1
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render_rejection :too_many_requests }
- 1
before_action :ensure_user_exists, only: :new
- 1
def new
end
- 1
def create
- 15
if user = User.active.authenticate_by(email_address: params[:email_address], password: params[:password])
- 15
start_new_session_for user
- 15
redirect_to post_authenticating_url
else
render_rejection :unauthorized
end
end
- 1
def destroy
remove_push_subscription
reset_authentication
redirect_to root_url
end
- 1
private
- 1
def ensure_user_exists
- 15
redirect_to first_run_url if User.none?
end
- 1
def render_rejection(status)
flash.now[:alert] = "Too many requests or unauthorized."
render :new, status: status
end
- 1
def remove_push_subscription
if endpoint = params[:push_subscription_endpoint]
Push::Subscription.destroy_by(endpoint: endpoint, user_id: Current.user.id)
end
end
end
- 1
class Users::AvatarsController < ApplicationController
- 1
include ActiveStorage::Streaming
- 1
rescue_from(ActiveSupport::MessageVerifier::InvalidSignature) { head :not_found }
- 1
def show
- 75
@user = User.from_avatar_token(params[:user_id])
- 75
if stale?(etag: @user)
- 75
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
- 75
if @user.avatar.attached?
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
send_webp_blob_file avatar_variant.key
- 75
elsif @user.bot?
- 15
render_default_bot
else
- 60
render_initials
end
end
end
- 1
def destroy
Current.user.avatar.destroy
redirect_to user_profile_url
end
- 1
private
- 1
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
- 1
def send_webp_blob_file(key)
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
end
- 1
def render_default_bot
- 15
send_file Rails.root.join("app/assets/images/default-bot-avatar.svg"), content_type: "image/svg+xml", disposition: :inline
end
- 1
def render_initials
- 60
render formats: :svg
end
end
- 1
class Users::SidebarsController < ApplicationController
- 1
DIRECT_PLACEHOLDERS = 20
- 1
def show
- 59
all_memberships = Current.user.memberships.visible.with_ordered_room
- 59
@direct_memberships = extract_direct_memberships(all_memberships)
- 59
@other_memberships = all_memberships.without(@direct_memberships)
- 59
@direct_placeholder_users = find_direct_placeholder_users
end
- 1
private
- 1
def extract_direct_memberships(all_memberships)
- 331
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
end
- 1
def find_direct_placeholder_users
- 59
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
- 59
User.active.where.not(id: exclude_user_ids).order(:created_at).limit(DIRECT_PLACEHOLDERS - exclude_user_ids.count)
end
- 1
def user_ids_already_in_direct_rooms_with_current_user
- 59
Membership.where(room_id: Current.user.rooms.directs.pluck(:id)).pluck(:user_id).uniq
end
end
- 1
class WelcomeController < ApplicationController
- 1
def show
- 15
if Current.user.rooms.any?
- 15
redirect_to room_url(last_room_visited)
else
render
end
end
end
- 1
module AccountsHelper
- 1
def account_logo_tag(style: nil)
- 15
tag.figure image_tag(fresh_account_logo_path, alt: "Account logo", size: 300), class: "account-logo avatar #{style}"
end
end
- 1
module ApplicationHelper
- 1
def page_title_tag
- 55
tag.title @page_title || "Campfire"
end
- 1
def current_user_meta_tags
- 55
unless Current.user.nil?
- 40
safe_join [
tag(:meta, name: "current-user-id", content: Current.user.id),
tag(:meta, name: "current-user-name", content: Current.user.name)
]
end
end
- 1
def custom_styles_tag
- 55
if custom_styles = Current.account&.custom_styles
tag.style(custom_styles.to_s.html_safe, data: { turbo_track: "reload" })
end
end
- 1
def body_classes
- 55
[ @body_class, admin_body_class, account_logo_body_class ].compact.join(" ")
end
- 1
def link_back
link_back_to request.referrer || root_path
end
- 1
def link_back_to(destination)
link_to destination, class: "btn" do
image_tag("arrow-left.svg", aria: { hidden: "true" }, size: 20) +
tag.span("Go Back", class: "for-screen-reader")
end
end
- 1
private
- 1
def admin_body_class
- 55
"admin" if Current.user&.can_administer?
end
- 1
def account_logo_body_class
- 55
"account-has-logo" if Current.account&.logo&.attached?
end
end
- 1
module BroadcastsHelper
- 1
def broadcast_image_tag(image, options)
image_tag(broadcast_image_path(image), options)
end
- 1
def broadcast_image_path(image)
if image.is_a?(Symbol) || image.is_a?(String)
image_path(image)
else
polymorphic_url(image, only_path: true)
end
end
end
- 1
module ClipboardHelper
- 1
def button_to_copy_to_clipboard(url, &)
tag.button class: "btn", data: {
controller: "copy-to-clipboard", action: "copy-to-clipboard#copy",
copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url
}, &
end
end
- 1
module ContentFilters
- 1
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
end
- 1
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
- 1
def applicable?
- 95
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
end
- 1
def apply
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
end
- 1
private
- 1
TWITTER_DOMAINS = %w[ x.com twitter.com ]
- 1
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
- 1
def solo_unfurled_url
- 95
unfurled_links.first["href"] if unfurled_links.size == 1
end
- 1
def unfurled_links
- 95
fragment.find_all("action-text-attachment[@content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}']")
end
- 1
def normalize_tweet_url(url)
- 190
return url unless twitter_url?(url)
uri = URI.parse(url)
uri.dup.tap do |u|
u.host = TWITTER_DOMAIN_MAPPING[uri.host&.downcase] || uri.host
u.query = nil
end.to_s
rescue URI::InvalidURIError
url
end
- 1
def twitter_url?(url)
- 380
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
end
end
- 1
class ContentFilters::SanitizeTags < ActionText::Content::Filter
- 1
def applicable?
- 95
true
end
- 1
def apply
- 95
fragment.replace(not_allowed_tags_css_selector) { nil }
end
- 1
private
- 1
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" ]
- 1
def not_allowed_tags_css_selector
- 4275
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
end
end
- 1
class ContentFilters::StyleUnfurledTwitterAvatars < ActionText::Content::Filter
- 1
def applicable?
- 95
unfurled_twitter_avatars.present?
end
- 1
def apply
fragment.update do |source|
div = source.at_css("div")
div["class"] = UNFURLED_TWITTER_AVATAR_CSS_CLASS
end
end
- 1
private
- 1
UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
- 1
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
- 1
def unfurled_twitter_avatars
- 95
fragment.find_all("#{opengraph_css_selector}[url*='#{TWITTER_AVATAR_URL_PREFIX}']")
end
- 1
def opengraph_css_selector
- 95
"action-text-attachment[@content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}']"
end
end
- 1
module DropTargetHelper
- 1
def drop_target_actions
- 70
"dragenter->drop-target#dragenter dragover->drop-target#dragover drop->drop-target#drop"
end
end
- 1
module EmojiHelper
REACTIONS = {
- 1
"👍" => "Thumbs up",
"👏" => "Clapping",
"👋" => "Waving hand",
"💪" => "Muscle",
"❤️" => "Red heart",
"😂" => "Face with tears of joy",
"🎉" => "Party popper",
"🔥" => "Fire"
}
end
- 1
module FormsHelper
- 1
def auto_submit_form_with(**attributes, &)
data = attributes.delete(:data) || {}
data[:controller] = "auto-submit #{data[:controller]}".strip
form_with **attributes, data: data, &
end
end
- 1
module MessagesHelper
- 1
def message_area_tag(room, &)
- 35
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
- 1
def messages_tag(room, &)
- 35
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
- 1
def message_tag(message, &)
- 93
message_timestamp_milliseconds = message.created_at.to_fs(:epoch)
- 93
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,
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
- 1
def message_timestamp(message, **attributes)
- 93
local_datetime_tag message.created_at, **attributes
end
- 1
def message_presentation(message)
- 95
case message.content_type
when "attachment"
message_attachment_presentation(message)
when "sound"
message_sound_presentation(message)
else
- 95
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
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
- 1
private
- 1
def messages_actions
- 35
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
end
- 1
def maintain_scroll_actions
- 35
"turbo:before-stream-render@document->maintain-scroll#beforeStreamRender"
end
- 1
def refresh_room_actions
- 35
"visibilitychange@document->refresh-room#visibilityChanged online@window->refresh-room#online"
end
- 1
def presence_actions
- 35
"visibilitychange@document->presence#visibilityChanged"
end
- 1
def message_attachment_presentation(message)
Messages::AttachmentPresentation.new(message, context: self).render
end
- 1
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
- 1
def play_button
tag.button "🔊", class: "btn btn--plain", data: { action: "sound#play" }
end
- 1
def sound_image_tag(image)
image_tag image.asset_path, width: image.width, height: image.height, class: "align--middle"
end
- 1
def message_author_title(author)
[ author.name, author.bio ].compact_blank.join(" – ")
end
end
- 1
module QrCodeHelper
- 1
def link_to_zoom_qr_code(url, &)
id = Base64.urlsafe_encode64(url)
link_to qr_code_path(id), class: "btn", data: {
lightbox_target: "image", action: "lightbox#open", lightbox_url_value: qr_code_path(id) }, &
end
end
- 1
module RichTextHelper
- 1
def rich_text_data_actions
default_actions =
- 38
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
autocomplete_actions =
- 38
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
- 38
[ default_actions, autocomplete_actions ].join(" ")
end
end
- 1
module Rooms::InvolvementsHelper
- 1
def turbo_frame_for_involvement_tag(room, &)
turbo_frame_tag dom_id(room, :involvement), data: {
controller: "turbo-frame", action: "notifications:ready@window->turbo-frame#load", turbo_frame_url_param: room_involvement_path(room)
}, &
end
- 1
def button_to_change_involvement(room, involvement)
button_to room_involvement_path(room, involvement: next_involvement_for(room, involvement: involvement)),
method: :put,
role: "checkbox", aria: { checked: true, labelledby: dom_id(room, :involvement_label) }, tabindex: 0,
class: "btn #{involvement}" do
image_tag("notification-bell-#{involvement}.svg", aria: { hidden: "true" }, size: 20) +
tag.span(HUMANIZE_INVOLVEMENT[involvement], class: "for-screen-reader", id: dom_id(room, :involvement_label))
end
end
- 1
private
HUMANIZE_INVOLVEMENT = {
- 1
"mentions" => "Notifying about @ mentions",
"everything" => "Notifying about all messages",
"nothing" => "Notifications are off",
"invisible" => "Notifications are off and room invisible in sidebar"
}
- 1
SHARED_INVOLVEMENT_ORDER = %w[ mentions everything nothing invisible ]
- 1
DIRECT_INVOLVEMENT_ORDER = %w[ everything nothing ]
- 1
def next_involvement_for(room, involvement:)
if room.direct?
DIRECT_INVOLVEMENT_ORDER[DIRECT_INVOLVEMENT_ORDER.index(involvement) + 1] || DIRECT_INVOLVEMENT_ORDER.first
else
SHARED_INVOLVEMENT_ORDER[SHARED_INVOLVEMENT_ORDER.index(involvement) + 1] || SHARED_INVOLVEMENT_ORDER.first
end
end
end
- 1
module RoomsHelper
- 1
def link_to_room(room, **attributes, &)
- 202
link_to room_path(room), **attributes, data: {
rooms_list_target: "room", room_id: room.id, badge_dot_target: "unread", sorted_list_target: "item"
}.merge(attributes.delete(:data) || {}), &
end
- 1
def link_to_edit_room(room, &)
- 35
link_to \
[ :edit, @room ],
class: "btn",
style: "view-transition-name: edit-room-#{@room.id}",
data: { room_id: @room.id },
&
end
- 1
def link_back_to_last_room_visited
if last_room = last_room_visited
link_back_to room_path(last_room)
else
link_back_to root_path
end
end
- 1
def button_to_delete_room(room, url: nil)
button_to url || room_url(room), method: :delete, class: "btn btn--negative max-width", aria: { label: "Delete #{room.name}" },
data: { turbo_confirm: "Are you sure you want to delete this room and all messages in it? This can’t be undone." } do
image_tag("trash.svg", aria: { hidden: "true" }, size: 20) +
tag.span(room_display_name(room), class: "overflow-ellipsis")
end
end
- 1
def button_to_jump_to_newest_message
- 35
tag.button \
class: "message-area__return-to-latest btn",
data: { action: "messages#returnToLatest", messages_target: "latest" },
hidden: true do
- 35
image_tag("arrow-down.svg", aria: { hidden: "true" }, size: 20) +
tag.span("Jump to newest message", class: "for-screen-reader")
end
end
- 1
def submit_room_button_tag
button_tag class: "btn btn--reversed txt-large center", type: "submit" do
image_tag("check.svg", aria: { hidden: "true" }, size: 20) +
tag.span("Save", class: "for-screen-reader")
end
end
- 1
def composer_form_tag(room, &)
- 35
form_with model: Message.new, url: room_messages_path(room),
id: "composer", class: "margin-block flex-item-grow contain", data: composer_data_options(room), &
end
- 1
def room_display_name(room, for_user: Current.user)
- 163
if room.direct?
- 16
room.users.without(for_user).pluck(:name).to_sentence.presence || for_user&.name
else
- 147
room.name
end
end
- 1
private
- 1
def composer_data_options(room)
{
- 35
controller: "composer drop-target",
action: composer_data_actions,
composer_messages_outlet: "#message-area",
composer_toolbar_class: "composer--rich-text", composer_room_id_value: room.id
}
end
- 1
def composer_data_actions
- 35
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
trix_attachment_actions =
- 35
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
remaining_actions =
- 35
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
- 35
[ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ")
end
end
- 1
module SearchesHelper
- 1
def search_results_tag(&)
tag.div id: "search-results", class: "messages searches__results", data: {
controller: "search-results",
search_results_target: "messages",
search_results_me_class: "message--me",
search_results_threaded_class: "message--threaded",
search_results_mentioned_class: "message--mentioned",
search_results_formatted_class: "message--formatted"
}, &
end
end
- 1
module TimeHelper
- 1
def local_datetime_tag(datetime, style: :time, **attributes)
- 186
tag.time **attributes, datetime: datetime.iso8601, data: { local_time_target: style }
end
end
- 1
module TranslationsHelper
TRANSLATIONS = {
- 1
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email" },
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha" },
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha" },
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome" },
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta" },
room_name: { "🇺🇸": "Name the room", "🇪🇸": "Nombrar la sala", "🇫🇷": "Nommez la salle", "🇮🇳": "कमरे का नाम दें", "🇩🇪": "Geben Sie dem Raum einen Namen", "🇧🇷": "Dê um nome a essa sala" },
invite_message: { "🇺🇸": "Welcome to Campfire. To invite some people to chat with you, share the join link below.", "🇪🇸": "Bienvenido a Campfire. Para invitar a algunas personas a chatear contigo, comparte el enlace de unión que se encuentra a continuación.", "🇫🇷": "Bienvenue sur Campfire. Pour inviter des personnes à discuter avec vous, partagez le lien pour rejoindre ci-dessous.", "🇮🇳": "Campfire में आपका स्वागत है। अधिक लोगों को चैट के लिए आमंत्रित करने के लिए, नीचे जुड़ने का लिंक साझा करें।", "🇩🇪": "Willkommen bei Campfire. Um einige Personen zum Chatten einzuladen, teilen Sie den unten stehenden Beitrittslink.", "🇧🇷": "Boas vindas ao Campfire. Para convidar pessoas para conversarem com você, compartilhe o link de convite abaixo." },
incompatible_browser_messsage: { "🇺🇸": "Upgrade to a supported web browser. Campfire requires a modern web browser. Please use one of the browsers listed below and make sure auto-updates are enabled.", "🇪🇸": "Actualiza a un navegador web compatible. Campfire requiere un navegador web moderno. Utiliza uno de los navegadores listados a continuación y asegúrate de que las actualizaciones automáticas estén habilitadas.", "🇫🇷": "Mettez à jour vers un navigateur web pris en charge. Campfire nécessite un navigateur web moderne. Veuillez utiliser l'un des navigateurs répertoriés ci-dessous et assurez-vous que les mises à jour automatiques sont activées.", "🇮🇳": "समर्थित वेब ब्राउज़र में अपग्रेड करें। Campfire को एक आधुनिक वेब ब्राउज़र की आवश्यकता है। कृपया नीचे सूचीबद्ध ब्राउज़रों में से कोई एक का उपयोग करें और सुनिश्चित करें कि स्वचालित अपडेट्स सक्षम हैं।", "🇩🇪": "Aktualisieren Sie auf einen unterstützten Webbrowser. Campfire erfordert einen modernen Webbrowser. Verwenden Sie bitte einen der unten aufgeführten Browser und stellen Sie sicher, dass automatische Updates aktiviert sind.", "🇧🇷": "Atualize para um navegador compatível. O Campfire requer um navegador moderno. Por favor, use um dos navegadores listados abaixo e certifique-se de que as atualizações automáticas estão ativadas." },
bio: { "🇺🇸": "Enter a few words about yourself.", "🇪🇸": "Ingresa algunas palabras sobre ti mismo.", "🇫🇷": "Saisissez quelques mots à propos de vous-même.", "🇮🇳": "अपने बारे में कुछ शब्द लिखें.", "🇩🇪": "Geben Sie ein paar Worte über sich selbst ein.", "🇧🇷": "Insira alguma palavras sobre você." },
webhook_url: { "🇺🇸": "Webhook URL", "🇪🇸": "URL del Webhook", "🇫🇷": "URL du webhook", "🇮🇳": "वेबहुक URL", "🇩🇪": "Webhook-URL", "🇧🇷": "URL do Webhook" },
chat_bots: { "🇺🇸": "Chat bots. With Chat bots, other sites and services can post updates directly to Campfire.", "🇪🇸": "Bots de chat. Con los bots de chat, otros sitios y servicios pueden publicar actualizaciones directamente en Campfire.", "🇫🇷": "Bots de discussion. Avec les bots de discussion, d'autres sites et services peuvent publier des mises à jour directement sur Campfire.", "🇮🇳": "चैट बॉट। चैट बॉट के साथ, अन्य साइटों और सेवाएं सीधे कैम्पफायर में अपडेट पोस्ट कर सकती हैं।", "🇩🇪": "Chat-Bots. Mit Chat-Bots können andere Websites und Dienste Updates direkt in Campfire veröffentlichen.", "🇧🇷": "Chat bots. Com Chat bots, outros sites e serviços podem postar atualizações diretamente no Campfire." },
bot_name: { "🇺🇸": "Name the bot", "🇪🇸": "Nombrar al bot", "🇫🇷": "Nommer le bot", "🇮🇳": "बॉट का नाम दें", "🇩🇪": "Benenne den Bot", "🇧🇷": "Dê um nome ao bot" },
custom_styles: { "🇺🇸": "Add custom CSS styles. Use Caution: you could break things.", "🇪🇸": "Agrega estilos CSS personalizados. Usa precaución: podrías romper cosas.", "🇫🇷": "Ajoutez des styles CSS personnalisés. Utilisez avec précaution : vous pourriez casser des choses.", "🇮🇳": "कस्टम CSS स्टाइल जोड़ें। सावधानी बरतें: आप चीज़ों को तोड़ सकते हैं।", "🇩🇪": "Fügen Sie benutzerdefinierte CSS-Stile hinzu. Vorsicht: Sie könnten Dinge kaputt machen.", "🇧🇷": "Adicione estilos CSS personalizados. Use com cuidado: você pode quebrar coisas." }
}
- 1
def translations_for(translation_key)
- 30
tag.dl(class: "language-list") do
- 30
TRANSLATIONS[translation_key].map do |language, translation|
- 180
concat tag.dt(language)
- 180
concat tag.dd(translation, class: "margin-none")
end
end
end
- 1
def translation_button(translation_key)
- 30
tag.details(class: "position-relative", data: { controller: "popup", action: "keydown.esc->popup#close toggle->popup#toggle click@document->popup#closeOnClickOutside", popup_orientation_top_class: "popup-orientation-top" }) do
- 30
tag.summary(class: "btn", tabindex: -1) do
- 30
concat image_tag("globe.svg", size: 20, aria: { hidden: "true" }, class: "color-icon")
- 30
concat tag.span("Translate", class: "for-screen-reader")
end +
tag.div(class: "lanuage-list-menu shadow", data: { popup_target: "menu" }) do
- 30
translations_for(translation_key)
end
end
end
end
- 1
require "zlib"
- 1
module Users::AvatarsHelper
AVATAR_COLORS = %w[
- 1
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
]
- 1
def avatar_background_color(user)
- 60
AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size]
end
- 1
def avatar_tag(user, **options)
- 162
link_to user_path(user), title: user.title, class: "btn avatar", data: { turbo_frame: "_top" } do
- 162
image_tag fresh_user_avatar_path(user), aria: { hidden: "true" }, size: 48, **options
end
end
end
- 1
module Users::FilterHelper
- 1
def user_filter_menu_tag(&)
tag.menu class: "flex flex-column gap margin-none pad overflow-y constrain-height",
data: { controller: "filter", filter_active_class: "filter--active", filter_selected_class: "selected" }, &
end
- 1
def user_filter_search_tag
tag.input type: "search", id: "search", autocorrect: "off", autocomplete: "off", "data-1p-ignore": "true", class: "input input--transparent full-width", placeholder: "Filter…", data: { action: "input->filter#filter" }
end
end
- 1
module Users::ProfilesHelper
- 1
def profile_form_with(model, **params, &)
form_with \
model: @user, url: user_profile_path, method: :patch,
data: { controller: "form" },
**params,
&
end
- 1
def profile_form_submit_button
tag.button class: "btn btn--reversed center txt-large", type: "submit" do
image_tag("check.svg", aria: { hidden: "true" }, size: 20) +
tag.span("Save changes", class: "for-screen-reader")
end
end
- 1
def web_share_session_button(url, title, text, &)
tag.button class: "btn", hidden: true, data: {
controller: "web-share", action: "web-share#share",
web_share_url_value: url,
web_share_text_value: text,
web_share_title_value: title
}, &
end
end
- 1
module Users::SidebarHelper
- 1
def sidebar_turbo_frame_tag(src: nil, &)
- 94
turbo_frame_tag :user_sidebar, src: src, target: "_top", data: {
turbo_permanent: true,
controller: "rooms-list read-rooms turbo-frame",
rooms_list_unread_class: "unread",
action: "presence:present@window->rooms-list#read read-rooms:read->rooms-list#read turbo:frame-load->rooms-list#loaded refresh-room:visible@window->turbo-frame#reload".html_safe # otherwise -> is escaped
}, &
end
end
- 1
module UsersHelper
- 1
def button_to_direct_room_with(user)
button_to rooms_directs_path(user_ids: [ user.id ]), class: "btn btn--primary full-width txt--large" do
image_tag("messages.svg")
end
end
end
- 1
module VersionHelper
- 1
def version_badge
- 15
tag.span(Rails.application.config.app_version, class: "version-badge")
end
end
- 1
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end
- 1
class Room::PushMessageJob < ApplicationJob
- 1
def perform(room, message)
Room::MessagePusher.new(room:, message:).push
end
end
- 1
class Account < ApplicationRecord
- 1
include Joinable
- 1
has_one_attached :logo
end
- 1
module Account::Joinable
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
before_create { self.join_code = generate_join_code }
end
- 1
def reset_join_code
update! join_code: generate_join_code
end
- 1
private
- 1
def generate_join_code
SecureRandom.alphanumeric(12).scan(/.{4}/).join("-")
end
end
- 1
class ApplicationPlatform < PlatformAgent
- 1
def ios?
- 140
match? /iPhone|iPad/
end
- 1
def android?
- 105
match? /Android/
end
- 1
def mac?
match? /Macintosh/
end
- 1
def chrome?
- 175
user_agent.browser.match? /Chrome/
end
- 1
def firefox?
- 210
user_agent.browser.match? /Firefox|FxiOS/
end
- 1
def safari?
- 140
user_agent.browser.match? /Safari/
end
- 1
def edge?
- 105
user_agent.browser.match? /Edg/
end
- 1
def apple_messages?
# Apple Messages pretends to be Facebook and Twitter bots via spoofed user agent.
# We want to avoid showing "Unsupported browser" message when a Campfire link
# is shared via Messages.
match?(/facebookexternalhit/i) && match?(/Twitterbot/i)
end
- 1
def mobile?
- 105
ios? || android?
end
- 1
def desktop?
- 105
!mobile?
end
- 1
def windows?
operating_system == "Windows"
end
- 1
def operating_system
- 35
case user_agent.platform
when /Android/ then "Android"
when /iPad/ then "iPad"
when /iPhone/ then "iPhone"
when /Macintosh/ then "macOS"
when /Windows/ then "Windows"
when /CrOS/ then "ChromeOS"
else
- 35
os =~ /Linux/ ? "Linux" : os
end
end
end
- 1
class ApplicationRecord < ActiveRecord::Base
- 1
primary_abstract_class
end
- 1
class Boost < ApplicationRecord
- 1
belongs_to :message, touch: true
- 3
belongs_to :booster, class_name: "User", default: -> { Current.user }
- 96
scope :ordered, -> { order(:created_at) }
end
- 1
class Current < ActiveSupport::CurrentAttributes
- 1
attribute :user, :request
- 1
delegate :host, :protocol, to: :request, prefix: true, allow_nil: true
- 1
def account
- 315
Account.first
end
end
- 1
class Membership < ApplicationRecord
- 1
include Connectable
- 1
belongs_to :room
- 1
belongs_to :user
- 1
after_destroy_commit { user.reset_remote_connections }
- 1
enum involvement: %w[ invisible nothing mentions everything ].index_by(&:itself), _prefix: :involved_in
- 60
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
- 1
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
- 64
scope :visible, -> { where.not(involvement: :invisible) }
- 1
scope :unread, -> { where.not(unread_at: nil) }
- 1
def read
update!(unread_at: nil)
end
- 1
def unread?
- 202
unread_at.present?
end
end
- 1
module Membership::Connectable
- 1
extend ActiveSupport::Concern
- 1
CONNECTION_TTL = 60.seconds
- 1
included do
- 1
scope :connected, -> { where(connected_at: CONNECTION_TTL.ago..) }
- 5
scope :disconnected, -> { where(connected_at: [ nil, ...CONNECTION_TTL.ago ]) }
end
- 1
class_methods do
- 1
def disconnect_all
connected.update_all connected_at: nil, connections: 0, updated_at: Time.current
end
- 1
def connect(membership, connections)
- 24
where(id: membership.id).update_all(connections: connections, connected_at: Time.current, unread_at: nil)
end
end
- 1
def connected?
- 48
connected_at? && connected_at >= CONNECTION_TTL.ago
end
- 1
def present
- 24
self.class.connect(self, connected? ? connections + 1 : 1)
end
- 1
def connected
increment_connections
touch :connected_at
end
- 1
def disconnected
- 24
decrement_connections
- 24
update! connected_at: nil if connections < 1
end
- 1
def refresh_connection
increment_connections unless connected?
touch :connected_at
end
- 1
def increment_connections
connected? ? increment!(:connections, touch: true) : update!(connections: 1)
end
- 1
def decrement_connections
- 24
connected? ? decrement!(:connections, touch: true) : update!(connections: 0)
end
end
- 1
class Message < ApplicationRecord
- 1
include Attachment, Broadcasts, Mentionee, Pagination, Searchable
- 1
belongs_to :room, touch: true
- 5
belongs_to :creator, class_name: "User", default: -> { Current.user }
- 1
has_many :boosts, dependent: :destroy
- 1
has_rich_text :body
- 5
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
- 5
after_create_commit -> { room.receive(self) }
- 84
scope :ordered, -> { order(:created_at) }
- 84
scope :with_creator, -> { includes(:creator) }
- 1
def plain_text_body
- 293
body.to_plain_text.presence || attachment&.filename&.to_s || ""
end
- 1
def to_key
- 1574
[ client_message_id ]
end
- 1
def content_type
case
- 191
when attachment? then "attachment"
when sound.present? then "sound"
- 191
else "text"
end.inquiry
end
- 1
def sound
- 191
plain_text_body.match(/\A\/play (?<name>\w+)\z/) do |match|
Sound.find_by_name match[:name]
end
end
end
- 1
module Message::Attachment
- 1
extend ActiveSupport::Concern
- 1
THUMBNAIL_MAX_WIDTH = 1200
- 1
THUMBNAIL_MAX_HEIGHT = 800
- 1
included do
- 1
has_one_attached :attachment do |attachable|
- 1
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
end
end
- 1
module ClassMethods
- 1
def create_with_attachment!(attributes)
- 4
create!(attributes).tap(&:process_attachment)
end
end
- 1
def attachment?
- 191
attachment.attached?
end
- 1
def process_attachment
- 4
ensure_attachment_analyzed
- 4
process_attachment_thumbnail
end
- 1
private
- 1
def ensure_attachment_analyzed
- 4
attachment&.analyze
end
- 1
def process_attachment_thumbnail
case
- 4
when attachment.video?
attachment.preview(format: :webp).processed
when attachment.representable?
attachment.representation(:thumb).processed
end
end
end
- 1
module Message::Broadcasts
- 1
def broadcast_create
- 4
broadcast_append_to room, :messages, target: [ room, :messages ]
- 4
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end
end
- 1
module Message::Mentionee
- 1
extend ActiveSupport::Concern
- 1
def mentionees
- 4
room.users.where(id: mentioned_users.map(&:id))
end
- 1
private
- 1
def mentioned_users
- 4
if body.body
- 4
body.body.attachables.grep(User).uniq
else
[]
end
end
end
- 1
module Message::Pagination
- 1
extend ActiveSupport::Concern
- 1
PAGE_SIZE = 40
- 1
included do
- 60
scope :last_page, -> { ordered.last(PAGE_SIZE) }
- 25
scope :first_page, -> { ordered.first(PAGE_SIZE) }
- 1
scope :before, ->(message) { where("created_at < ?", message.created_at) }
- 1
scope :after, ->(message) { where("created_at > ?", message.created_at) }
- 1
scope :page_before, ->(message) { before(message).last_page }
- 1
scope :page_after, ->(message) { after(message).first_page }
- 25
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
- 25
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
end
- 1
class_methods do
- 1
def page_around(message)
page_before(message) + [ message ] + page_after(message)
end
- 1
def paged?
count > PAGE_SIZE
end
end
end
- 1
module Message::Searchable
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
after_create_commit :create_in_index
- 1
after_update_commit :update_in_index
- 1
after_destroy_commit :remove_from_index
- 1
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
end
- 1
private
- 1
def create_in_index
- 4
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
end
- 1
def update_in_index
- 5
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
end
- 1
def remove_from_index
- 1
execute_sql_with_binds "delete from message_search_index where rowid = ?", id
end
- 1
def execute_sql_with_binds(*statement)
- 10
self.class.connection.execute self.class.sanitize_sql(statement)
end
end
- 1
module Push
- 1
def self.table_name_prefix
- 1
"push_"
end
end
- 1
class Push::Subscription < ApplicationRecord
- 1
belongs_to :user
- 1
def notification(**params)
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
end
end
- 1
class Room < ApplicationRecord
- 1
has_many :memberships, dependent: :delete_all do
- 1
def grant_to(users)
room = proxy_association.owner
Membership.insert_all(Array(users).collect { |user| { room_id: room.id, user_id: user.id, involvement: room.default_involvement } })
end
- 1
def revoke_from(users)
destroy_by user: users
end
- 1
def revise(granted: [], revoked: [])
transaction do
grant_to(granted) if granted.present?
revoke_from(revoked) if revoked.present?
end
end
end
- 1
has_many :users, through: :memberships
- 1
has_many :messages, dependent: :destroy
- 1
belongs_to :creator, class_name: "User", default: -> { Current.user }
- 1
scope :opens, -> { where(type: "Rooms::Open") }
- 1
scope :closeds, -> { where(type: "Rooms::Closed") }
- 60
scope :directs, -> { where(type: "Rooms::Direct") }
- 1
scope :without_directs, -> { where.not(type: "Rooms::Direct") }
- 1
scope :ordered, -> { order("LOWER(name)") }
- 1
class << self
- 1
def create_for(attributes, users:)
transaction do
create!(attributes).tap do |room|
room.memberships.grant_to users
end
end
end
- 1
def original
- 50
order(:created_at).first
end
end
- 1
def receive(message)
- 4
unread_memberships(message)
- 4
push_later(message)
end
- 1
def open?
is_a?(Rooms::Open)
end
- 1
def closed?
is_a?(Rooms::Closed)
end
- 1
def direct?
- 474
is_a?(Rooms::Direct)
end
- 1
def default_involvement
"mentions"
end
- 1
private
- 1
def unread_memberships(message)
- 4
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end
- 1
def push_later(message)
- 4
Room::PushMessageJob.perform_later(self, message)
end
end
# Rooms where only a subset of all users on the account have been explicited granted membership.
- 1
class Rooms::Closed < Room
end
# Rooms for direct message chats between users. These act as a singleton, so a single set of users will
# always refer to the same direct room.
- 1
class Rooms::Direct < Room
- 1
class << self
- 1
def find_or_create_for(users)
find_for(users) || create_for({}, users: users)
end
- 1
private
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
# which could be to store the membership id list as a hash on the room, and use that for lookup.
- 1
def find_for(users)
all.joins(:users).detect do |room|
Set.new(room.user_ids) == Set.new(users.pluck(:id))
end
end
end
- 1
def default_involvement
"everything"
end
end
# Rooms open to all users on the account. When a new user is added to the account, they're automatically granted membership.
- 1
class Rooms::Open < Room
- 1
after_save_commit :grant_access_to_all_users
- 1
private
- 1
def grant_access_to_all_users
memberships.grant_to(User.active) if type_previously_changed?(to: "Rooms::Open")
end
end
- 1
class Search < ApplicationRecord
- 1
belongs_to :user
- 1
after_create :trim_recent_searches
- 1
scope :ordered, -> { order(updated_at: :desc) }
- 1
class << self
- 1
def record(query)
find_or_create_by(query: query).touch
end
end
- 1
private
- 1
def trim_recent_searches
user.searches.excluding(user.searches.ordered.limit(10)).destroy_all
end
end
- 1
class Session < ApplicationRecord
- 1
ACTIVITY_REFRESH_RATE = 1.hour
- 1
has_secure_token
- 1
belongs_to :user
- 16
before_create { self.last_active_at ||= Time.now }
- 1
def self.start!(user_agent:, ip_address:)
- 15
create! user_agent: user_agent, ip_address: ip_address
end
- 1
def resume(user_agent:, ip_address:)
- 229
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
end
end
end
- 1
class User < ApplicationRecord
- 1
include Avatar, Bot, Mentionable, Role, Transferable
- 1
has_many :memberships, dependent: :delete_all
- 1
has_many :rooms, through: :memberships
- 1
has_many :reachable_messages, through: :rooms, source: :messages
- 1
has_many :messages, dependent: :destroy, foreign_key: :creator_id
- 1
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
- 1
has_many :boosts, dependent: :destroy, foreign_key: :booster_id
- 1
has_many :searches, dependent: :delete_all
- 1
has_many :sessions, dependent: :destroy
- 79
scope :active, -> { where(active: true) }
- 1
has_secure_password validations: false
- 1
after_create_commit :grant_membership_to_open_rooms
- 1
scope :ordered, -> { order("LOWER(name)") }
- 1
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
- 1
def initials
- 120
name.scan(/\b\w/).join
end
- 1
def title
- 255
[ name, bio ].compact_blank.join(" – ")
end
- 1
def deactivate
transaction do
close_remote_connections
memberships.without_direct_rooms.delete_all
push_subscriptions.delete_all
searches.delete_all
sessions.delete_all
update! active: false, email_address: deactived_email_address
end
end
- 1
def deactivated?
!active?
end
- 1
def reset_remote_connections
close_remote_connections reconnect: true
end
- 1
private
- 1
def grant_membership_to_open_rooms
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
end
- 1
def deactived_email_address
email_address&.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
- 1
def close_remote_connections(reconnect: false)
ActionCable.server.remote_connections.where(current_user: self).disconnect reconnect: reconnect
end
end
- 1
module User::Avatar
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
has_one_attached :avatar
end
- 1
class_methods do
- 1
def from_avatar_token(sid)
- 75
find_signed!(sid, purpose: :avatar)
end
end
- 1
def avatar_token
- 457
signed_id(purpose: :avatar)
end
end
- 1
module User::Bot
- 1
extend ActiveSupport::Concern
- 1
included do
- 5
scope :active_bots, -> { active.where(role: :bot) }
- 1
scope :without_bots, -> { where.not(role: :bot) }
- 1
has_one :webhook, dependent: :delete
end
- 1
module ClassMethods
- 1
def create_bot!(attributes)
bot_token = generate_bot_token
webhook_url = attributes.delete(:webhook_url)
User.create!(**attributes, bot_token: bot_token, role: :bot).tap do |user|
user.create_webhook!(url: webhook_url) if webhook_url
end
end
- 1
def authenticate_bot(bot_key)
bot_id, bot_token = bot_key.split("-")
active.find_by(id: bot_id, bot_token: bot_token)
end
- 1
def generate_bot_token
- 1
SecureRandom.alphanumeric(12)
end
end
- 1
def update_bot!(attributes)
transaction do
update_webhook_url!(attributes.delete(:webhook_url))
update!(attributes)
end
end
- 1
def bot_key
"#{id}-#{bot_token}"
end
- 1
def reset_bot_key
update! bot_token: self.class.generate_bot_token
end
- 1
def webhook_url
webhook&.url
end
- 1
def deliver_webhook_later(message)
Bot::WebhookJob.perform_later(self, message) if webhook
end
- 1
def deliver_webhook(message)
webhook.deliver(message)
end
- 1
private
- 1
def update_webhook_url!(url)
if url.present?
webhook&.update!(url: url) || create_webhook!(url: url)
else
webhook&.destroy
end
end
end
- 1
module User::Mentionable
- 1
include ActionText::Attachable
- 1
def to_attachable_partial_path
"users/mention"
end
- 1
def to_trix_content_attachment_partial_path
"users/mention"
end
- 1
def attachable_plain_text_representation(caption)
"@#{name}"
end
end
- 1
module User::Role
- 1
extend ActiveSupport::Concern
- 1
included do
- 1
enum role: %i[ member administrator bot ]
end
- 1
def can_administer?(record = nil)
- 46
administrator? || self == record&.creator || record&.new_record?
end
end
- 1
module User::Transferable
- 1
extend ActiveSupport::Concern
- 1
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
- 1
class_methods do
- 1
def find_by_transfer_id(id)
find_signed(id, purpose: :transfer)
end
end
- 1
def transfer_id
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
end
end
- 1
require "net/http"
- 1
require "uri"
- 1
class Webhook < ApplicationRecord
- 1
ENDPOINT_TIMEOUT = 7.seconds
- 1
belongs_to :user
- 1
def deliver(message)
post(payload(message)).tap do |response|
if text = extract_text_from(response)
receive_text_reply_to(message.room, text: text)
elsif attachment = extract_attachment_from(response)
receive_attachment_reply_to(message.room, attachment: attachment)
end
end
rescue Net::OpenTimeout, Net::ReadTimeout
receive_text_reply_to message.room, text: "Failed to respond within #{ENDPOINT_TIMEOUT} seconds"
end
- 1
private
- 1
def post(payload)
http.request \
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
end
- 1
def http
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = (uri.scheme == "https")
http.open_timeout = ENDPOINT_TIMEOUT
http.read_timeout = ENDPOINT_TIMEOUT
end
end
- 1
def uri
@uri ||= URI(url)
end
- 1
def payload(message)
{
user: { id: message.creator.id, name: message.creator.name },
room: { id: message.room.id, name: message.room.name, path: room_bot_messages_path(message) },
message: { id: message.id, body: { html: message.body.body, plain: without_recipient_mentions(message.plain_text_body) }, path: message_path(message) }
}.to_json
end
- 1
def message_path(message)
Rails.application.routes.url_helpers.room_at_message_path(message.room, message)
end
- 1
def room_bot_messages_path(message)
Rails.application.routes.url_helpers.room_bot_messages_path(message.room, user.bot_key)
end
- 1
def extract_text_from(response)
response.body.force_encoding("UTF-8") if response.code == "200" && response.content_type.in?(%w[ text/html text/plain ])
end
- 1
def receive_text_reply_to(room, text:)
room.messages.create!(body: text, creator: user).broadcast_create
end
- 1
def extract_attachment_from(response)
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
ActiveStorage::Blob.create_and_upload! \
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
end
end
- 1
def receive_attachment_reply_to(room, attachment:)
room.messages.create_with_attachment!(attachment: attachment, creator: user).broadcast_create
end
- 1
def without_recipient_mentions(body)
body \
.gsub(user.attachable_plain_text_representation(nil), "") # Remove mentions of the recipient user
.gsub(/\A\p{Space}+|\p{Space}+\z/, "") # Remove leading and trailing whitespace uncluding unicode spaces
end
end
# Load the Rails application.
- 1
require_relative "application"
# Initialize the Rails application.
- 1
Rails.application.initialize!
- 1
require "active_support/core_ext/integer/time"
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
# and recreated between test runs. Don't rely on the data there!
- 1
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Turn false under Spring and add config.action_view.cache_template_loading = true.
- 1
config.cache_classes = true
# Eager loading loads your whole application. When running a single test locally,
# this probably isn't necessary. It's a good idea to do in a continuous integration
# system, or in some way before deploying your code.
- 1
config.eager_load = ENV["CI"].present?
# Configure public file server for tests with Cache-Control for performance.
- 1
config.public_file_server.enabled = true
- 1
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{1.hour.to_i}"
}
# Show full error reports and disable caching.
- 1
config.consider_all_requests_local = true
- 1
config.action_controller.perform_caching = false
- 1
config.cache_store = :null_store
# Raise exceptions instead of rendering exception templates.
- 1
config.action_dispatch.show_exceptions = :none
# Disable request forgery protection in test environment.
- 1
config.action_controller.allow_forgery_protection = false
# Store uploaded files on the local file system in a temporary directory.
- 1
config.active_storage.service = :test
# Print deprecation notices to the stderr.
- 1
config.active_support.deprecation = :stderr
# Raise exceptions for disallowed deprecations.
- 1
config.active_support.disallowed_deprecation = :raise
# Tell Active Support which deprecation messages to disallow.
- 1
config.active_support.disallowed_deprecation_warnings = []
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true
# Load test helpers
- 1
config.autoload_paths += %w[ test/test_helpers ]
end
- 1
ActiveSupport.on_load(:active_storage_blob) do
- 1
ActiveStorage::DiskController.after_action only: :show do
response.set_header("Cache-Control", "max-age=3600, public")
end
end
# Be sure to restart your server when you modify this file.
# Version of your assets, change this if you want to expire all your assets.
- 1
Rails.application.config.assets.version = "1.0"
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap and inline scripts
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src)
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
- 1
%w[ rails_ext ].each do |extensions_dir|
- 6
Dir["#{Rails.root}/lib/#{extensions_dir}/*"].each { |path| require "#{extensions_dir}/#{File.basename(path)}" }
end
# Be sure to restart your server when you modify this file.
# Configure parameters to be filtered from the log file. Use this to limit dissemination of
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
# notations and behaviors.
- 1
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :endpoint, "message.body"
]
# Be sure to restart your server when you modify this file.
# Add new inflection rules using the following format. Inflections
# are locale specific, and you may define rules for as many different
# locales as you wish. All of these examples are active by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.plural /^(ox)$/i, "\\1en"
# inflect.singular /^(ox)en/i, "\\1"
# inflect.irregular "person", "people"
# inflect.uncountable %w( fish sheep )
# end
# These inflection rules are supported but not enabled by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym "RESTful"
# end
# Define an application-wide HTTP permissions policy. For further
# information see https://developers.google.com/web/updates/2018/06/feature-policy
#
# Rails.application.config.permissions_policy do |f|
# f.camera :none
# f.gyroscope :none
# f.microphone :none
# f.usb :none
# f.fullscreen :self
# f.payment :self, "https://secure.example.com"
# end
- 1
if Rails.env.production? && ENV["SKIP_TELEMETRY"].blank?
Sentry.init do |config|
config.dsn = "https://975a8bf631edee43b6a8cf4823998d92@o33603.ingest.sentry.io/4506587182530560"
config.breadcrumbs_logger = [ :active_support_logger, :http_logger ]
config.send_default_pii = false
config.release = ENV["GIT_REVISION"]
end
end
- 1
Rails.application.config.session_store :cookie_store,
key: "_campfire_session",
# Persist session cookie as permament so re-opened browser windows maintain a CSRF token
expire_after: 20.years
- 1
module SQLite3Configuration
- 1
private
- 1
def configure_connection
- 2
super
- 2
if @config[:retries]
- 2
retries = self.class.type_cast_config_to_integer(@config[:retries])
- 2
raw_connection.busy_handler do |count|
(count <= retries).tap { |result| sleep count * 0.001 if result }
end
end
end
end
- 1
module SQLite3DumpConfiguration
- 1
def structure_dump(filename, extra_flags)
args = []
args.concat(Array(extra_flags)) if extra_flags
args << db_config.database
ignore_tables = ActiveRecord::SchemaDumper.ignore_tables
if ignore_tables.any?
ignore_tables = connection.data_sources.select { |table| ignore_tables.any? { |pattern| pattern === table } }
condition = ignore_tables.map { |table| connection.quote(table) }.join(", ")
args << "SELECT sql || ';' FROM sqlite_master WHERE tbl_name NOT IN (#{condition}) ORDER BY tbl_name, type DESC, name"
else
args << ".schema --nosys"
end
run_cmd("sqlite3", args, filename)
end
end
- 1
ActiveSupport.on_load :active_record do
- 1
ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend SQLite3Configuration
- 1
ActiveRecord::Tasks::SQLiteDatabaseTasks.prepend SQLite3DumpConfiguration
end
- 1
Rails.application.config.after_initialize do
- 1
%w[ db files ].each do |dir|
- 2
Rails.root.join("storage", dir).mkpath
end
end
# Used to match JavaScripts (new Date).getTime() for sorting
- 292
Time::DATE_FORMATS[:epoch] = ->(time) { (time.to_f * 1000).to_i }
- 1
Rails.application.configure do
- 1
config.x.vapid.private_key = ENV.fetch("VAPID_PRIVATE_KEY", Rails.application.credentials.dig(:vapid, :private_key))
- 1
config.x.vapid.public_key = ENV.fetch("VAPID_PUBLIC_KEY", Rails.application.credentials.dig(:vapid, :public_key))
end
- 1
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0")
- 1
Rails.application.config.git_revision = ENV["GIT_REVISION"]
- 1
require "web-push"
- 1
require "web_push/pool"
- 1
require "web_push/notification"
- 1
Rails.application.configure do
- 1
config.x.web_push_pool = WebPush::Pool.new(
invalid_subscription_handler: ->(subscription_id) do
Rails.application.executor.wrap do
Rails.logger.info "Destroying push subscription: #{subscription_id}"
Push::Subscription.find_by(id: subscription_id)&.destroy
end
end
)
- 1
at_exit { config.x.web_push_pool.shutdown }
end
- 1
module WebPush::PersistentRequest
- 1
def perform
if @options[:connection]
http = @options[:connection]
else
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
end
req = Net::HTTP::Post.new(uri.request_uri, headers)
req.body = body
if http.is_a?(Net::HTTP::Persistent)
response = http.request uri, req
else
resp = http.request(req)
verify_response(resp)
end
resp
end
end
- 1
WebPush::Request.prepend WebPush::PersistentRequest
- 1
Rails.application.routes.draw do
- 1
root "welcome#show"
- 1
resource :first_run
- 1
resource :session do
- 1
scope module: "sessions" do
- 1
resources :transfers, only: %i[ show update ]
end
end
- 1
resource :account do
- 1
scope module: "accounts" do
- 1
resources :users
- 1
resources :bots do
- 1
scope module: "bots" do
- 1
resource :key, only: :update
end
end
- 1
resource :join_code, only: :create
- 1
resource :logo, only: %i[ show destroy ]
- 1
resource :custom_styles, only: %i[ edit update ]
end
end
- 1
direct :fresh_account_logo do |options|
- 125
route_for :account_logo, v: Current.account&.updated_at&.to_fs(:number), size: options[:size]
end
- 1
get "join/:join_code", to: "users#new", as: :join
- 1
post "join/:join_code", to: "users#create"
- 1
resources :qr_code, only: :show
- 1
resources :users, only: :show do
- 1
scope module: "users" do
- 1
resource :avatar, only: %i[ show destroy ]
- 1
scope defaults: { user_id: "me" } do
- 1
resource :sidebar, only: :show
- 1
resource :profile
- 1
resources :push_subscriptions do
- 1
scope module: "push_subscriptions" do
- 1
resources :test_notifications, only: :create
end
end
end
end
end
- 1
namespace :autocompletable do
- 1
resources :users, only: :index
end
- 1
direct :fresh_user_avatar do |user, options|
- 457
route_for :user_avatar, user.avatar_token, v: user.updated_at.to_fs(:number)
end
- 1
resources :rooms do
- 1
resources :messages
- 1
post ":bot_key/messages", to: "messages/by_bots#create", as: :bot_messages
- 1
scope module: "rooms" do
- 1
resource :refresh, only: :show
- 1
resource :settings, only: :show
- 1
resource :involvement, only: %i[ show update ]
end
- 1
get "@:message_id", to: "rooms#show", as: :at_message
end
- 1
namespace :rooms do
- 1
resources :opens
- 1
resources :closeds
- 1
resources :directs
end
- 1
resources :messages do
- 1
scope module: "messages" do
- 1
resources :boosts
end
end
- 1
resources :searches, only: %i[ index create ] do
- 1
delete :clear, on: :collection
end
- 1
resource :unfurl_link, only: :create
- 1
get "webmanifest" => "pwa#manifest"
- 1
get "service-worker" => "pwa#service_worker"
- 1
get "up" => "rails/health#show", as: :rails_health_check
end
- 1
ActiveSupport.on_load(:action_text_content) do
- 1
class ActionText::Attachment
- 1
class << self
- 1
def from_node(node, attachable = nil)
new(node, attachable || ActionText::Attachment::OpengraphEmbed.from_node(node) || attachable_from_possibly_expired_sgid(node["sgid"]) || ActionText::Attachable.from_node(node))
end
- 1
private
# Our @mentions use ActionText attachments, which are signed. If someone rotates SECRET_KEY_BASE, the existing attachments become invalid.
# This allows ignoring invalid signatures for User attachments in ActionText.
- 1
ATTACHABLES_PERMITTED_WITH_INVALID_SIGNATURES = %w[ User ]
- 1
def attachable_from_possibly_expired_sgid(sgid)
if message = sgid&.split("--")&.first
encoded_message = JSON.parse Base64.strict_decode64(message)
decoded_gid = Marshal.load Base64.urlsafe_decode64(encoded_message.dig("_rails", "message"))
model = GlobalID.find(decoded_gid)
model.model_name.to_s.in?(ATTACHABLES_PERMITTED_WITH_INVALID_SIGNATURES) ? model : nil
end
rescue ActiveRecord::RecordNotFound
nil
end
end
end
end
- 1
class ActionText::Attachment::OpengraphEmbed
- 1
include ActiveModel::Model
- 1
OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
- 1
class << self
- 1
def from_node(node)
if node["content-type"]
if matches = node["content-type"].match(OPENGRAPH_EMBED_CONTENT_TYPE)
attachment = new(attributes_from_node(node))
attachment if attachment.valid?
end
end
end
- 1
private
- 1
def attributes_from_node(node)
{
href: node["href"],
url: node["url"],
filename: node["filename"],
description: node["caption"]
}
end
end
- 1
attr_accessor :href, :url, :filename, :description
- 1
def attachable_content_type
OPENGRAPH_EMBED_CONTENT_TYPE
end
- 1
def attachable_plain_text_representation(caption)
""
end
- 1
def to_partial_path
"action_text/attachables/opengraph_embed"
end
- 1
def to_trix_content_attachment_partial_path
"action_text/attachables/opengraph_embed"
end
end
- 1
class ActionText::Content::Filter
- 1
class << self
- 1
def apply(content)
- 285
filter = new(content)
- 285
filter.applicable? ? ActionText::Content.new(filter.apply, canonicalize: false) : content
end
end
- 1
def initialize(content)
- 285
@content = content
end
- 1
def applicable?
raise NotImplementedError
end
- 1
def apply
raise NotImplementedError
end
- 1
private
- 1
attr_reader :content
- 1
delegate :fragment, to: :content
end
- 1
class ActionText::Content::Filters
- 1
def initialize(*filters)
- 1
@filters = filters
end
- 1
def apply(content)
- 380
filters.reduce(content) { |content, filter| filter.apply(content) }
end
- 1
private
- 1
attr_reader :filters
end
- 1
class String
- 1
def all_emoji?
- 123
self.match? /\A(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+\z/u
end
end
- 1
class WebPush::Notification
- 1
def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:)
@title, @body, @path, @badge = title, body, path, badge
@endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key
end
- 1
def deliver(connection: nil)
WebPush.payload_send \
message: encoded_message,
endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key,
vapid: vapid_identification,
connection: connection,
urgency: "high"
end
- 1
private
- 1
def vapid_identification
{ subject: "mailto:support@37signals.com" }.merge \
Rails.configuration.x.vapid.symbolize_keys
end
- 1
def encoded_message
JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } }
end
- 1
def icon_path
Rails.application.routes.url_helpers.account_logo_path
end
end
# This is in lib so we can use it in a thread pool without the Rails executor
- 1
class WebPush::Pool
- 1
attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler
- 1
def initialize(invalid_subscription_handler:)
- 9
@delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000)
- 9
@invalidation_pool = Concurrent::FixedThreadPool.new(1)
- 9
@connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150)
- 9
@invalid_subscription_handler = invalid_subscription_handler
end
- 1
def queue(payload, subscriptions)
subscriptions.find_each do |subscription|
deliver_later(payload, subscription)
end
end
- 1
def shutdown
- 8
connection.shutdown
- 8
shutdown_pool(delivery_pool)
- 8
shutdown_pool(invalidation_pool)
end
- 1
private
- 1
def deliver_later(payload, subscription)
# Ensure any AR operations happen before we post to the thread pool
notification = subscription.notification(**payload)
subscription_id = subscription.id
delivery_pool.post do
deliver(notification, subscription_id)
rescue Exception => e
Rails.logger.error "Error in WebPush::Pool.deliver: #{e.class} #{e.message}"
end
rescue Concurrent::RejectedExecutionError
end
- 1
def deliver(notification, id)
notification.deliver(connection: connection)
rescue WebPush::ExpiredSubscription, OpenSSL::OpenSSLError => ex
invalidate_subscription_later(id) if invalid_subscription_handler
end
- 1
def invalidate_subscription_later(id)
invalidation_pool.post do
invalid_subscription_handler.call(id)
rescue Exception => e
Rails.logger.error "Error in WebPush::Pool.invalid_subscription_handler: #{e.class} #{e.message}"
end
end
- 1
def shutdown_pool(pool)
- 16
pool.shutdown
- 16
pool.kill unless pool.wait_for_termination(1)
end
end
- 1
require "application_system_test_case"
- 1
class SendingMessagesTest < ApplicationSystemTestCase
- 1
setup do
- 3
sign_in "jz@37signals.com"
- 3
join_room rooms(:designers)
end
- 1
test "sending messages between two users" do
- 1
using_session("Kevin") do
- 1
sign_in "kevin@37signals.com"
- 1
join_room rooms(:designers)
end
- 1
join_room rooms(:designers)
- 1
send_message "Is this thing on?"
- 1
using_session("Kevin") do
- 1
join_room rooms(:designers)
- 1
assert_message_text "Is this thing on?"
- 1
send_message "👍👍"
end
- 1
join_room rooms(:designers)
- 1
assert_message_text "👍👍"
end
- 1
test "editing messages" do
- 1
using_session("Kevin") do
- 1
sign_in "kevin@37signals.com"
- 1
join_room rooms(:designers)
end
- 1
within_message messages(:third) do
- 1
reveal_message_actions
- 1
find(".message__edit-btn").click
- 1
fill_in_rich_text_area "message_body", with: "Redacted!"
- 1
click_on "Save changes"
end
- 1
using_session("Kevin") do
- 1
join_room rooms(:designers)
- 1
assert_message_text "Redacted!"
end
end
- 1
test "deleting messages" do
- 1
using_session("Kevin") do
- 1
sign_in "kevin@37signals.com"
- 1
join_room rooms(:designers)
- 1
assert_message_text "Third time's a charm."
end
- 1
within_message messages(:third) do
- 1
reveal_message_actions
- 1
find(".message__edit-btn").click
- 1
accept_confirm do
- 1
click_on "Delete message"
end
end
- 1
using_session("Kevin") do
- 1
assert_message_text "Third time's a charm.", count: 0
end
end
end
- 1
require "application_system_test_case"
- 1
class UnreadRoomsTest < ApplicationSystemTestCase
- 1
setup do
- 1
sign_in "jz@37signals.com"
end
- 1
test "sending messages between two users" do
- 1
designers_room = rooms(:designers)
- 1
hq_room = rooms(:hq)
- 1
join_room hq_room
- 1
assert_room_read hq_room
- 1
using_session("Kevin") do
- 1
sign_in "kevin@37signals.com"
- 1
join_room designers_room
- 1
send_message("Hello!!")
- 1
send_message("Talking to myself?")
end
- 1
assert_room_unread designers_room
- 1
join_room designers_room
- 1
assert_room_read designers_room
end
end
- 1
module MentionTestHelper
- 1
def mention_attachment_for(name)
user = users(name)
attachment_body = ApplicationController.render partial: "users/mention", locals: { user: user }
"<action-text-attachment sgid=\"#{user.attachable_sgid}\" content-type=\"application/vnd.campfire.mention\" content=\"#{attachment_body.gsub('"', '"')}\"></action-text-attachment>"
end
end
- 1
module SessionTestHelper
- 1
def parsed_cookies
ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
end
- 1
def sign_in(user)
user = users(user) unless user.is_a? User
post session_url, params: { email_address: user.email_address, password: "secret123456" }
assert cookies[:session_token].present?
end
end
- 1
module SystemTestHelper
- 1
def sign_in(email_address, password = "secret123456")
- 15
visit root_url
- 15
fill_in "email_address", with: email_address
- 15
fill_in "password", with: password
- 15
click_on "log_in"
- 15
assert_selector "a.btn", text: "Designers"
end
- 1
def wait_for_cable_connection
- 20
assert_selector "turbo-cable-stream-source[connected]", count: 3, visible: false
end
- 1
def join_room(room)
- 20
visit room_url(room)
- 20
wait_for_cable_connection
- 20
dismiss_pwa_install_prompt
end
- 1
def send_message(message)
- 4
fill_in_rich_text_area "message_body", with: message
- 4
click_on "send"
end
- 1
def within_message(message, &block)
- 9
within "#" + dom_id(message), &block
end
- 1
def assert_message_text(text, **options)
- 8
assert_selector ".message__body", text: text, **options
end
- 1
def assert_room_read(room)
- 2
assert_selector ".rooms a", class: "!unread", text: "#{room.name}", wait: 5
end
- 1
def assert_room_unread(room)
- 1
assert_selector ".rooms a", class: "unread", text: "#{room.name}", wait: 5
end
- 1
def reveal_message_actions
- 7
find(".message__options-btn").click
rescue Capybara::ElementNotFound
- 7
find(".message__options-btn", visible: false).hover.click
ensure
- 7
assert_selector ".message__boost-btn", visible: true
end
- 1
def dismiss_pwa_install_prompt
- 20
if page.has_css?("[data-pwa-install-target~='dialog']", visible: :visible, wait: 5)
click_on("Close")
end
end
end
- 1
module TurboTestHelper
- 1
def assert_rendered_turbo_stream_broadcast(*streambles, action:, target:, &block)
streams = find_broadcasts_for(*streambles)
target = ActionView::RecordIdentifier.dom_id(*target)
assert_select Nokogiri::HTML.fragment(streams), %(turbo-stream[action="#{action}"][target="#{target}"]), &block
end
- 1
private
- 1
def find_broadcasts_for(*streambles)
broadcasting = streambles.collect do |streamble|
streamble.try(:to_gid_param) || streamble
end.join(":")
broadcasts = ActionCable.server.pubsub.broadcasts(broadcasting)
broadcasts.collect { |b| JSON.parse(b) }.join("\n\n")
end
end