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