Initial D-letion

This commit is contained in:
Jyrki Gadinger 2024-09-04 22:43:13 +02:00
parent 2c6f2d0dab
commit d8e98eee98
469 changed files with 38 additions and 19685 deletions

10
Gemfile
View file

@ -34,15 +34,10 @@ gem "haml", "~> 6.3"
gem "hcaptcha", git: "https://github.com/retrospring/hcaptcha", ref: "fix/flash-in-turbo-streams"
gem "mini_magick"
gem "oj"
gem "rpush"
gem "rqrcode"
gem "web-push"
gem "rolify", "~> 6.0"
gem "dry-initializer", "~> 3.1"
gem "dry-types", "~> 1.7"
gem "pghero"
gem "rails_admin"
gem "sentry-rails"
@ -52,14 +47,9 @@ gem "sentry-sidekiq"
gem "sidekiq", "< 7" # remove version constraint once are ready to upgrade https://github.com/mperham/sidekiq/blob/main/docs/7.0-Upgrade.md
gem "sidekiq-scheduler"
gem "questiongenerator", "~> 1.1"
gem "httparty"
gem "redcarpet"
gem "sanitize"
gem "twitter-text"
gem "connection_pool"
gem "redis"

View file

@ -120,7 +120,6 @@ GEM
crass (1.0.6)
cssbundling-rails (1.4.1)
railties (>= 6.0.0)
csv (3.3.0)
database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.1.0)
@ -146,22 +145,6 @@ GEM
dotenv-rails (3.1.2)
dotenv (= 3.1.2)
railties (>= 6.1)
dry-core (1.0.1)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-inflector (1.0.0)
dry-initializer (3.1.1)
dry-logic (1.5.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-types (1.7.2)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
@ -212,18 +195,11 @@ GEM
rainbow
rubocop (>= 1.0)
sysexits (~> 1.1)
hkdf (0.3.0)
http-2 (0.11.0)
httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
i18n-js (4.0.0)
glob
i18n
idn-ruby (0.1.5)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
@ -276,13 +252,7 @@ GEM
minitest (5.25.1)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
nested_form (0.3.2)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-http2 (0.18.5)
http-2 (~> 0.11)
net-imap (0.4.15)
date
net-protocol
@ -316,7 +286,6 @@ GEM
nio4r (~> 2.0)
pundit (2.4.0)
activesupport (>= 3.0.0)
questiongenerator (1.1.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.9)
@ -378,16 +347,6 @@ GEM
rolify (6.0.1)
rotp (6.3.0)
rouge (4.1.3)
rpush (7.0.1)
activesupport (>= 5.2)
jwt (>= 1.5.6)
multi_json (~> 1.0)
net-http-persistent
net-http2 (~> 0.18, >= 0.18.3)
railties
rainbow
thor (>= 0.18.1, < 2.0)
webpush (~> 1.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
@ -503,14 +462,8 @@ GEM
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
twitter-text (3.1.0)
idn-ruby
unf (~> 0.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (2.5.0)
uniform_notifier (1.16.0)
view_component (3.14.0)
@ -519,12 +472,6 @@ GEM
method_source (~> 1.0)
warden (1.2.9)
rack (>= 2.0.9)
web-push (3.0.1)
jwt (~> 2.0)
openssl (~> 3.0)
webpush (1.1.0)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@ -551,8 +498,6 @@ DEPENDENCIES
devise-async
devise-i18n
dotenv-rails (~> 3.1)
dry-initializer (~> 3.1)
dry-types (~> 1.7)
factory_bot_rails
fake_email_validator
faker
@ -562,7 +507,6 @@ DEPENDENCIES
haml (~> 6.3)
haml_lint
hcaptcha!
httparty
i18n-js (= 4.0)
jsbundling-rails (~> 1.3)
json-schema
@ -581,7 +525,6 @@ DEPENDENCIES
prometheus-client (~> 4.2)
puma
pundit (~> 2.4)
questiongenerator (~> 1.1)
rails (~> 7.0.8)
rails-controller-testing
rails-i18n (~> 7.0)
@ -590,7 +533,6 @@ DEPENDENCIES
redcarpet
redis
rolify (~> 6.0)
rpush
rqrcode
rspec-its (~> 1.3)
rspec-mocks
@ -614,9 +556,7 @@ DEPENDENCIES
sprockets-rails
tldv (~> 0.1.0)
turbo-rails
twitter-text
view_component
web-push
BUNDLED WITH
2.5.5

View file

@ -1,22 +0,0 @@
%li.comment{ data: { comment_id: @comment.id } }
.d-flex
.flex-shrink-0
%a{ href: user_path(@comment.user), target: :_top }
= render AvatarComponent.new(user: @comment.user, size: "sm", classes: ["comment__user-avatar"])
.flex-grow-1
%h6.comment__user
= user_screen_name @comment.user
%span.text-muted
·
= time_tooltip @comment
.comment__content
= markdown @comment.content
.flex-shrink-0.ms-auto
- if current_user&.smiled?(@comment)
= render "reactions/destroy", type: "Comment", target: @comment
- else
= render "reactions/create", type: "Comment", target: @comment
.dropdown.d-inline
%button.btn.btn-link.answerbox__action{ data: { bs_toggle: :dropdown }, aria: { expanded: false } }
%i.fa.fa-fw.fa-ellipsis
= render "actions/comment", comment: @comment, answer: @answer

View file

@ -1,12 +0,0 @@
# frozen_string_literal: true
class CommentComponent < ApplicationComponent
include ApplicationHelper
include BootstrapHelper
include UserHelper
def initialize(comment:, answer:)
@comment = comment
@answer = answer
end
end

View file

@ -1,23 +0,0 @@
# frozen_string_literal: true
class QuestionComponent < ApplicationComponent
include ApplicationHelper
include BootstrapHelper
include UserHelper
def initialize(question:, context_user: nil, collapse: true, hide_avatar: false, profile_question: false)
@question = question
@context_user = context_user
@collapse = collapse
@hide_avatar = hide_avatar
@profile_question = profile_question
end
private
def author_identifier = @question.author_is_anonymous ? @question.author_identifier : nil
def follower_question? = !@question.author_is_anonymous && !@question.direct && @question.answer_count.positive?
def hide_avatar? = @hide_avatar || @question.author_is_anonymous
end

View file

@ -1,9 +0,0 @@
en:
anon_hint: "This question was asked anonymously"
answers:
zero: "0 answers"
one: "1 answer"
other: "%{count} answers"
asked_html: "%{user} asked %{time} ago"
visible_to_you: "Only visible to you as it was asked directly"
visible_mod_mode: "You can see this because you are in moderation view"

View file

@ -1,34 +0,0 @@
.d-flex
- unless hide_avatar?
.flex-shrink-0
%a{ href: user_path(@question.user) }
= render AvatarComponent.new(user: @question.user, size: "md", classes: ["question__avatar"])
.flex-grow-1
%h6.text-muted.question__user
- if @question.author_is_anonymous
%span{ title: t(".anon_hint"), data: { controller: :tooltip, bs_placement: :bottom } }
%i.fas.fa-user-secret
- if @profile_question && @question.direct
- if user_signed_in? && @question.user == current_user
%span.d-inline-block{ title: t(".visible_to_you"), data: { controller: :tooltip, bs_placement: :bottom } }
%i.fa.fa-eye-slash
- elsif moderation_view?
%span{ title: t(".visible_mod_mode"), data: { controller: :tooltip, bs_placement: :bottom } }
%i.fa.fa-eye-slash
= user_screen_name(@question.user, context_user: @context_user, author_identifier: author_identifier)
- if follower_question?
·
%a{ href: question_path(@question.user.screen_name, @question.id), data: { selection_hotkey: "a" } }
= t(".answers", count: @question.answer_count)
·
= time_tooltip(@question)
- if user_signed_in?
.dropdown.d-inline
%button.btn.btn-link.btn-sm.p-0{ data: { bs_toggle: :dropdown }, aria: { expanded: false } }
%i.fa.fa-fw.fa-ellipsis
= render "actions/question", question: @question
.question__body{ data: { controller: @question.long? ? "collapse" : nil } }
.question__text{ class: @question.long? && @collapse ? "collapsed" : "", data: { collapse_target: "content" } }
= question_markdown @question.content
- if @question.long? && @collapse
= render "shared/collapse", type: "question"

View file

@ -7,28 +7,9 @@ class AboutController < ApplicationController
render template: "about/index_advanced"
end
def about
@users = Rails.cache.fetch("about_count_users", expires_in: 1.hour) { user_count - current_ban_count }
@questions = Rails.cache.fetch("about_count_questions", expires_in: 1.hour) { Question.count(:id) }
@answers = Rails.cache.fetch("about_count_answers", expires_in: 1.hour) { Answer.count(:id) }
@comments = Rails.cache.fetch("about_count_comments", expires_in: 1.hour) { Comment.count(:id) }
end
def about; end
def privacy_policy; end
def terms; end
private
def user_count = User
.where.not(confirmed_at: nil)
.where("answered_count > 0")
.count
def current_ban_count = UserBan
.current
.joins(:user)
.where.not("users.confirmed_at": nil)
.where("users.answered_count > 0")
.count
end

View file

@ -1,56 +0,0 @@
# frozen_string_literal: true
class Admin::AnnouncementController < ApplicationController
before_action :authenticate_user!
def index
@announcements = Announcement.all
end
def new
@announcement = Announcement.new
end
def create
@announcement = Announcement.new(announcement_params)
@announcement.user = current_user
if @announcement.save
flash[:success] = t(".success")
redirect_to action: :index
else
flash[:error] = t(".error")
render "admin/announcement/new"
end
end
def edit
@announcement = Announcement.find(params[:id])
end
def update
@announcement = Announcement.find(params[:id])
@announcement.update(announcement_params)
if @announcement.save
flash[:success] = t(".success")
redirect_to announcement_index_path
else
flash[:error] = t(".error")
render "admin/announcement/edit"
end
end
def destroy
if Announcement.destroy(params[:id])
flash[:success] = t(".success")
else
flash[:error] = t(".error")
end
redirect_to announcement_index_path
end
private
def announcement_params
params.require(:announcement).permit(:content, :link_text, :link_href, :starts_at, :ends_at)
end
end

View file

@ -1,14 +0,0 @@
# frozen_string_literal: true
require "sidekiq/api"
class Admin::DashboardController < ApplicationController
before_action :authenticate_user!
def index
@sidekiq = {
processes: Sidekiq::ProcessSet.new,
stats: Sidekiq::Stats.new
}
end
end

View file

@ -1,81 +0,0 @@
# frozen_string_literal: true
require "cgi"
class Ajax::AnswerController < AjaxController
include SocialHelper
def create
params.require :id
params.require :answer
params.require :inbox
inbox = (params[:inbox] == "true")
if inbox
inbox_entry = InboxEntry.find(params[:id])
unless current_user == inbox_entry.user
@response[:status] = :fail
@response[:message] = t(".error")
return
end
else
question = Question.find(params[:id])
unless question.user.privacy_allow_stranger_answers
@response[:status] = :privacy_stronk
@response[:message] = t(".privacy")
return
end
end
answer = if inbox
inbox_entry.answer params[:answer], current_user
else
current_user.answer question, params[:answer]
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
@response[:sharing] = sharing_hash(answer) if current_user.sharing_enabled
return if inbox
@question = 1
@response[:render] = render_to_string(partial: "answerbox", locals: { a: answer, show_question: false })
end
def destroy
params.require :answer
answer = Answer.find(params[:answer])
unless (current_user == answer.user) || (privileged? answer.user)
@response[:status] = :nopriv
@response[:message] = t(".nopriv")
return
end
InboxEntry.create!(user: answer.user, question: answer.question, new: true, returning: true) if answer.user == current_user
answer.destroy
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
private
def sharing_hash(answer) = {
url: answer_share_url(answer),
text: prepare_tweet(answer, nil, true),
twitter: twitter_share_url(answer),
bluesky: bluesky_share_url(answer),
tumblr: tumblr_share_url(answer),
telegram: telegram_share_url(answer),
custom: CGI.escape(prepare_tweet(answer)),
}
end

View file

@ -1,45 +0,0 @@
class Ajax::CommentController < AjaxController
def create
params.require :answer
params.require :comment
answer = Answer.find(params[:answer])
begin
current_user.comment(answer, params[:comment])
rescue ActiveRecord::RecordInvalid => e
Sentry.capture_exception(e)
@response[:status] = :rec_inv
@response[:message] = t(".invalid")
return
end
comments = Comment.where(answer:).includes([{ user: :profile }, :smiles])
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
@response[:render] = render_to_string(partial: "answerbox/comments", locals: { a: answer, comments: })
@response[:count] = answer.comment_count
end
def destroy
params.require :comment
@response[:status] = :err
comment = Comment.find(params[:comment])
unless (current_user == comment.user) or (current_user == comment.answer.user) or (privileged? comment.user)
@response[:status] = :nopriv
@response[:message] = t(".nopriv")
return
end
@response[:count] = comment.answer.comment_count - 1
comment.destroy
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
end

View file

@ -1,63 +0,0 @@
# frozen_string_literal: true
class Ajax::InboxController < AjaxController
def remove
params.require :id
inbox = InboxEntry.find(params[:id])
unless current_user == inbox.user
@response[:status] = :fail
@response[:message] = t(".error")
return
end
begin
inbox.remove
rescue => e
Sentry.capture_exception(e)
@response[:status] = :err
@response[:message] = t("errors.base")
return
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
def remove_all
raise unless user_signed_in?
begin
InboxEntry.where(user: current_user).find_each(&:remove)
rescue => e
Sentry.capture_exception(e)
@response[:status] = :err
@response[:message] = t("errors.base")
return
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
def remove_all_author
begin
@target_user = User.where("LOWER(screen_name) = ?", params[:author].downcase).first!
@inbox = current_user.inbox_entries.joins(:question)
.where(questions: { user_id: @target_user.id, author_is_anonymous: false })
@inbox.each(&:remove)
rescue => e
Sentry.capture_exception(e)
@response[:status] = :err
@response[:message] = t("errors.base")
return
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
end

View file

@ -1,61 +0,0 @@
# frozen_string_literal: true
class Ajax::ListController < AjaxController
before_action :authenticate_user!
def create
params.require :name
params.require :user
@response[:status] = :err
target_user = User.find_by!(screen_name: params[:user])
list = List.create! user: current_user, display_name: params[:name]
@response[:status] = :okay
@response[:success] = true
@response[:message] = t(".success")
@response[:render] = render_to_string(partial: "modal/list/item", locals: { list: list, user: target_user })
end
def destroy
params.require :list
@response[:status] = :err
List.where(user: current_user, name: params[:list]).first.destroy!
@response[:status] = :okay
@response[:success] = true
@response[:message] = t(".success")
end
def membership
params.require :user
params.require :list
params.require :add
@response[:status] = :err
add = params[:add] == "true"
target_user = User.find_by!(screen_name: params[:user])
list = current_user.lists.find_by!(name: params[:list])
raise Errors::ListingSelfBlockedOther if current_user.blocking?(target_user)
raise Errors::ListingOtherBlockedSelf if target_user.blocking?(current_user)
if add
list.add_member target_user if list.members.find_by(user_id: target_user.id).nil?
@response[:checked] = true
@response[:message] = t(".success.add")
else
list.remove_member target_user unless list.members.find_by(user_id: target_user.id).nil?
@response[:checked] = false
@response[:message] = t(".success.remove")
end
@response[:status] = :okay
@response[:success] = true
end
end

View file

@ -1,111 +0,0 @@
# frozen_string_literal: true
class Ajax::ModerationController < AjaxController
def destroy_report
params.require :id
report = Report.find(params[:id])
begin
report.deleted = true
report.save
rescue => e
Sentry.capture_exception(e)
@response[:status] = :fail
@response[:message] = t(".error")
return
end
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
def ban
@response[:status] = :err
@response[:message] = t(".error")
params.require :user
params.require :ban
duration = params[:duration].to_i
duration_unit = params[:duration_unit].to_s
reason = params[:reason].to_s
target_user = User.find_by_screen_name!(params[:user])
unban = params[:ban] == '0'
perma = params[:duration].blank?
if !unban && target_user.has_cached_role?(:administrator)
@response[:status] = :nopriv
@response[:message] = t(".nopriv")
return
end
if unban
UseCase::User::Unban.call(target_user.id)
@response[:message] = t(".success.unban")
@response[:success] = true
@response[:status] = :okay
return
elsif perma
@response[:message] = t(".success.permanent")
expiry = nil
else
params.require :duration
params.require :duration_unit
raise Errors::InvalidBanDuration unless %w[hours days weeks months].include? duration_unit
expiry = DateTime.now + duration.public_send(duration_unit)
@response[:message] = t(".success.temporary", date: expiry.to_s)
end
UseCase::User::Ban.call(
target_user_id: target_user.id,
expiry: expiry,
reason: reason,
source_user_id: current_user.id)
target_user.save!
@response[:status] = :okay
@response[:success] = true
end
def privilege
@response[:status] = :err
params.require :user
params.require :type
params.require :status
status = params[:status] == 'true'
target_user = User.find_by_screen_name!(params[:user])
@response[:message] = t(".error")
return unless %w[moderator administrator].include? params[:type].downcase
unless current_user.has_cached_role?(:administrator)
@response[:status] = :nopriv
@response[:message] = t(".nopriv")
return
end
@response[:checked] = status
type = params[:type].downcase
target_role = type.to_sym
if status
target_user.add_role target_role
else
target_user.remove_role target_role
end
target_user.save!
@response[:message] = t(".success", privilege: params[:type])
@response[:status] = :okay
@response[:success] = true
end
end

View file

@ -1,48 +0,0 @@
# frozen_string_literal: true
class Ajax::QuestionController < AjaxController
def create
params.require :question
params.require :anonymousQuestion
params.require :rcpt
# set up fake success response -- the use cases raise errors on exceptions
# which get rescued by the base class
@response = {
success: true,
message: t(".success"),
status: :okay,
}
if user_signed_in? && params[:rcpt] == "followers"
UseCase::Question::CreateFollowers.call(
source_user_id: current_user.id,
content: params[:question],
author_identifier: AnonymousBlock.get_identifier(request.remote_ip),
send_to_own_inbox: params[:sendToOwnInbox],
)
return
end
UseCase::Question::Create.call(
source_user_id: user_signed_in? ? current_user.id : nil,
target_user_id: params[:rcpt],
content: params[:question],
anonymous: params[:anonymousQuestion],
author_identifier: AnonymousBlock.get_identifier(request.remote_ip),
)
end
def destroy
params.require :question
UseCase::Question::Destroy.call(
question_id: params[:question],
current_user:,
)
@response[:status] = :okay
@response[:message] = t(".success")
@response[:success] = true
end
end

View file

@ -1,45 +0,0 @@
class Ajax::ReportController < AjaxController
def create
params.require :id
params.require :type
@response[:status] = :err
unless user_signed_in?
@response[:status] = :noauth
@response[:message] = t(".noauth")
return
end
unless %w(answer comment question user).include? params[:type]
@response[:message] = t(".unknown")
return
end
obj = params[:type].strip.capitalize
object = case obj
when 'User'
User.find_by_screen_name! params[:id]
when 'Question'
Question.find params[:id]
when 'Answer'
Answer.find params[:id]
when 'Comment'
Comment.find params[:id]
else
Answer.find params[:id]
end
if object.nil?
@response[:message] = t(".notfound", parameter: params[:type])
return
end
current_user.report object, params[:reason]
@response[:status] = :okay
@response[:message] = t(".success", parameter: params[:type].titleize)
@response[:success] = true
end
end

View file

@ -1,58 +0,0 @@
# frozen_string_literal: true
class Ajax::WebPushController < AjaxController
before_action :authenticate_user!
def key
certificate = Rpush::Webpush::App.find_by(name: "webpush").certificate
@response[:status] = :okay
@response[:success] = true
@response[:key] = JSON.parse(certificate)["public_key"]
end
def check
params.require(:endpoint)
found = current_user.web_push_subscriptions.where("subscription ->> 'endpoint' = ?", params[:endpoint]).first
@response[:status] = if found
if found.failures >= 3
:failed
else
:subscribed
end
else
:unsubscribed
end
@response[:success] = true
end
def subscribe
params.require(:subscription)
WebPushSubscription.create!(
user: current_user,
subscription: params[:subscription]
)
@response[:status] = :okay
@response[:success] = true
@response[:message] = t(".subscription_count", count: current_user.web_push_subscriptions.count)
end
def unsubscribe
removed = if params.key?(:endpoint)
current_user.web_push_subscriptions.where("subscription ->> 'endpoint' = ?", params[:endpoint]).destroy_all
else
current_user.web_push_subscriptions.destroy_all
end
count = current_user.web_push_subscriptions.count
@response[:status] = removed.any? ? :okay : :err
@response[:success] = removed.any?
@response[:message] = t(".subscription_count", count:)
@response[:count] = count
end
end

View file

@ -1,116 +0,0 @@
# frozen_string_literal: true
class AjaxController < ApplicationController
skip_before_action :find_active_announcements
before_action :build_response
after_action :return_response
respond_to :json
unless Rails.env.development?
rescue_from(StandardError) do |e|
Sentry.capture_exception(e)
@response = {
success: false,
message: t("errors.base"),
status: :err
}
return_response
end
end
rescue_from(Errors::Base) do |e|
Sentry.capture_exception(e)
@response = {
success: false,
message: e.message,
status: e.code
}
return_response
end
rescue_from(KeyError) do |e|
Sentry.capture_exception(e)
@response = {
success: false,
message: t("errors.parameter_error", parameter: e.key),
status: :err
}
return_response
end
rescue_from(Dry::Types::CoercionError, Dry::Types::ConstraintError) do |e|
Sentry.capture_exception(e)
@response = {
success: false,
message: t("errors.invalid_parameter"),
status: :err
}
return_response
end
rescue_from(ActiveRecord::RecordNotFound) do |e|
Sentry.capture_exception(e)
@response = {
success: false,
message: t("errors.record_not_found"),
status: :not_found
}
return_response
end
rescue_from(ActionController::ParameterMissing) do |e|
Sentry.capture_exception(e)
@response = {
success: false,
message: t("errors.parameter_error", parameter: e.param.capitalize),
status: :parameter_error
}
return_response
end
rescue_from(Errors::Base) do |e|
Sentry.capture_exception(e)
@response = {
success: false,
message: I18n.t(e.locale_tag),
status: e.code
}
return_response
end
private
def build_response
@response = {
success: false,
message: "",
status: "unknown"
}
end
def return_response
# Q: Why don't we just use render(json:) here?
# A: Because otherwise Rails wants us to use views, which do not make much sense here.
#
# Q: Why do we always return 200?
# A: Because JQuery might not do things we want it to if we don't.
response.status = @status || 200
response.headers["Content-Type"] = "application/json"
response.body = @response.to_json
end
end

View file

@ -1,57 +0,0 @@
# frozen_string_literal: true
class AnonymousBlockController < ApplicationController
include TurboStreamable
before_action :authenticate_user!
turbo_stream_actions :create, :destroy
def create
params.require :question
question = Question.find(params[:question])
authorize AnonymousBlock, :create_global? if params[:global]
AnonymousBlock.create!(
user: params[:global] ? nil : current_user,
identifier: question.author_identifier,
question:,
target_user: question.user
)
inbox_id = question.inbox_entries.first&.id
question.inbox_entries.first&.destroy
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
inbox_id ? turbo_stream.remove("inbox_#{inbox_id}") : nil,
render_toast(t(".success"))
].compact
end
format.html { redirect_back(fallback_location: inbox_path) }
end
end
def destroy
params.require :id
block = AnonymousBlock.find(params[:id])
authorize block
block.destroy!
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.remove("block_#{params[:id]}"),
render_toast(t(".success"))
]
end
format.html { redirect_back(fallback_location: settings_blocks_path) }
end
end
end

View file

@ -1,60 +0,0 @@
# frozen_string_literal: true
class AnswerController < ApplicationController
before_action :authenticate_user!, only: %i[pin unpin]
include TurboStreamable
turbo_stream_actions :pin, :unpin
def show
@answer = Answer.for_user(current_user).includes(question: [:user], smiles: [:user]).find(params[:id])
@display_all = true
return unless user_signed_in?
mark_notifications_as_read
end
def pin
answer = Answer.includes(:user).find(params[:id])
UseCase::Answer::Pin.call(user: current_user, answer:)
respond_to do |format|
format.html { redirect_to(user_path(username: current_user.screen_name)) }
format.turbo_stream do
render turbo_stream: [
turbo_stream.update("ab-pin-#{answer.id}", partial: "actions/pin", locals: { answer: }),
render_toast(t(".success"))
]
end
end
end
def unpin
answer = Answer.includes(:user).find(params[:id])
UseCase::Answer::Unpin.call(user: current_user, answer:)
respond_to do |format|
format.html { redirect_to(user_path(username: current_user.screen_name)) }
format.turbo_stream do
render turbo_stream: [
turbo_stream.update("ab-pin-#{answer.id}", partial: "actions/pin", locals: { answer: }),
render_toast(t(".success"))
]
end
end
end
private
def mark_notifications_as_read
updated = Notification.where(recipient_id: current_user.id, new: true)
.and(Notification.where(type: "Notification::QuestionAnswered", target_id: @answer.id)
.or(Notification.where(type: "Notification::Commented", target_id: @answer.comments.pluck(:id)))
.or(Notification.where(type: "Notification::Smiled", target_id: @answer.smiles.pluck(:id)))
.or(Notification.where(type: "Notification::CommentSmiled", target_id: @answer.comment_smiles.pluck(:id))))
.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations
current_user.touch(:notifications_updated_at) if updated.positive?
end
end

View file

@ -7,9 +7,6 @@ class ApplicationController < ActionController::Base
before_action :sentry_user_context
before_action :configure_permitted_parameters, if: :devise_controller?
around_action :switch_locale
before_action :banned?
before_action :find_active_announcements
before_action :set_has_new_reports
# check if user wants to read
def switch_locale(&)
@ -24,43 +21,6 @@ class ApplicationController < ActionController::Base
I18n.with_locale(locale, &)
end
# check if user got hit by the banhammer of doom
def banned?
if current_user.present? && current_user.banned?
name = current_user.screen_name
# obligatory '2001: A Space Odyssey' reference
flash[:notice] = t("user.sessions.create.banned", name:)
current_ban = current_user.bans.current.first
flash[:notice] += "\n#{t('user.sessions.create.reason', reason: current_ban.reason)}" unless current_ban&.reason&.empty?
flash[:notice] += if current_ban&.permanent?
"\n#{t('user.sessions.create.permanent')}"
else
# TODO: format banned_until
"\n#{t('user.sessions.create.until', time: current_ban.expires_at)}"
end
sign_out current_user
redirect_to new_user_session_path
end
end
def find_active_announcements
@active_announcements ||= Announcement.find_active
end
def set_has_new_reports
return unless current_user&.mod?
@has_new_reports = if current_user.last_reports_visit.nil?
true
else
Report.where(deleted: false)
.where("created_at > ?", current_user.last_reports_visit)
.count.positive?
end
end
include ApplicationHelper
protected

View file

@ -1,10 +0,0 @@
# frozen_string_literal: true
class Comments::ReactionsController < ApplicationController
def index
comment = Comment.find(params[:id])
@reactions = Reaction.where(parent_type: "Comment", parent: comment.id).includes([{ user: :profile }])
redirect_to answer_path(username: comment.answer.user.screen_name, id: comment.answer.id) unless turbo_frame_request?
end
end

View file

@ -1,10 +0,0 @@
# frozen_string_literal: true
class CommentsController < ApplicationController
def index
answer = Answer.find(params[:id])
@comments = Comment.where(answer:).includes([{ user: :profile }, :smiles])
render "index", locals: { a: answer }
end
end

View file

@ -1,10 +0,0 @@
# frozen_string_literal: true
module PaginatesAnswers
def paginate_answers
@answers = yield(last_id: params[:last_id])
answer_ids = @answers.map(&:id)
@answers_last_id = answer_ids.min
@more_data_available = !yield(last_id: @answers_last_id, size: 1).select("answers.id").count.zero?
end
end

View file

@ -1,39 +0,0 @@
# frozen_string_literal: true
module TurboStreamable
extend ActiveSupport::Concern
class_methods do
def turbo_stream_actions(*actions)
around_action :handle_error, only: actions
end
end
def render_toast(message, success = true)
turbo_stream.append("toasts", partial: "shared/toast", locals: { message:, success: })
end
private
def handle_error
yield
rescue Errors::Base => e
render_error I18n.t(e.locale_tag)
rescue KeyError, ActionController::ParameterMissing => e
render_error t("errors.parameter_error", parameter: e.instance_of?(KeyError) ? e.key : e.param.capitalize)
rescue Dry::Types::CoercionError, Dry::Types::ConstraintError
render_error t("errors.invalid_parameter")
rescue ActiveRecord::RecordInvalid => e
render_error e.record.errors.full_messages.flatten.join(" ")
rescue ActiveRecord::RecordNotFound
render_error t("errors.record_not_found")
end
def render_error(message)
respond_to do |format|
format.turbo_stream do
render turbo_stream: render_toast(message, false)
end
end
end
end

View file

@ -1,34 +0,0 @@
# frozen_string_literal: true
class DiscoverController < ApplicationController
before_action :authenticate_user!
def index
return redirect_to root_path unless APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod?
top_x = 10 # only display the top X items
week_ago = Time.now.utc.ago(1.week)
@popular_answers = Answer.for_user(current_user).where("created_at > ?", week_ago).order(:smile_count).reverse_order.limit(top_x).includes(:question, :user, :comments)
@most_discussed = Answer.for_user(current_user).where("created_at > ?", week_ago).order(:comment_count).reverse_order.limit(top_x).includes(:question, :user, :comments)
@popular_questions = Question.where("created_at > ?", week_ago).order(:answer_count).reverse_order.limit(top_x).includes(:user)
@new_users = User.where("asked_count > 0").order(:id).reverse_order.limit(top_x).includes(:profile)
# .user = the user
# .question_count = how many questions did the user ask
@users_with_most_questions = Question.select("user_id, COUNT(*) AS question_count")
.where("created_at > ?", week_ago)
.where(author_is_anonymous: false)
.group(:user_id)
.order("question_count")
.reverse_order.limit(top_x)
# .user = the user
# .answer_count = how many questions did the user answer
@users_with_most_answers = Answer.select("user_id, COUNT(*) AS answer_count")
.where("created_at > ?", week_ago)
.group(:user_id)
.order("answer_count")
.reverse_order.limit(top_x)
end
end

View file

@ -1,32 +0,0 @@
# frozen_string_literal: true
class FeedbackController < ApplicationController
before_action :authenticate_user!
before_action :feature_enabled?
before_action :canny_consent_given?, only: %w[features bugs]
def consent
redirect_to feedback_bugs_path if current_user.has_cached_role? :canny_consent
end
def update
return unless params[:consent] == "true"
current_user.add_role :canny_consent
redirect_to feedback_bugs_path
end
def features; end
def bugs; end
private
def feature_enabled?
redirect_to root_path if APP_CONFIG["canny"].nil?
end
def canny_consent_given?
redirect_to feedback_consent_path unless current_user.has_cached_role? :canny_consent
end
end

View file

@ -1,77 +0,0 @@
# frozen_string_literal: true
class InboxController < ApplicationController
before_action :authenticate_user!
def show
find_inbox_entries
@delete_id = find_delete_id
@disabled = true if @inbox.empty?
mark_inbox_entries_as_read
respond_to do |format|
format.html
format.turbo_stream
end
end
def create
question = Question.create!(content: QuestionGenerator.generate,
author_is_anonymous: true,
author_identifier: "justask",
user: current_user)
inbox = InboxEntry.create!(user: current_user, question_id: question.id, new: true)
increment_metric
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.prepend("entries", partial: "inbox/entry", locals: { i: inbox })
inbox.update(new: false)
end
format.html { redirect_to inbox_path }
end
end
private
def filter_params
params.slice(*InboxFilter::KEYS).permit(*InboxFilter::KEYS)
end
def find_inbox_entries
filter = InboxFilter.new(current_user, filter_params)
@inbox = filter.cursored_results(last_id: params[:last_id])
@inbox_last_id = @inbox.map(&:id).min
@more_data_available = filter.cursored_results(last_id: @inbox_last_id, size: 1).count.positive?
@inbox_count = filter.results.count
end
def find_delete_id
return "ib-delete-all-author" if params[:author].present? && @inbox_count.positive?
"ib-delete-all"
end
# rubocop:disable Rails/SkipsModelValidations
def mark_inbox_entries_as_read
# using .dup to not modify @inbox -- useful in tests
updated = @inbox&.dup&.update_all(new: false)
current_user.touch(:inbox_updated_at) if updated.positive?
end
# rubocop:enable Rails/SkipsModelValidations
def increment_metric
Retrospring::Metrics::QUESTIONS_ASKED.increment(
labels: {
anonymous: true,
followers: false,
generated: true,
}
)
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
class LinkFilterController < ApplicationController
def index
redirect_to root_path unless params[:url]
@link = params[:url]
end
end

View file

@ -3,9 +3,6 @@
class ManifestsController < ApplicationController
include ThemeHelper
skip_before_action :banned?
skip_before_action :find_active_announcements
def show
expires_in 1.day
return if fresh_when current_user&.theme
@ -28,7 +25,6 @@ class ManifestsController < ApplicationController
private
def shortcuts = [
webapp_shortcut(inbox_url, t("navigation.inbox"), "inbox")
]
def webapp_shortcut(url, name, icon_name)

View file

@ -4,8 +4,6 @@ class ModalController < ApplicationController
include ActionView::Helpers::TagHelper
include Turbo::FramesHelper
skip_before_action :find_active_announcements, :banned?
def close
return redirect_to root_path unless turbo_frame_request?

View file

@ -1,7 +0,0 @@
# frozen_string_literal: true
class Moderation::AnonymousBlockController < ApplicationController
def index
@anonymous_blocks = AnonymousBlock.where(user: nil)
end
end

View file

@ -1,26 +0,0 @@
# frozen_string_literal: true
class Moderation::InboxController < ApplicationController
before_action :authenticate_user!
def index
@user = User.find_by(screen_name: params[:user])
filter = InboxFilter.new(@user, filter_params)
@inboxes = filter.cursored_results(last_id: params[:last_id])
@inbox_last_id = @inboxes.map(&:id).min
@more_data_available = !filter.cursored_results(last_id: @inbox_last_id, size: 1).count.zero?
@inbox_count = @user.inbox_entries.count
respond_to do |format|
format.html
format.turbo_stream { render "index", layout: false, status: :see_other }
end
end
private
def filter_params
params.slice(*InboxFilter::KEYS).permit(*InboxFilter::KEYS)
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
class Moderation::QuestionsController < ApplicationController
before_action :authenticate_user!
def show
@questions = Question.where(author_identifier: params[:author_identifier])
end
end

View file

@ -1,48 +0,0 @@
# frozen_string_literal: true
class Moderation::ReportsController < ApplicationController
before_action :authenticate_user!
before_action :set_filter_enabled
before_action :set_type_options
before_action :set_last_reports_visit
def index
filter = ReportFilter.new(filter_params)
@reports = filter.cursored_results(last_id: params[:last_id])
@reports_last_id = @reports.map(&:id).min
@more_data_available = filter.cursored_results(last_id: @reports_last_id, size: 1).count.positive?
respond_to do |format|
format.html
format.turbo_stream { render "index", layout: false, status: :see_other }
end
end
private
def filter_params
params.slice(*ReportFilter::KEYS).permit(*ReportFilter::KEYS)
end
def set_filter_enabled
@filter_enabled = params.slice(*ReportFilter::KEYS)
.reject! { |_, value| value.empty? || value.nil? }
.values
.any?
end
def set_type_options
@type_options = [
[t("voc.all"), ""],
[t("activerecord.models.answer.one"), :answer],
[t("activerecord.models.comment.one"), :comment],
[t("activerecord.models.question.one"), :question],
[t("activerecord.models.user.one"), :user]
]
end
def set_last_reports_visit
current_user.last_reports_visit = DateTime.now
current_user.save
end
end

View file

@ -1,8 +0,0 @@
class ModerationController < ApplicationController
before_action :authenticate_user!
def toggle_unmask
session[:moderation_view] = !session[:moderation_view]
redirect_back fallback_location: root_path
end
end

View file

@ -1,71 +0,0 @@
# frozen_string_literal: true
class NotificationsController < ApplicationController
before_action :authenticate_user!
TYPE_MAPPINGS = {
"answer" => Notification::QuestionAnswered.name,
"comment" => Notification::Commented.name,
"commentsmile" => Notification::CommentSmiled.name,
"relationship" => Notification::StartedFollowing.name,
"smile" => Notification::Smiled.name
}.freeze
def index
@type = TYPE_MAPPINGS[params[:type]] || params[:type]
@notifications = cursored_notifications_for(type: @type, last_id: params[:last_id])
paginate_notifications
@counters = count_unread_by_type
mark_notifications_as_read
respond_to do |format|
format.html
format.turbo_stream { render layout: false, status: :see_other }
end
end
def read
current_user.notifications.where(new: true).update_all(new: false) # rubocop:disable Rails/SkipsModelValidations
current_user.touch(:notifications_updated_at)
respond_to do |format|
format.turbo_stream do
render "navigation/notifications", locals: { notifications: [], notification_count: nil }
end
end
end
private
def paginate_notifications
@notifications_last_id = @notifications.map(&:id).min
@more_data_available = !cursored_notifications_for(type: @type, last_id: @notifications_last_id, size: 1).count.zero?
end
def count_unread_by_type
Notification.where(recipient: current_user, new: true)
.group(:target_type)
.count(:target_type)
end
# rubocop:disable Rails/SkipsModelValidations
def mark_notifications_as_read
# using .dup to not modify @notifications -- useful in tests
updated = @notifications&.dup&.update_all(new: false)
current_user.touch(:notifications_updated_at) if updated.positive?
end
# rubocop:enable Rails/SkipsModelValidations
def cursored_notifications_for(type:, last_id:, size: nil)
cursor_params = { last_id: last_id, size: size }.compact
case type
when "all"
Notification.cursored_for(current_user, **cursor_params)
when "new"
Notification.cursored_for(current_user, new: true, **cursor_params)
else
Notification.cursored_for_type(current_user, type, **cursor_params)
end
end
end

View file

@ -1,18 +0,0 @@
# frozen_string_literal: true
class QuestionController < ApplicationController
include PaginatesAnswers
def show
@question = Question.find(params[:id])
@answers = @question.cursored_answers(last_id: params[:last_id], current_user:)
answer_ids = @answers.map(&:id)
@answers_last_id = answer_ids.min
@more_data_available = !@question.cursored_answers(last_id: @answers_last_id, size: 1, current_user:).select("answers.id").count.zero?
respond_to do |format|
format.html
format.turbo_stream { render layout: false, status: :see_other }
end
end
end

View file

@ -1,75 +0,0 @@
# frozen_string_literal: true
class ReactionsController < ApplicationController
include TurboStreamable
before_action :authenticate_user!, only: %w[create destroy]
turbo_stream_actions :create, :destroy
def index
answer = Answer.includes([smiles: { user: :profile }]).find(params[:id])
render "index", locals: { a: answer }
end
def create
params.require :id
target = target_class.find(params[:id])
UseCase::Reaction::Create.call(
source_user_id: current_user.id,
target:,
)
target.reload
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("reaction-#{params[:type]}-#{params[:id]}", partial: "reactions/destroy", locals: { type: params[:type], target: }),
render_toast(t(".#{params[:type].downcase}.success"))
]
end
format.html { redirect_back(fallback_location: root_path) }
end
end
def destroy
params.require :id
target = target_class.find(params[:id])
UseCase::Reaction::Destroy.call(
source_user_id: current_user.id,
target:,
)
target.reload
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("reaction-#{params[:type]}-#{params[:id]}", partial: "reactions/create", locals: { type: params[:type], target: }),
render_toast(t(".#{params[:type].downcase}.success"))
]
end
format.html { redirect_back(fallback_location: root_path) }
end
end
private
ALLOWED_TYPES = %w[Answer Comment].freeze
private_constant :ALLOWED_TYPES
def target_class
params.require :type
raise NameError unless ALLOWED_TYPES.include?(params[:type])
params[:type].constantize
end
end

View file

@ -1,49 +0,0 @@
# frozen_string_literal: true
class RelationshipsController < ApplicationController
include TurboStreamable
before_action :authenticate_user!
turbo_stream_actions :create, :destroy
def create
params.require :screen_name
UseCase::Relationship::Create.call(
source_user: current_user,
target_user: ::User.find_by!(screen_name: params[:screen_name]),
type: params[:type],
)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("#{params[:type]}-#{params[:screen_name]}", partial: "relationships/destroy", locals: { type: params[:type], screen_name: params[:screen_name] }),
render_toast(t(".#{params[:type]}.success"))
]
end
format.html { redirect_back(fallback_location: user_path(username: params[:screen_name])) }
end
end
def destroy
UseCase::Relationship::Destroy.call(
source_user: current_user,
target_user: ::User.find_by!(screen_name: params[:screen_name]),
type: params[:type],
)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("#{params[:type]}-#{params[:screen_name]}", partial: "relationships/create", locals: { type: params[:type], screen_name: params[:screen_name] }),
render_toast(t(".#{params[:type]}.success"))
]
end
format.html { redirect_back(fallback_location: user_path(username: params[:screen_name])) }
end
end
end

View file

@ -1,10 +0,0 @@
# frozen_string_literal: true
class Settings::BlocksController < ApplicationController
before_action :authenticate_user!
def index
@blocks = Relationships::Block.where(source: current_user)
@anonymous_blocks = AnonymousBlock.where(user: current_user)
end
end

View file

@ -1,7 +0,0 @@
# frozen_string_literal: true
class Settings::DataController < ApplicationController
before_action :authenticate_user!
def index; end
end

View file

@ -2,31 +2,12 @@
class Settings::ExportController < ApplicationController
before_action :authenticate_user!
before_action :mark_notifications_as_read, only: %i[index]
def index
flash[:info] = t(".info") if current_user.export_processing
end
def create
if current_user.can_export?
ExportWorker.perform_async(current_user.id)
flash[:success] = t(".success")
else
flash[:error] = t(".error")
end
redirect_to settings_export_path
end
private
# rubocop:disable Rails/SkipsModelValidations
def mark_notifications_as_read
updated = Notification::DataExported
.where(recipient: current_user, new: true)
.update_all(new: false)
current_user.touch(:notifications_updated_at) if updated.positive?
end
# rubocop:enable Rails/SkipsModelValidations
end

View file

@ -1,49 +0,0 @@
# frozen_string_literal: true
class Settings::MutesController < ApplicationController
include TurboStreamable
before_action :authenticate_user!
turbo_stream_actions :create, :destroy
def index
@users = current_user.muted_users
@rules = MuteRule.where(user: current_user)
end
def create
result = UseCase::MuteRule::Create.call(user: current_user, phrase: params[:muted_phrase])
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("form", partial: "settings/mutes/form"),
turbo_stream.append("rules", partial: "settings/mutes/rule", locals: { rule: result[:resource] }),
render_toast(t(".success"))
]
end
format.html { redirect_to settings_muted_path }
end
end
def destroy
rule = MuteRule.find(params[:id])
authorize rule
UseCase::MuteRule::Destroy.call(rule:)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.remove("rule_#{params[:id]}"),
render_toast(t(".success"))
]
end
format.html { redirect_to settings_muted_path }
end
end
end

View file

@ -1,24 +0,0 @@
# frozen_string_literal: true
class Settings::PrivacyController < ApplicationController
before_action :authenticate_user!
def edit; end
def update
user_attributes = params.require(:user).permit(:privacy_lock_inbox,
:privacy_allow_anonymous_questions,
:privacy_allow_public_timeline,
:privacy_allow_stranger_answers,
:privacy_show_in_search,
:privacy_require_user,
:privacy_noindex,
:privacy_hide_social_graph)
if current_user.update(user_attributes)
flash[:success] = t(".success")
else
flash[:error] = t(".error")
end
render :edit
end
end

View file

@ -1,19 +0,0 @@
# frozen_string_literal: true
class Settings::ProfileController < ApplicationController
before_action :authenticate_user!
def edit; end
def update
profile_attributes = params.require(:profile).permit(:display_name, :motivation_header, :website, :location, :description, :anon_display_name, :allow_long_questions)
if current_user.profile.update(profile_attributes)
flash[:success] = t(".success")
else
flash[:error] = t(".error")
end
render :edit
end
end

View file

@ -1,21 +0,0 @@
# frozen_string_literal: true
class Settings::ProfilePictureController < ApplicationController
before_action :authenticate_user!
def update
user_attributes = params.require(:user).permit(:show_foreign_themes, :profile_picture_x, :profile_picture_y, :profile_picture_w, :profile_picture_h,
:profile_header_x, :profile_header_y, :profile_header_w, :profile_header_h, :profile_picture, :profile_header)
if current_user.update(user_attributes)
text = t(".success")
text += t(".notice.profile_picture") if user_attributes[:profile_picture]
text += t(".notice.profile_header") if user_attributes[:profile_header]
flash[:success] = text
else
# CarrierWave resets the image to the default upon an error
current_user.reload
end
render "settings/profile/edit"
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
class Settings::PushNotificationsController < ApplicationController
before_action :authenticate_user!
def index
@subscriptions = current_user.web_push_subscriptions.active
end
end

View file

@ -1,20 +0,0 @@
# frozen_string_literal: true
class Settings::SharingController < ApplicationController
before_action :authenticate_user!
def edit; end
def update
user_attributes = params.require(:user).permit(:sharing_enabled,
:sharing_autoclose,
:sharing_custom_url)
if current_user.update(user_attributes)
flash.now[:success] = t(".success")
else
flash.now[:error] = t(".error")
end
render :edit
end
end

View file

@ -1,51 +0,0 @@
# frozen_string_literal: true
class Settings::ThemeController < ApplicationController
include ThemeHelper
before_action :authenticate_user!
def edit; end
def update
if current_user.theme.nil?
current_user.theme = Theme.new theme_attributes
current_user.theme.user_id = current_user.id
if current_user.theme.save
flash[:success] = t(".success")
else
flash[:error] = t(".error", errors: current_user.theme.errors.messages.flatten.join(" "))
end
elsif current_user.theme.update(theme_attributes)
flash[:success] = t(".success")
else
flash[:error] = t(".error", errors: current_user.theme.errors.messages.flatten.join(" "))
end
render :edit
end
def destroy
current_user.theme.destroy!
redirect_to edit_settings_theme_path
end
private
def theme_attributes
params.require(:theme).permit(%i[
primary_color primary_text
danger_color danger_text
success_color success_text
warning_color warning_text
info_color info_text
dark_color dark_text
light_color light_text
raised_background raised_accent
raised_text raised_accent_text
background_color body_text
muted_text input_color
input_text input_placeholder
])
end
end

View file

@ -1,47 +0,0 @@
# frozen_string_literal: true
class Settings::TwoFactorAuthentication::OtpAuthenticationController < ApplicationController
before_action :authenticate_user!
def index
if current_user.otp_module_disabled?
current_user.otp_secret_key = User.otp_random_secret(25)
current_user.save
qr_code = RQRCode::QRCode.new(current_user.provisioning_uri("Retrospring:#{current_user.screen_name}", issuer: "Retrospring"))
@qr_svg = qr_code.as_svg({ offset: 4, module_size: 4, color: "000;fill:var(--primary)" }).html_safe
else
@recovery_code_count = current_user.totp_recovery_codes.count
end
end
def update
req_params = params.require(:user).permit(:otp_validation)
current_user.otp_module = :enabled
if current_user.authenticate_otp(req_params[:otp_validation], drift: APP_CONFIG.fetch(:otp_drift_period, 30).to_i)
@recovery_keys = TotpRecoveryCode.generate_for(current_user)
current_user.save!
render "settings/two_factor_authentication/otp_authentication/recovery_keys"
else
flash[:error] = t(".error")
redirect_to settings_two_factor_authentication_otp_authentication_path
end
end
def destroy
current_user.otp_module = :disabled
current_user.save!
current_user.totp_recovery_codes.delete_all
flash[:success] = t(".success")
redirect_to settings_two_factor_authentication_otp_authentication_path, status: :see_other
end
def reset
current_user.totp_recovery_codes.delete_all
@recovery_keys = TotpRecoveryCode.generate_for(current_user)
render "settings/two_factor_authentication/otp_authentication/recovery_keys", status: :see_other
end
end

View file

@ -1,41 +0,0 @@
# frozen_string_literal: true
class SubscriptionsController < ApplicationController
include TurboStreamable
before_action :authenticate_user!
turbo_stream_actions :create, :destroy
def create
answer = Answer.find(params[:answer])
result = Subscription.subscribe(current_user, answer)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("subscription-#{answer.id}", partial: "subscriptions/destroy", locals: { answer: }),
render_toast(t(result.present? ? ".success" : ".error"), result.present?)
]
end
format.html { redirect_to answer_path(username: answer.user.screen_name, id: answer.id) }
end
end
def destroy
answer = Answer.find(params[:answer])
result = Subscription.unsubscribe(current_user, answer)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace("subscription-#{answer.id}", partial: "subscriptions/create", locals: { answer: }),
render_toast(t(result.present? ? ".success" : ".error"), result.present?)
]
end
format.html { redirect_to answer_path(username: answer.user.screen_name, id: answer.id) }
end
end
end

View file

@ -1,44 +0,0 @@
# frozen_string_literal: true
class TimelineController < ApplicationController
before_action :authenticate_user!
before_action :set_list, only: %i[list]
before_action :set_lists
def index
paginate_timeline { |args| current_user.cursored_timeline(**args) }
end
def list
@title = list_title(@list)
paginate_timeline { |args| @list.cursored_timeline(**args, current_user:) }
end
def public
@title = generate_title(t(".title"))
paginate_timeline { |args| Answer.cursored_public_timeline(**args, current_user:) }
end
private
def set_list
@list = current_user.lists.find_by!(name: params[:list_name]) if params[:list_name].present?
end
def set_lists
@lists = current_user.lists
@lists = @lists.where.not(id: @list.id) if @list.present?
end
def paginate_timeline
@timeline = yield(last_id: params[:last_id])
timeline_ids = @timeline.select("answers.id").map(&:id)
@timeline_last_id = timeline_ids.min
@more_data_available = !yield(last_id: @timeline_last_id, size: 1).select("answers.id").count.zero?
respond_to do |format|
format.html { render "timeline/timeline" }
format.turbo_stream { render "timeline/timeline", layout: false, status: :see_other }
end
end
end

View file

@ -3,21 +3,8 @@
class User::RegistrationsController < Devise::RegistrationsController
before_action :redirect_if_registrations_disabled!, only: %w[create new] # rubocop:disable Rails/LexicallyScopedActionFilter
def create
if captcha_valid?
super
else
respond_with_navigational(resource) { redirect_to new_user_registration_path }
end
end
def destroy
if resource.export_processing
flash[:error] = t(".export_pending")
redirect_to edit_user_registration_path
return
end
# TODO: destroy export async
Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
resource.destroy
set_flash_message :notice, :destroyed if is_flashing_format?
@ -35,6 +22,6 @@ class User::RegistrationsController < Devise::RegistrationsController
end
def redirect_if_registrations_disabled!
redirect_to root_path unless Retrospring::Config.registrations_enabled?
redirect_to root_path
end
end

View file

@ -1,114 +0,0 @@
# frozen_string_literal: true
class UserController < ApplicationController
include PaginatesAnswers
before_action :set_user
before_action :hidden_social_graph_redirect, only: %i[followers followings]
after_action :mark_notification_as_read, only: %i[show]
def show
@pinned_answers = @user.answers.for_user(current_user).pinned.includes([{ user: :profile }, :question]).order(pinned_at: :desc).limit(10).load_async
paginate_answers { |args| @user.cursored_answers(current_user_id: current_user, **args) }
respond_to do |format|
format.html
format.turbo_stream { render layout: false }
end
end
def followers
paginate_relationships(:cursored_follower_relationships)
@users = @relationships.map(&:source)
own_relationships = find_own_relationships
locals = {
type: :follower,
own_followings: own_relationships[Relationships::Follow],
own_blocks: own_relationships[Relationships::Block],
own_mutes: own_relationships[Relationships::Mute]
}
respond_to do |format|
format.html { render "show_follow", locals: }
format.turbo_stream { render "show_follow", locals: }
end
end
def followings
paginate_relationships(:cursored_following_relationships)
@users = @relationships.map(&:target)
own_relationships = find_own_relationships
locals = {
type: :friend,
own_followings: own_relationships[Relationships::Follow],
own_blocks: own_relationships[Relationships::Block],
own_mutes: own_relationships[Relationships::Mute]
}
respond_to do |format|
format.html { render "show_follow", locals: }
format.turbo_stream { render "show_follow", locals: }
end
end
def questions
@questions = @user.cursored_questions(author_is_anonymous: false, direct: direct_param, last_id: params[:last_id])
@questions_last_id = @questions.map(&:id).min
@more_data_available = !@user.cursored_questions(author_is_anonymous: false, direct: direct_param, last_id: @questions_last_id, size: 1).count.zero?
respond_to do |format|
format.html
format.turbo_stream
end
end
private
def mark_notification_as_read
return unless user_signed_in?
Notification
.where(
target_type: "Relationship",
target_id: @user.active_follow_relationships.where(target_id: current_user.id).pluck(:id),
recipient_id: current_user.id,
new: true
).update(new: false)
end
def set_user
@user = User.where("LOWER(screen_name) = ?", params[:username].downcase).includes(:profile).first!
end
def find_own_relationships
return {} unless user_signed_in?
Relationship.where(source: current_user, target_id: @users.map(&:id))
&.select(:target_id, :type)
&.group_by(&:type)
end
def paginate_relationships(method)
@relationships = @user.public_send(method, last_id: params[:last_id])
@relationships_last_id = @relationships.map(&:id).min
@more_data_available = !@user.public_send(method, last_id: @relationships_last_id, size: 1).count.zero?
end
def hidden_social_graph_redirect
return if belongs_to_current_user? || !@user.privacy_hide_social_graph
redirect_to user_path(@user)
end
def direct_param
# return `nil` instead of `false` so we retrieve all questions for the user, direct or not.
# `cursored_questions` will then remove the `direct` field from the WHERE query. otherwise the query filters
# for `WHERE direct = false` ...
return if belongs_to_current_user? || moderation_view?
# page is not being viewed by the current user, and we're not in the moderation view -> only show public questions
false
end
def belongs_to_current_user? = @user == current_user
end

View file

@ -1,4 +0,0 @@
# frozen_string_literal: true
module AjaxHelper
end

View file

@ -1,14 +1,5 @@
# frozen_string_literal: true
module ApplicationHelper
include ApplicationHelper::GraphMethods
include ApplicationHelper::TitleMethods
def privileged?(user)
!current_user.nil? && ((current_user == user) || current_user.mod?)
end
def rails_admin_path_for_resource(resource)
[rails_admin_path, resource.model_name.param_key, resource.id].join("/")
end
end

View file

@ -1,61 +0,0 @@
# frozen_string_literal: true
module ApplicationHelper::GraphMethods
# Creates <meta> tags for OpenGraph properties from a hash
# @param values [Hash]
def opengraph_meta_tags(values)
safe_join(values.map { |name, content| tag.meta(property: name, content:) }, "\n")
end
# Creates <meta> tags from a hash
# @param values [Hash]
def meta_tags(values)
safe_join(values.map { |name, content| tag.meta(name:, content:) }, "\n")
end
# @param user [User]
def user_opengraph(user)
opengraph_meta_tags({
"og:title": user.profile.safe_name,
"og:type": "profile",
"og:image": full_profile_picture_url(user),
"og:url": user_url(user),
"og:description": user.profile.description,
"og:site_name": APP_CONFIG["site_name"],
"profile:username": user.screen_name,
})
end
# @param user [User]
def user_twitter_card(user)
meta_tags({
"twitter:card": "summary",
"twitter:site": "@retrospring",
"twitter:title": user.profile.motivation_header.presence || "Ask me anything!",
"twitter:description": "Ask #{user.profile.safe_name} anything on Retrospring",
"twitter:image": full_profile_picture_url(user),
})
end
# @param answer [Answer]
def answer_opengraph(answer)
opengraph_meta_tags({
"og:title": "#{answer.user.profile.safe_name} answered: #{answer.question.content}",
"og:type": "article",
"og:image": full_profile_picture_url(answer.user),
"og:url": answer_url(answer.user.screen_name, answer.id),
"og:description": answer.content,
"og:site_name": APP_CONFIG["site_name"],
})
end
def full_profile_picture_url(user)
# @type [String]
profile_picture_url = user.profile_picture.url(:large)
if profile_picture_url.start_with? "/"
"#{root_url}#{profile_picture_url[1..]}"
else
profile_picture_url
end
end
end

View file

@ -2,7 +2,6 @@
module ApplicationHelper::TitleMethods
include MarkdownHelper
include UserHelper
def generate_title(name, junction = nil, content = nil, possessive = false)
if possessive
@ -24,43 +23,4 @@ module ApplicationHelper::TitleMethods
list.join " "
end
def question_title(question)
context_user = question.answers&.first&.user if question.direct
name = user_screen_name question.user,
context_user:,
author_identifier: question.author_is_anonymous ? question.author_identifier : nil,
url: false
generate_title name, "asked", question.content
end
def answer_title(answer)
name = user_screen_name answer.user, url: false
generate_title name, "answered", answer.question.content
end
def user_title(user, junction = nil)
name = user_screen_name user, url: false
generate_title name, junction, nil, !junction.nil?
end
def questions_title(user)
user_title user, "questions"
end
def answers_title(user)
user_title user, "answers"
end
def smiles_title(user)
user_title user, "smiles"
end
def comments_title(user)
user_title user, "comments"
end
def list_title(list)
generate_title list.display_name
end
end

View file

@ -1,16 +0,0 @@
# frozen_string_literal: true
module FeedbackHelper
def canny_token
return if current_user.nil?
user_data = {
avatarURL: current_user.profile_picture.url(:large),
name: current_user.screen_name,
id: current_user.id,
email: current_user.email,
}
JWT.encode(user_data, APP_CONFIG.dig("canny", "sso"))
end
end

View file

@ -1,20 +0,0 @@
# frozen_string_literal: true
module ModerationHelper
# @param report [Report]
def content_url(report)
target = report.target
case report.type
when "Reports::Answer"
answer_path target.user.screen_name, target.id
when "Reports::Comment"
answer_path target.answer.user.screen_name, target.answer.id
when "Reports::Question"
question_path "user", target.id
when "Reports::User"
user_path target
else
"#"
end
end
end

View file

@ -1,17 +0,0 @@
# frozen_string_literal: true
module SocialHelper
include SocialHelper::BlueskyMethods
include SocialHelper::TwitterMethods
include SocialHelper::TumblrMethods
include SocialHelper::TelegramMethods
def answer_share_url(answer)
answer_url(
id: answer.id,
username: answer.user.screen_name,
host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG["https"] ? :https : :http),
)
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
require "cgi"
module SocialHelper::BlueskyMethods
def bluesky_share_url(answer)
"https://bsky.app/intent/compose?text=#{CGI.escape(prepare_tweet(answer))}"
end
end

View file

@ -1,23 +0,0 @@
# frozen_string_literal: true
require "cgi"
module SocialHelper::TelegramMethods
include MarkdownHelper
def telegram_text(answer)
# using twitter_markdown here as it removes all formatting
"#{twitter_markdown answer.question.content}\n———\n#{twitter_markdown answer.content}"
end
def telegram_share_url(answer)
url = answer_url(
id: answer.id,
username: answer.user.screen_name,
host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG["https"] ? :https : :http),
)
%(https://t.me/share/url?url=#{CGI.escape(url)}&text=#{CGI.escape(telegram_text(answer))})
end
end

View file

@ -1,37 +0,0 @@
# frozen_string_literal: true
require "cgi"
module SocialHelper::TumblrMethods
def tumblr_title(answer)
asker = if answer.question.author_is_anonymous?
answer.user.profile.anon_display_name.presence || APP_CONFIG["anonymous_name"]
else
answer.question.user.profile.safe_name
end
"#{asker} asked: #{answer.question.content}"
end
def tumblr_body(answer)
answer_url = answer_url(
id: answer.id,
username: answer.user.screen_name,
host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG["https"] ? :https : :http),
)
"#{answer.content}\n\n[Smile or comment on the answer here](#{answer_url})"
end
def tumblr_share_url(answer)
answer_url = answer_url(
id: answer.id,
username: answer.user.screen_name,
host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG["https"] ? :https : :http),
)
"https://www.tumblr.com/widgets/share/tool?shareSource=legacy&posttype=text&title=#{CGI.escape(tumblr_title(answer))}&url=#{CGI.escape(answer_url)}&caption=&content=#{CGI.escape(tumblr_body(answer))}"
end
end

View file

@ -1,50 +0,0 @@
# frozen_string_literal: true
require "cgi"
module SocialHelper::TwitterMethods
include MarkdownHelper
def prepare_tweet(answer, post_tag = nil, omit_url = false)
question_content = twitter_markdown answer.question.content.gsub(/@(\w+)/, '\1')
original_question_length = question_content.length
answer_content = twitter_markdown answer.content
original_answer_length = answer_content.length
unless omit_url
answer_url = answer_url(
id: answer.id,
username: answer.user.screen_name,
host: APP_CONFIG["hostname"],
protocol: (APP_CONFIG["https"] ? :https : :http),
)
end
parsed_tweet = { valid: false }
tweet_text = ""
until parsed_tweet[:valid]
shortened_question = "#{question_content[0..122]}#{'…' if original_question_length > [123, question_content.length].min}"
shortened_answer = "#{answer_content[0..123]}#{'…' if original_answer_length > [124, answer_content.length].min}"
components = [
shortened_question,
"",
shortened_answer,
post_tag,
answer_url
]
tweet_text = components.compact.join(" ")
parsed_tweet = Twitter::TwitterText::Validation.parse_tweet(tweet_text)
question_content = question_content[0..-2]
answer_content = answer_content[0..-2]
end
tweet_text
end
def twitter_share_url(answer)
"https://twitter.com/intent/tweet?text=#{CGI.escape(prepare_tweet(answer))}"
end
end

View file

@ -1,51 +1,7 @@
# frozen_string_literal: true
module ThemeHelper
ATTRIBUTE_MAP = {
"primary_color" => %w[primary primary-rgb],
"primary_text" => "primary-text",
"danger_color" => "danger",
"danger_text" => "danger-text",
"warning_color" => "warning",
"warning_text" => "warning-text",
"info_color" => "info",
"info_text" => "info-text",
"success_color" => "success",
"success_text" => "success-text",
"dark_color" => "dark",
"dark_text" => "dark-text",
"light_color" => "light",
"light_text" => "light-text",
"raised_background" => %w[raised-bg raised-bg-rgb],
"raised_text" => "raised-text",
"raised_accent" => %w[raised-accent raised-accent-rgb],
"raised_accent_text" => "raised-accent-text",
"background_color" => "background",
"body_text" => "body-text",
"input_color" => "input-bg",
"input_text" => "input-text",
"input_placeholder" => "input-placeholder",
"muted_text" => "muted-text",
}.freeze
def render_theme
theme = get_active_theme
return unless theme
body = ":root {\n"
theme.attributes.each do |k, v|
next unless ATTRIBUTE_MAP.key?(k)
Array(ATTRIBUTE_MAP[k]).each do |var|
body += "\t--#{var}: #{get_color_for_key(var, v)};\n"
end
end
body += "\t--turbolinks-progress-color: ##{lighten(theme.primary_color)}\n}"
content_tag(:style, body)
end
def render_theme = nil
def get_color_for_key(key, color)
hex = get_hex_color_from_theme_value(color)
@ -57,49 +13,9 @@ module ThemeHelper
end
end
def theme_color
theme = get_active_theme
if theme
theme.theme_color
else
"#5e35b1"
end
end
def theme_color = "#5e35b1"
def mobile_theme_color
theme = get_active_theme
if theme
theme.mobile_theme_color
else
"#f0edf4"
end
end
def get_active_theme
if @user&.theme
if user_signed_in?
if current_user&.show_foreign_themes?
@user.theme
else
current_user&.theme
end
else
@user.theme
end
elsif @answer&.user&.theme
if user_signed_in?
if current_user&.show_foreign_themes?
@answer.user.theme
else
current_user&.theme
end
else
@answer.user.theme
end
elsif current_user&.theme
current_user.theme
end
end
def mobile_theme_color = "#f0edf4"
def get_hex_color_from_theme_value(value)
"0000000#{value.to_s(16)}"[-6, 6]

View file

@ -1,47 +0,0 @@
# frozen_string_literal: true
module UserHelper
# Decides what user name to show.
# @param context_user [User] the user whose the profile preferences should be applied
# @param author_identifier [nil, String] the author identifier of the question (questions only)
# @return [String] The user name
def user_screen_name(user, context_user: nil, author_identifier: nil, url: true, link_only: false)
return unmask(user, context_user, author_identifier) if should_unmask?(author_identifier)
return anonymous_name(context_user) if anonymous?(user, author_identifier.present?)
if url
return user_path(user) if link_only
return profile_link(user, target: "_top")
end
user.profile.safe_name.strip
end
def moderation_view?
!!(current_user&.mod? && session[:moderation_view] == true)
end
private
def profile_link(user, target: nil)
link_to(user.profile.safe_name, user_path(user), class: ("user--banned" if user.banned?).to_s, target:)
end
def should_unmask?(author_identifier)
moderation_view? && author_identifier.present?
end
def unmask(user, context_user, author_identifier)
return profile_link(user) if user.present?
content_tag(:abbr, anonymous_name(context_user), title: author_identifier)
end
def anonymous_name(context_user)
sanitize(context_user&.profile&.anon_display_name.presence || APP_CONFIG["anonymous_name"], tags: [])
end
def anonymous?(user, author_identifier)
user.nil? || author_identifier.present?
end
end

View file

@ -1,29 +1,11 @@
import start from 'retrospring/common';
import initAnswerbox from 'retrospring/features/answerbox/index';
import initInbox from 'retrospring/features/inbox/index';
import initUser from 'retrospring/features/user';
import initSettings from 'retrospring/features/settings/index';
import initLists from 'retrospring/features/lists';
import initQuestionbox from 'retrospring/features/questionbox';
import initQuestion from 'retrospring/features/question';
import initModeration from 'retrospring/features/moderation';
import initMemes from 'retrospring/features/memes';
import initFront from 'retrospring/features/front';
import initWebpush from 'retrospring/features/webpush';
import initWebpushSettingsButtons from 'retrospring/features/webpush/settingsButtons';
start();
document.addEventListener('DOMContentLoaded', initAnswerbox);
document.addEventListener('DOMContentLoaded', initInbox);
document.addEventListener('DOMContentLoaded', initUser);
document.addEventListener('turbo:load', initSettings);
document.addEventListener('DOMContentLoaded', initLists);
document.addEventListener('turbo:load', initQuestionbox);
document.addEventListener('DOMContentLoaded', initQuestion);
document.addEventListener('DOMContentLoaded', initModeration);
document.addEventListener('DOMContentLoaded', initMemes);
document.addEventListener('turbo:load', initFront);
document.addEventListener('DOMContentLoaded', initWebpush);
document.addEventListener('turbo:load', initWebpushSettingsButtons);

View file

@ -1,13 +1,11 @@
import '@hotwired/turbo-rails';
import initializeBootstrap from './initializers/bootstrap';
import initializeHotkey from './initializers/hotkey';
import initializeServiceWorker from './initializers/serviceWorker';
import initializeStimulus from './initializers/stimulus';
export default function start(): void {
try {
initializeBootstrap();
initializeHotkey();
initializeServiceWorker();
initializeStimulus();
} catch (e) {

View file

@ -1,23 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { Alert } from 'bootstrap';
export default class extends Controller {
static values = {
id: Number
};
declare readonly idValue: number;
connect(): void {
if (!window.localStorage.getItem(`announcement${this.idValue}`)) {
this.element.classList.remove('d-none');
}
}
close(): void {
window.localStorage.setItem(`announcement${this.idValue}`, 'true');
const alert = Alert.getOrCreateInstance(this.element);
alert.close();
}
}

View file

@ -1,7 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect(): void {
(this.element as HTMLInputElement).focus();
}
}

View file

@ -1,36 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['input', 'counter', 'action'];
declare readonly inputTarget: HTMLInputElement;
declare readonly counterTarget: HTMLElement;
declare readonly actionTarget: HTMLInputElement;
static values = {
max: Number
};
declare readonly maxValue: number;
connect(): void {
this.inputTarget.addEventListener('input', this.update.bind(this));
}
update(): void {
this.counterTarget.innerHTML = String(`${this.maxValue - this.inputTarget.value.length}`);
if (this.inputTarget.value.length > this.maxValue) {
if (!this.inputTarget.classList.contains('is-invalid') && !this.actionTarget.disabled) {
this.inputTarget.classList.add('is-invalid');
this.actionTarget.disabled = true;
}
}
else {
if (this.inputTarget.classList.contains('is-invalid') && this.actionTarget.disabled) {
this.inputTarget.classList.remove('is-invalid');
this.actionTarget.disabled = false;
}
}
}
}

View file

@ -1,30 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['input', 'warning'];
declare readonly inputTarget: HTMLInputElement;
declare readonly warningTarget: HTMLElement;
static values = {
warn: Number
};
declare readonly warnValue: number;
connect(): void {
this.inputTarget.addEventListener('input', this.update.bind(this));
}
update(): void {
if (this.inputTarget.value.length > this.warnValue) {
if (this.warningTarget.classList.contains('d-none')) {
this.warningTarget.classList.remove('d-none');
}
} else {
if (!this.warningTarget.classList.contains('d-none')) {
this.warningTarget.classList.add('d-none');
}
}
}
}

View file

@ -1,23 +0,0 @@
import { Controller } from "@hotwired/stimulus";
import I18n from 'retrospring/i18n';
import { showErrorNotification, showNotification } from "retrospring/utilities/notifications";
export default class extends Controller {
static values = {
copy: String
};
declare readonly copyValue: string;
async copy(){
try {
await navigator.clipboard.writeText(this.copyValue);
showNotification(I18n.translate("frontend.clipboard_copy.success"));
this.element.dispatchEvent(new CustomEvent('retrospring:copied'));
} catch (error) {
console.log(error);
showErrorNotification(I18n.translate("frontend.clipboard_copy.error"));
}
}
}

View file

@ -1,16 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['action', 'content'];
declare readonly contentTarget: HTMLElement;
declare readonly actionTarget: HTMLElement;
connect(): void {
this.actionTarget.addEventListener('click', this.update.bind(this));
}
update(): void {
this.contentTarget.classList.toggle('collapsed');
}
}

View file

@ -1,54 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import Croppr from 'croppr';
export default class extends Controller {
static targets = ['input', 'controls', 'cropper', 'x', 'y', 'w', 'h'];
declare readonly inputTarget: HTMLInputElement;
declare readonly controlsTarget: HTMLElement;
declare readonly cropperTarget: HTMLImageElement;
declare readonly xTarget: HTMLInputElement;
declare readonly yTarget: HTMLInputElement;
declare readonly wTarget: HTMLInputElement;
declare readonly hTarget: HTMLInputElement;
static values = {
aspectRatio: String
};
declare readonly aspectRatioValue: string;
readImage(file: File, callback: (string) => void): void {
callback((window.URL || window.webkitURL).createObjectURL(file));
}
updateValues(data: Record<string, string>): void {
this.xTarget.value = data.x;
this.yTarget.value = data.y;
this.wTarget.value = data.width;
this.hTarget.value = data.height;
}
change(): void {
this.controlsTarget.classList.toggle('d-none');
if (this.inputTarget.files && this.inputTarget.files[0]) {
this.readImage(this.inputTarget.files[0], (src) => {
this.cropperTarget.addEventListener('load', () => {
new Croppr(this.cropperTarget, {
aspectRatio: parseFloat(this.aspectRatioValue),
startSize: [100, 100, '%'],
onInitialize: (instance) => { this.updateValues(instance.getValue()) },
onCropStart: this.updateValues.bind(this),
onCropMove: this.updateValues.bind(this),
onCropEnd: this.updateValues.bind(this)
});
}, {
once: true
});
this.cropperTarget.src = src;
});
}
}
}

View file

@ -1,18 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { Popover } from 'bootstrap';
export default class extends Controller {
connect(): void {
const formatOptionsElement = document.getElementById('formatting-options');
this.element.addEventListener('click', e => e.preventDefault());
new Popover(this.element, {
html: true,
content: formatOptionsElement.innerHTML,
placement: 'bottom',
trigger: 'focus',
customClass: 'rs-popover'
})
}
}

View file

@ -1,12 +0,0 @@
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
export default class extends Controller<HTMLElement> {
connect(): void {
install(this.element);
}
disconnect(): void {
uninstall(this.element);
}
}

View file

@ -1,66 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['twitter', 'bluesky', 'tumblr', 'telegram', 'other', 'custom', 'clipboard'];
declare readonly twitterTarget: HTMLAnchorElement;
declare readonly blueskyTarget: HTMLAnchorElement;
declare readonly tumblrTarget: HTMLAnchorElement;
declare readonly telegramTarget: HTMLAnchorElement;
declare readonly customTarget: HTMLAnchorElement;
declare readonly otherTarget: HTMLButtonElement;
declare readonly clipboardTarget: HTMLButtonElement;
declare readonly hasCustomTarget: boolean;
static values = {
config: Object,
autoClose: Boolean
};
declare readonly configValue: Record<string, string>;
declare readonly autoCloseValue: boolean;
connect(): void {
if (this.autoCloseValue) {
this.twitterTarget.addEventListener('click', () => this.close());
this.blueskyTarget.addEventListener('click', () => this.close());
this.tumblrTarget.addEventListener('click', () => this.close());
this.telegramTarget.addEventListener('click', () => this.close());
this.otherTarget.addEventListener('click', () => this.closeAfterShare());
this.clipboardTarget.addEventListener('click', () => this.closeAfterCopyToClipboard());
if (this.hasCustomTarget) {
this.customTarget.addEventListener('click', () => this.close());
}
}
}
configValueChanged(value: Record<string, string>): void {
if (Object.keys(value).length === 0) {
return;
}
this.element.classList.remove('d-none');
this.twitterTarget.href = this.configValue['twitter'];
this.blueskyTarget.href = this.configValue['bluesky'];
this.tumblrTarget.href = this.configValue['tumblr'];
this.telegramTarget.href = this.configValue['telegram'];
if (this.hasCustomTarget) {
this.customTarget.href = `${this.customTarget.href}${this.configValue['custom']}`;
}
}
close(): void {
(this.element.closest(".inbox-entry")).remove();
}
closeAfterShare(): void {
this.otherTarget.addEventListener('retrospring:shared', () => this.close());
}
closeAfterCopyToClipboard(): void {
this.clipboardTarget.addEventListener('retrospring:copied', () => this.close());
}
}

View file

@ -1,62 +0,0 @@
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
export default class extends Controller {
static classes = ["current"];
static targets = ["current", "traversable"];
declare readonly hasCurrentTarget: boolean;
declare readonly currentTarget: HTMLElement;
declare readonly traversableTargets: HTMLElement[];
traversableTargetConnected(target: HTMLElement): void {
if (!("navigationIndex" in target.dataset)) {
target.dataset.navigationIndex = this.traversableTargets.indexOf(target).toString();
}
if (!this.hasCurrentTarget) {
const first = this.traversableTargets[0];
first.dataset.navigationTarget += " current";
}
}
currentTargetConnected(target: HTMLElement): void {
target.classList.add("js-hotkey-current-selection");
target.querySelectorAll<HTMLElement>("[data-selection-hotkey]")
.forEach(el => install(el, el.dataset.selectionHotkey));
}
currentTargetDisconnected(target: HTMLElement): void {
target.classList.remove("js-hotkey-current-selection");
target.querySelectorAll<HTMLElement>("[data-selection-hotkey]")
.forEach(el => uninstall(el));
}
up(): void {
const prevIndex = this.traversableTargets.indexOf(this.currentTarget) - 1;
if (prevIndex == -1) return;
this.navigate(this.traversableTargets[prevIndex]);
}
down(): void {
const nextIndex = this.traversableTargets.indexOf(this.currentTarget) + 1;
if (nextIndex == this.traversableTargets.length) return;
this.navigate(this.traversableTargets[nextIndex]);
}
navigate(target: HTMLElement): void {
if (!document.body.classList.contains("js-hotkey-navigating")) {
document.body.classList.add("js-hotkey-navigating");
}
if (target.dataset.navigationTarget == "traversable") {
this.currentTarget.dataset.navigationTarget = "traversable";
target.dataset.navigationTarget = "traversable current";
target.scrollIntoView({ block: "center", inline: "center" });
}
}
}

View file

@ -1,18 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller<HTMLElement> {
isPwa: boolean;
badgeCapable: boolean;
initialize(): void {
this.isPwa = window.matchMedia('(display-mode: standalone)').matches;
this.badgeCapable = "setAppBadge" in navigator;
}
connect(): void {
if (this.isPwa && this.badgeCapable) {
const count = Number.parseInt(this.element.innerText) || 0;
navigator.setAppBadge(count);
}
}
}

View file

@ -1,12 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';
export default class extends Controller {
click(): void {
const modal = Modal.getInstance(this.element.closest('.modal'));
const questionbox = document.querySelector((this.element as HTMLAnchorElement).href);
modal.hide();
questionbox.scrollIntoView();
}
}

View file

@ -1,15 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['button'];
declare readonly buttonTarget: HTMLButtonElement;
enable(): void {
this.buttonTarget.disabled = false;
}
disable(): void {
this.buttonTarget.disabled = true;
}
}

View file

@ -1,45 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import noop from 'utilities/noop';
export default class extends Controller {
static values = {
url: String,
text: String,
title: String
};
declare readonly urlValue: string;
declare readonly textValue: string;
declare readonly titleValue: string;
share() {
let shareConfiguration = {};
if (this.urlValue.length >= 1) {
shareConfiguration = {
...shareConfiguration,
...{ url: this.urlValue }
};
}
if (this.textValue.length >= 1) {
shareConfiguration = {
...shareConfiguration,
...{ text: this.textValue }
};
}
if (this.titleValue.length >= 1) {
shareConfiguration = {
...shareConfiguration,
...{ title: this.titleValue }
};
}
navigator.share(shareConfiguration)
.then(() => {
this.element.dispatchEvent(new CustomEvent('retrospring:shared'));
})
.catch(noop);
}
}

View file

@ -1,79 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import Coloris from '@melloware/coloris';
import {
THEME_MAPPING,
getColorForKey,
getHexColorFromThemeValue,
getIntegerFromHexColor
} from 'utilities/theme';
export default class extends Controller {
static targets = ['color'];
declare readonly colorTargets: HTMLInputElement[];
previewStyle = null;
previewTimeout = null;
setupPreviewElement(): void {
this.previewStyle = document.createElement('style');
this.previewStyle.setAttribute('data-preview-style', '');
document.body.appendChild(this.previewStyle);
}
convertColors(): void {
this.colorTargets.forEach((color) => {
color.value = `#${getHexColorFromThemeValue(color.value)}`;
});
}
connect(): void {
this.setupPreviewElement();
this.convertColors();
Coloris.init();
Coloris({
el: '.color',
wrap: false,
formatToggle: false,
alpha: false
});
}
updatePreview(): void {
clearTimeout(this.previewTimeout);
this.previewTimeout = setTimeout(this.previewTheme.bind(this), 1000);
}
previewTheme(): void {
const payload = {};
this.colorTargets.forEach((color) => {
const name = color.name.substring(6, color.name.length - 1);
payload[name] = parseInt(color.value.substr(1, 6), 16);
});
this.generateTheme(payload);
}
generateTheme(payload: Record<string, string>): void {
let body = ":root {\n";
Object.entries(payload).forEach(([key, value]) => {
if (THEME_MAPPING[key]) {
body += `--${THEME_MAPPING[key]}: ${getColorForKey(THEME_MAPPING[key], value)};\n`;
}
});
body += "}";
this.previewStyle.innerHTML = body;
}
submit(): void {
this.colorTargets.forEach((color) => {
color.value = String(getIntegerFromHexColor(color.value));
});
}
}

View file

@ -1,17 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { showNotification } from "utilities/notifications";
export default class extends Controller<HTMLElement> {
static values = {
message: String,
success: Boolean
};
declare readonly messageValue: string;
declare readonly successValue: boolean;
connect(): void {
showNotification(this.messageValue, this.successValue);
this.element.remove();
}
}

View file

@ -1,8 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { Tooltip } from 'bootstrap';
export default class extends Controller {
connect(): void {
new Tooltip(this.element);
}
}

View file

@ -1,47 +0,0 @@
import { post } from '@rails/request.js';
import swal from 'sweetalert';
import I18n from 'retrospring/i18n';
import { showNotification, showErrorNotification } from 'utilities/notifications';
export function commentDestroyHandler(event: Event): void {
const button = event.target as HTMLButtonElement;
const id = button.dataset.cId;
event.preventDefault();
button.disabled = true;
swal({
title: I18n.translate('frontend.destroy_comment.confirm.title'),
text: I18n.translate('frontend.destroy_comment.confirm.text'),
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#DD6B55',
confirmButtonText: I18n.translate('voc.delete'),
cancelButtonText: I18n.translate('voc.cancel'),
closeOnConfirm: true
}, (returnValue) => {
if (returnValue === false) {
button.disabled = false;
return;
}
post('/ajax/destroy_comment', {
body: {
comment: id
},
contentType: 'application/json'
})
.then(async response => {
const data = await response.json;
showNotification(data.message);
document.querySelector(`[data-comment-id="${id}"]`).remove();
})
.catch(err => {
console.log(err);
showErrorNotification(I18n.translate('frontend.error.message'));
button.disabled = false;
});
});
}

View file

@ -1,10 +0,0 @@
export function commentHotkeyHandler(event: Event): void {
const button = event.target as HTMLButtonElement;
const id = button.dataset.aId;
const answerbox = button.closest('.answerbox');
if (answerbox !== null) {
answerbox.querySelector(`#ab-comments-section-${id}`).classList.toggle('d-none');
answerbox.querySelector<HTMLElement>(`[name="ab-comment-new"][data-a-id="${id}"]`).focus();
}
}

View file

@ -1,19 +0,0 @@
import registerEvents from "retrospring/utilities/registerEvents";
import { commentDestroyHandler } from "./destroy";
import { commentComposeEnd, commentComposeStart, commentCreateClickHandler, commentCreateKeyboardHandler } from "./new";
import { commentReportHandler } from "./report";
import { commentToggleHandler } from "./toggle";
import { commentHotkeyHandler } from "retrospring/features/answerbox/comment/hotkey";
export default (): void => {
registerEvents([
{ type: 'click', target: '[name=ab-comments]', handler: commentToggleHandler, global: true },
{ type: 'click', target: '[name=ab-open-and-comment]', handler: commentHotkeyHandler, global: true },
{ type: 'click', target: '[data-action=ab-comment-report]', handler: commentReportHandler, global: true },
{ type: 'click', target: '[data-action=ab-comment-destroy]', handler: commentDestroyHandler, global: true },
{ type: 'compositionstart', target: '[name=ab-comment-new]', handler: commentComposeStart, global: true },
{ type: 'compositionend', target: '[name=ab-comment-new]', handler: commentComposeEnd, global: true },
{ type: 'keydown', target: '[name=ab-comment-new]', handler: commentCreateKeyboardHandler, global: true },
{ type: 'click', target: '[name=ab-comment-new-submit]', handler: commentCreateClickHandler, global: true }
]);
}

View file

@ -1,85 +0,0 @@
import { post } from '@rails/request.js';
import I18n from 'retrospring/i18n';
import { showNotification, showErrorNotification } from 'utilities/notifications';
let compositionJustEnded = false;
function createComment(input: HTMLInputElement, id: string, counter: Element, group: Element) {
if (input.value.length > 512) {
group.classList.add('has-error');
return true;
}
input.disabled = true;
post('/ajax/create_comment', {
body: {
answer: id,
comment: input.value
},
contentType: 'application/json'
})
.then(async response => {
const data = await response.json;
if (data.success) {
document.querySelector(`#ab-comments-${id}`).innerHTML = data.render;
const commentCount = document.getElementById(`#ab-comment-count-${id}`);
if (commentCount) {
commentCount.innerHTML = data.count;
}
input.value = '';
counter.innerHTML = String(512);
}
showNotification(data.message, data.success);
})
.catch(err => {
console.log(err);
showErrorNotification(I18n.translate('frontend.error.message'));
})
.finally(() => {
input.disabled = false;
});
}
export function commentCreateKeyboardHandler(event: KeyboardEvent): boolean {
if (compositionJustEnded && event.which == 13) {
compositionJustEnded = false;
return;
}
const input = event.target as HTMLInputElement;
const id = input.dataset.aId;
const counter = document.querySelector(`#ab-comment-charcount-${id}`);
const group = document.querySelector(`[name=ab-comment-new-group][data-a-id="${id}"]`);
if ((event.ctrlKey || event.metaKey) && event.which === 13) {
event.preventDefault();
createComment(input, id, counter, group);
}
}
export function commentCreateClickHandler(event: MouseEvent): void {
event.preventDefault();
const button = event.target as HTMLButtonElement;
const id = button.dataset.aId;
const input = document.querySelector<HTMLInputElement>(`[name="ab-comment-new"][data-a-id="${id}"]`);
const counter = document.querySelector(`#ab-comment-charcount-${id}`);
const group = document.querySelector(`[name=ab-comment-new-group][data-a-id="${id}"]`);
createComment(input, id, counter, group);
}
export function commentComposeStart(): boolean {
compositionJustEnded = false;
return true;
}
export function commentComposeEnd(): boolean {
compositionJustEnded = true;
return true;
}

View file

@ -1,9 +0,0 @@
import { reportDialog } from 'utilities/reportDialog';
export function commentReportHandler(event: Event): void {
event.preventDefault();
const button = event.target as HTMLButtonElement;
const commentId = button.dataset.cId;
reportDialog('comment', commentId);
}

View file

@ -1,9 +0,0 @@
export function commentToggleHandler(event: Event): void {
const button = event.target as HTMLButtonElement;
const id = button.dataset.aId;
const answerbox = button.closest('.answerbox');
if (answerbox !== null) {
answerbox.querySelector(`#ab-comments-section-${id}`).classList.toggle('d-none');
}
}

View file

@ -1,47 +0,0 @@
import { post } from '@rails/request.js';
import { showErrorNotification, showNotification } from 'utilities/notifications';
import swal from 'sweetalert';
import I18n from 'retrospring/i18n';
export function answerboxDestroyHandler(event: Event): void {
event.preventDefault();
const button = event.target as HTMLButtonElement;
const answerId = button.dataset.aId;
swal({
title: I18n.translate('frontend.destroy_own.confirm.title'),
text: I18n.translate('frontend.destroy_own.confirm.text'),
type: "warning",
showCancelButton: true,
confirmButtonColor: "#DD6B55",
confirmButtonText: I18n.translate('voc.y'),
cancelButtonText: I18n.translate('voc.n'),
closeOnConfirm: true
}, (returnValue) => {
if (returnValue === false) {
button.disabled = false;
return;
}
post('/ajax/destroy_answer', {
body: {
answer: answerId
},
contentType: 'application/json'
})
.then(async response => {
const data = await response.json;
if (data.success) {
document.querySelector(`.answerbox[data-id="${answerId}"]`).remove();
}
showNotification(data.message, data.success);
})
.catch(err => {
console.log(err);
showErrorNotification(I18n.translate('frontend.error.message'));
});
});
}

View file

@ -1,13 +0,0 @@
import registerEvents from 'utilities/registerEvents';
import registerAnswerboxCommentEvents from './comment';
import { answerboxDestroyHandler } from './destroy';
import { answerboxReportHandler } from './report';
export default (): void => {
registerEvents([
{ type: 'click', target: '[data-action=ab-report]', handler: answerboxReportHandler, global: true },
{ type: 'click', target: '[data-action=ab-destroy]', handler: answerboxDestroyHandler, global: true },
]);
registerAnswerboxCommentEvents();
}

View file

@ -1,9 +0,0 @@
import { reportDialog } from 'utilities/reportDialog';
export function answerboxReportHandler(event: Event): void {
event.preventDefault();
const button = event.target as HTMLButtonElement;
const answerId = button.dataset.aId;
reportDialog('answer', answerId);
}

View file

@ -1,93 +0,0 @@
import { post } from '@rails/request.js';
import swal from 'sweetalert';
import I18n from 'retrospring/i18n';
import { showErrorNotification } from 'utilities/notifications';
export function updateDeleteButton(increment = true): void {
const deleteButton: HTMLElement = document.querySelector('[id^=ib-delete-all]');
const inboxCount: number = parseInt(deleteButton.getAttribute('data-ib-count'));
let targetInboxCount = 0;
if (increment) {
targetInboxCount = inboxCount + 1;
}
else {
targetInboxCount = inboxCount - 1;
}
deleteButton.setAttribute('data-ib-count', targetInboxCount.toString());
if (targetInboxCount > 0) {
deleteButton.removeAttribute('disabled');
} else {
deleteButton.setAttribute('disabled', 'disabled');
}
}
export function deleteAllQuestionsHandler(event: Event): void {
const button = event.target as Element;
const count = button.getAttribute('data-ib-count');
swal({
title: I18n.translate('frontend.inbox.confirm_all.title', { count: count }),
text: I18n.translate('frontend.inbox.confirm_all.text'),
type: "warning",
showCancelButton: true,
confirmButtonColor: "#DD6B55",
confirmButtonText: I18n.translate('voc.delete'),
cancelButtonText: I18n.translate('voc.cancel'),
closeOnConfirm: true
}, (returnValue) => {
if (returnValue === false) {
return;
}
post('/ajax/delete_all_inbox')
.then(async response => {
const data = await response.json;
if (!data.success) return false;
updateDeleteButton(false);
document.querySelector('#entries').innerHTML = 'Nothing to see here!';
})
.catch(err => {
console.log(err);
showErrorNotification(I18n.translate('frontend.error.message'));
});
});
}
export function deleteAllAuthorQuestionsHandler(event: Event): void {
const button = event.target as Element;
const count = button.getAttribute('data-ib-count');
const urlSearchParams = new URLSearchParams(window.location.search);
swal({
title: I18n.translate('frontend.inbox.confirm_all.title', { count: count }),
text: I18n.translate('frontend.inbox.confirm_all.text'),
type: "warning",
showCancelButton: true,
confirmButtonColor: "#DD6B55",
confirmButtonText: I18n.translate('voc.delete'),
cancelButtonText: I18n.translate('voc.cancel'),
closeOnConfirm: true
}, (returnValue) => {
if (returnValue === null) return false;
post(`/ajax/delete_all_inbox/${urlSearchParams.get('author')}`)
.then(async response => {
const data = await response.json;
if (!data.success) return false;
updateDeleteButton(false);
document.querySelector('#entries').innerHTML = 'Nothing to see here!';
})
.catch(err => {
console.log(err);
showErrorNotification(I18n.translate('frontend.error.message'));
});
});
}

View file

@ -1,65 +0,0 @@
import { post } from '@rails/request.js';
import { updateDeleteButton } from '../delete';
import { showNotification, showErrorNotification } from 'utilities/notifications';
export function answerEntryHandler(event: Event): void {
const element: HTMLButtonElement = event.target as HTMLButtonElement;
const inboxEntry: HTMLElement = element.closest<HTMLElement>('.inbox-entry');
element.disabled = true;
const data = {
id: element.getAttribute('data-ib-id'),
answer: inboxEntry.querySelector<HTMLInputElement>('textarea[name=ib-answer]')?.value,
inbox: 'true'
};
post('/ajax/answer', {
body: data,
contentType: 'application/json'
})
.then(async response => {
const data = await response.json;
if (!data.success) {
showErrorNotification(data.message);
element.disabled = false;
return false;
}
updateDeleteButton(false);
showNotification(data.message);
const shareButton = inboxEntry.querySelector<HTMLButtonElement>('[data-controller="share"]');
const clipboardCopyButton = inboxEntry.querySelector<HTMLButtonElement>('[data-action="clipboard#copy"]')
if (shareButton != null) {
shareButton.dataset.shareUrlValue = data.sharing.url;
shareButton.dataset.shareTextValue = data.sharing.text;
}
if (clipboardCopyButton != null){
clipboardCopyButton.dataset.clipboardCopyValue = `${data.sharing.text} ${data.sharing.url}`;
}
const sharing = inboxEntry.querySelector<HTMLElement>('.inbox-entry__sharing');
if (sharing != null) {
sharing.dataset.inboxSharingConfigValue = JSON.stringify(data.sharing);
}
else {
(inboxEntry as HTMLElement).remove();
}
})
.catch(err => {
console.log(err);
element.disabled = false;
});
}
export function answerEntryInputHandler(event: KeyboardEvent): void {
const input = event.target as HTMLInputElement;
const inboxId = input.dataset.id;
if (event.keyCode == 13 && (event.ctrlKey || event.metaKey)) {
document.querySelector<HTMLButtonElement>(`button[name="ib-answer"][data-ib-id="${inboxId}"]`).click();
}
}

Some files were not shown because too many files have changed in this diff Show more