Merge branch 'main' into feature/raised-texts

This commit is contained in:
Andreas Nedbal 2023-01-29 19:38:32 +01:00
commit 6b3342036f
33 changed files with 191 additions and 141 deletions

View File

@ -437,7 +437,7 @@ GEM
ruby-vips (2.1.4)
ffi (~> 1.12)
rubyzip (2.3.2)
sanitize (6.0.0)
sanitize (6.0.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
sassc (2.4.0)

View File

@ -51,6 +51,15 @@ namespace :justask do # rubocop:disable Metrics/BlockLength
user.remove_role :moderator
puts "#{user.screen_name} is no longer a moderator."
end
desc "Removes users whose accounts haven't been verified for over 3 months."
task remove_stale: :environment do
puts "Removing stale users…"
removed = User.where(confirmed_at: nil)
.where("confirmation_sent_at < ?", DateTime.now.utc - 3.months)
.destroy_all.count
puts "Removed #{removed} users"
end
end
namespace :db do

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
class AjaxController < ApplicationController
skip_before_action :find_active_announcements
before_action :build_response
after_action :return_response
@ -92,10 +93,6 @@ class AjaxController < ApplicationController
return_response
end
def find_active_announcements
# We do not need announcements here
end
private
def build_response

View File

@ -3,6 +3,8 @@
class InboxController < ApplicationController
before_action :authenticate_user!
after_action :mark_inbox_entries_as_read, only: %i[show]
def show
find_author
find_inbox_entries
@ -77,4 +79,9 @@ class InboxController < ApplicationController
.joins(:question)
.where(questions: { user: @author_user, author_is_anonymous: false })
end
def mark_inbox_entries_as_read
# using .dup to not modify @inbox -- useful in tests
@inbox&.dup&.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations
end
end

View File

@ -3,7 +3,13 @@
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
render json: {
name: APP_CONFIG["site_name"],
description: t("about.index.subtitle"),
@ -12,17 +18,19 @@ class ManifestsController < ApplicationController
display: "standalone",
categories: %w[social],
lang: I18n.locale,
shortcuts: [
webapp_shortcut(inbox_url, t("navigation.inbox"), "inbox")
],
shortcuts:,
icons: webapp_icons,
theme_color: theme_color,
theme_color:,
background_color: mobile_theme_color
}
end
private
def shortcuts = [
webapp_shortcut(inbox_url, t("navigation.inbox"), "inbox")
]
def webapp_shortcut(url, name, icon_name)
{
name: name,

View File

@ -3,6 +3,8 @@
class NotificationsController < ApplicationController
before_action :authenticate_user!
after_action :mark_notifications_as_read, only: %i[index]
TYPE_MAPPINGS = {
"answer" => Notification::QuestionAnswered.name,
"comment" => Notification::Commented.name,
@ -25,6 +27,11 @@ class NotificationsController < ApplicationController
private
def mark_notifications_as_read
# using .dup to not modify @notifications -- useful in tests
@notifications&.dup&.update_all(new: false) # rubocop:disable Rails/SkipsModelValidations
end
def cursored_notifications_for(type:, last_id:, size: nil)
cursor_params = { last_id: last_id, size: size }.compact

View File

@ -3,20 +3,13 @@
class UserController < ApplicationController
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
@answers = @user.cursored_answers(last_id: params[:last_id])
@answers_last_id = @answers.map(&:id).min
@more_data_available = !@user.cursored_answers(last_id: @answers_last_id, size: 1).count.zero?
if user_signed_in?
notif = 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).first
unless notif.nil?
notif.new = false
notif.save
end
end
respond_to do |format|
format.html
format.turbo_stream
@ -24,36 +17,30 @@ class UserController < ApplicationController
end
def followers
@title = "Followers"
@relationships = @user.cursored_follower_relationships(last_id: params[:last_id])
@relationships_last_id = @relationships.map(&:id).min
@more_data_available = !@user.cursored_follower_relationships(last_id: @relationships_last_id, size: 1).count.zero?
@users = @relationships.map(&:source)
@type = :follower
respond_to do |format|
format.html { render "show_follow" }
format.turbo_stream { render "show_follow" }
format.html { render "show_follow", locals: { type: :follower } }
format.turbo_stream { render "show_follow", locals: { type: :follower } }
end
end
def followings
@title = "Following"
@relationships = @user.cursored_following_relationships(last_id: params[:last_id])
@relationships_last_id = @relationships.map(&:id).min
@more_data_available = !@user.cursored_following_relationships(last_id: @relationships_last_id, size: 1).count.zero?
@users = @relationships.map(&:target)
@type = :friend
respond_to do |format|
format.html { render "show_follow" }
format.turbo_stream { render "show_follow" }
format.html { render "show_follow", locals: { type: :friend } }
format.turbo_stream { render "show_follow", locals: { type: :friend } }
end
end
def questions
@title = "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?
@ -66,6 +53,18 @@ class UserController < ApplicationController
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

View File

@ -0,0 +1,49 @@
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.src = src;
new Croppr(this.cropperTarget, {
aspectRatio: parseFloat(this.aspectRatioValue),
startSize: [100, 100, '%'],
onCropStart: this.updateValues.bind(this),
onCropMove: this.updateValues.bind(this),
onCropEnd: this.updateValues.bind(this)
});
});
}
}
}

View File

@ -1,6 +0,0 @@
export function authorSearchHandler(event: Event): void {
event.preventDefault();
const author = document.querySelector<HTMLInputElement>('#author')?.value;
window.location.href = `/inbox/${encodeURIComponent(author)}`;
}

View File

@ -62,6 +62,7 @@ export function deleteAllQuestionsHandler(event: Event): void {
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 }),
@ -75,7 +76,7 @@ export function deleteAllAuthorQuestionsHandler(event: Event): void {
}, (returnValue) => {
if (returnValue === null) return false;
post(`/ajax/delete_all_inbox/${location.pathname.split('/')[2]}`)
post(`/ajax/delete_all_inbox/${urlSearchParams.get('author')}`)
.then(async response => {
const data = await response.json;
@ -89,4 +90,4 @@ export function deleteAllAuthorQuestionsHandler(event: Event): void {
showErrorNotification(I18n.translate('frontend.error.message'));
});
});
}
}

View File

@ -6,8 +6,7 @@ import { deleteAllAuthorQuestionsHandler, deleteAllQuestionsHandler } from './de
export default (): void => {
registerEvents([
{ type: 'click', target: '#ib-delete-all', handler: deleteAllQuestionsHandler, global: true },
{ type: 'click', target: '#ib-delete-all-author', handler: deleteAllAuthorQuestionsHandler, global: true },
{ type: 'submit', target: '#author-form', handler: authorSearchHandler, global: true }
{ type: 'click', target: '#ib-delete-all-author', handler: deleteAllAuthorQuestionsHandler, global: true }
]);
registerInboxEntryEvents();

View File

@ -1,61 +0,0 @@
import Croppr from 'croppr';
const readImage = (file, callback) => callback((window.URL || window.webkitURL).createObjectURL(file));
export function profilePictureChangeHandler(event: Event): void {
const input = event.target as HTMLInputElement;
const cropControls = document.querySelector('#profile-picture-crop-controls');
cropControls.classList.toggle('d-none');
if (input.files && input.files[0]) {
readImage(input.files[0], (src) => {
const updateValues = (data) => {
document.querySelector<HTMLInputElement>('#profile_picture_x').value = data.x;
document.querySelector<HTMLInputElement>('#profile_picture_y').value = data.y;
document.querySelector<HTMLInputElement>('#profile_picture_w').value = data.width;
document.querySelector<HTMLInputElement>('#profile_picture_h').value = data.height;
}
const cropper = document.querySelector<HTMLImageElement>('#profile-picture-cropper');
cropper.src = src;
new Croppr(cropper, {
aspectRatio: 1,
startSize: [100, 100, '%'],
onCropStart: updateValues,
onCropMove: updateValues,
onCropEnd: updateValues
});
});
}
}
export function profileHeaderChangeHandler(event: Event): void {
const input = event.target as HTMLInputElement;
const cropControls = document.querySelector('#profile-header-crop-controls');
cropControls.classList.toggle('d-none');
if (input.files && input.files[0]) {
readImage(input.files[0], (src) => {
const updateValues = (data) => {
document.querySelector<HTMLInputElement>('#profile_header_x').value = data.x;
document.querySelector<HTMLInputElement>('#profile_header_y').value = data.y;
document.querySelector<HTMLInputElement>('#profile_header_w').value = data.width;
document.querySelector<HTMLInputElement>('#profile_header_h').value = data.height;
}
const cropper = document.querySelector<HTMLImageElement>('#profile-header-cropper');
cropper.src = src;
new Croppr(cropper, {
aspectRatio: 7/30,
startSize: [100, 100, '%'],
onCropStart: updateValues,
onCropMove: updateValues,
onCropEnd: updateValues
});
});
}
}

View File

@ -1,11 +1,8 @@
import registerEvents from "utilities/registerEvents";
import { profileHeaderChangeHandler, profilePictureChangeHandler } from "./crop";
import { userSubmitHandler } from "./password";
export default (): void => {
registerEvents([
{ type: 'submit', target: document.querySelector('#edit_user'), handler: userSubmitHandler },
{ type: 'change', target: document.querySelector('#user_profile_picture[type=file]'), handler: profilePictureChangeHandler },
{ type: 'change', target: document.querySelector('#user_profile_header[type=file]'), handler: profileHeaderChangeHandler }
{ type: 'submit', target: document.querySelector('#edit_user'), handler: userSubmitHandler }
]);
}

View File

@ -7,6 +7,7 @@ import FormatPopupController from "retrospring/controllers/format_popup_controll
import CollapseController from "retrospring/controllers/collapse_controller";
import ThemeController from "retrospring/controllers/theme_controller";
import CapabilitiesController from "retrospring/controllers/capabilities_controller";
import CropperController from "retrospring/controllers/cropper_controller";
/**
* This module sets up Stimulus and our controllers
@ -23,6 +24,7 @@ export default function (): void {
window['Stimulus'].register('character-count', CharacterCountController);
window['Stimulus'].register('character-count-warning', CharacterCountWarningController);
window['Stimulus'].register('collapse', CollapseController);
window['Stimulus'].register('cropper', CropperController);
window['Stimulus'].register('format-popup', FormatPopupController);
window['Stimulus'].register('theme', ThemeController);
}

View File

@ -5,10 +5,11 @@ module User::AnswerMethods
define_cursor_paginator :cursored_answers, :ordered_answers
# @return [ActiveRecord::Relation<Answer>] List of a user's answers
def ordered_answers
answers
.order(:created_at)
.reverse_order
.includes(comments: [:user, :smiles], question: [:user], smiles: [:user])
.includes(comments: %i[user smiles], question: { user: :profile }, smiles: [:user])
end
end

View File

@ -5,9 +5,10 @@ module User::InboxMethods
define_cursor_paginator :cursored_inbox, :ordered_inbox
# @return [ActiveRecord::Relation<Inbox>] the user's inbox entries
def ordered_inbox
inboxes
.includes(:question, :user)
.includes(:question, user: :profile)
.order(:created_at)
.reverse_order
end

View File

@ -5,6 +5,7 @@ module User::QuestionMethods
define_cursor_paginator :cursored_questions, :ordered_questions
# @return [ActiveRecord::Relation<Question>] List of questions sent by the user
def ordered_questions(author_is_anonymous: nil, direct: nil)
questions
.where({ author_is_anonymous:, direct: }.compact)

View File

@ -6,10 +6,12 @@ module User::RelationshipMethods
define_cursor_paginator :cursored_following_relationships, :ordered_following_relationships
define_cursor_paginator :cursored_follower_relationships, :ordered_follower_relationships
# @return [ActiveRecord::Relation<Relationships::Follow>] List of the user's following relationships
def ordered_following_relationships
active_follow_relationships.reverse_order.includes(target: [:profile])
end
# @return [ActiveRecord::Relation<Relationships::Follow>] List of the user's follower relationships
def ordered_follower_relationships
passive_follow_relationships.reverse_order.includes(source: [:profile])
end

View File

@ -5,8 +5,12 @@ module User::TimelineMethods
define_cursor_paginator :cursored_timeline, :timeline
# @return [Array] the users' timeline
# @return [ActiveRecord::Relation<Answer>] the user's timeline
def timeline
Answer.where("user_id in (?) OR user_id = ?", following_ids, id).order(:created_at).reverse_order.includes(comments: %i[user smiles], question: [:user], user: [:profile], smiles: [:user])
Answer
.where("user_id in (?) OR user_id = ?", following_ids, id)
.order(:created_at)
.reverse_order
.includes(comments: %i[user smiles], question: { user: :profile }, user: [:profile], smiles: [:user])
end
end

View File

@ -19,6 +19,7 @@ class TypoedEmailValidator < ActiveModel::EachValidator
gmaile.com
gmaill.com
gmali.com
gmaul.com
gnail.com
hotamil.com
hotmai.com

View File

@ -9,6 +9,5 @@
= render 'shared/links'
:ruby
@inbox.update_all(new: false)
provide(:title, generate_title('Inbox'))
parent_layout 'base'

View File

@ -8,5 +8,4 @@
.d-block.d-sm-none= render "shared/links"
:ruby
Notification.for(current_user).update_all(new: false)
parent_layout 'base'

View File

@ -2,36 +2,38 @@
.card-body
= bootstrap_form_for(current_user, url: settings_profile_picture_path, html: { multipart: true }, method: :patch, data: { turbo: false }) do |f|
.d-flex#profile-picture-media
.flex-shrink-0
%img.avatar-lg.me-3{ src: current_user.profile_picture.url(:medium) }
.flex-grow-1
= f.file_field :profile_picture, accept: APP_CONFIG[:accepted_image_formats].join(",")
%div{ data: { controller: "cropper", cropper_aspect_ratio_value: "1" } }
.d-flex
.flex-shrink-0
%img.avatar-lg.me-3{ src: current_user.profile_picture.url(:medium) }
.flex-grow-1
= f.file_field :profile_picture, accept: APP_CONFIG[:accepted_image_formats].join(","), data: { cropper_target: "input", action: "cropper#change" }
.row.d-none#profile-picture-crop-controls
.col-sm-10.col-md-8
%strong= t(".adjust.profile_picture")
%img#profile-picture-cropper{ src: current_user.profile_picture.url(:medium) }
.row.d-none{ data: { cropper_target: "controls" } }
.col-sm-10.col-md-8
%strong= t(".adjust.profile_picture")
%img{ src: current_user.profile_picture.url(:medium), data: { cropper_target: "cropper" } }
.row.mb-2#profile-header-media
.col-xs-12.col-md-6
%img.mw-100.me-3{ src: current_user.profile_header.url(:mobile) }
.col-xs-12.col-md-6.mt-3.mt-sm-0.ps-3.pe-3
= f.file_field :profile_header, accept: APP_CONFIG[:accepted_image_formats].join(",")
- %i[profile_picture_x profile_picture_y profile_picture_w profile_picture_h].each do |attrib|
= f.hidden_field attrib, id: attrib, data: { cropper_target: attrib.to_s.split("_").last }
.row.d-none#profile-header-crop-controls
.col-sm-10.col-md-8
%strong= t(".adjust.profile_header")
%img#profile-header-cropper{ src: current_user.profile_header.url(:web) }
%div{ data: { controller: "cropper", cropper_aspect_ratio_value: "0.23" } }
.row.mb-2
.col-xs-12.col-md-6
%img.mw-100.me-3{ src: current_user.profile_header.url(:mobile) }
.col-xs-12.col-md-6.mt-3.mt-sm-0.ps-3.pe-3
= f.file_field :profile_header, accept: APP_CONFIG[:accepted_image_formats].join(","), data: { cropper_target: "input", action: "cropper#change" }
.row.d-none{ data: { cropper_target: "controls" } }
.col-sm-10.col-md-8
%strong= t(".adjust.profile_header")
%img{ src: current_user.profile_header.url(:web), data: { cropper_target: "cropper" } }
- %i[profile_header_x profile_header_y profile_header_w profile_header_h].each do |attrib|
= f.hidden_field attrib, id: attrib, data: { cropper_target: attrib.to_s.split("_").last }
= f.check_box :show_foreign_themes
- %i[profile_picture_x profile_picture_y profile_picture_w profile_picture_h].each do |attrib|
= f.hidden_field attrib, id: attrib
- %i[profile_header_x profile_header_y profile_header_w profile_header_h].each do |attrib|
= f.hidden_field attrib, id: attrib
= f.primary t(".submit_picture")
.card
.card-body

View File

@ -1,15 +1,15 @@
.row.row-cols-1.row-cols-sm-2.row-cols-md-3#users
- @users.each do |user|
.col.pb-3
= render 'shared/userbox', user: user, type: @type
= render "shared/userbox", user:, type:
- if @more_data_available
.d-flex.justify-content-center.justify-content-sm-start#paginator
= button_to t("voc.load"), @type == :follower ? show_user_followers_path(@user) : show_user_followings_path(@user),
= button_to t("voc.load"), type == :follower ? show_user_followers_path(@user) : show_user_followings_path(@user),
class: "btn btn-light",
method: :get,
params: { last_id: @relationships_last_id },
form: { data: { turbo_stream: true } }
- provide(:title, user_title(@user, 'friends and followers'))
- parent_layout 'user/profile'
- provide(:title, t(".title.#{type}", user: @user.profile.safe_name))
- parent_layout "user/profile"

View File

@ -1,11 +1,11 @@
= turbo_stream.append "users" do
- @users.each do |user|
.col.pb-3
= render 'shared/userbox', user: user, type: @type
= render "shared/userbox", user:, type:
= turbo_stream.update "paginator" do
- if @more_data_available
= button_to t("voc.load"), @type == :follower ? show_user_followers_path(@user) : show_user_followings_path(@user),
= button_to t("voc.load"), type == :follower ? show_user_followers_path(@user) : show_user_followings_path(@user),
class: "btn btn-light",
method: :get,
params: { last_id: @relationships_last_id },

View File

@ -8,4 +8,20 @@ Sentry.init do |config|
# of transactions for performance monitoring.
# We recommend adjusting this value in production
config.traces_sample_rate = 0.25
exception_fingerprints = {
Excon::Error::ServiceUnavailable => 'external-service',
Twitter::Error::InternalServerError => 'external-service',
}
config.before_send = lambda do |event, hint|
# These are used for user-facing errors, not when something goes wrong
next if hint[:exception].is_a?(Errors::Base)
exception_class = hint[:exception].class
if exception_fingerprints.key?(exception_class)
event.fingerprint = [exception_fingerprints[exception_class]]
end
event
end
end

View File

@ -651,6 +651,10 @@ en:
index:
title: "Questions from %{author_identifier}"
user:
show_follow:
title:
follower: "%{user}'s followers"
friend: "%{user}'s followings"
actions:
view_inbox: "View inbox"
privilege: "Check %{user}'s privileges"

View File

@ -157,14 +157,14 @@ Rails.application.routes.draw do
get "/@:username/q/:id", to: "question#show", as: :question
get "/@:username/followers", to: "user#followers", as: :show_user_followers
get "/@:username/followings", to: "user#followings", as: :show_user_followings
get "/@:username/friends", to: redirect("/@%{username}/followings/p/%{page}")
get "/@:username/friends", to: redirect("/@%{username}/followings")
get "/@:username/questions", to: "user#questions", as: :show_user_questions
get "/:username", to: "user#show", as: :user_alt
get "/:username/a/:id", to: "answer#show", as: :answer_alt
get "/:username/q/:id", to: "question#show", as: :question_alt
get "/:username/followers", to: "user#followers", as: :show_user_followers_alt
get "/:username/followings", to: "user#followings", as: :show_user_followings_alt
get "/:username/friends", to: redirect("/%{username}/followings/p/%{page}")
get "/:username/friends", to: redirect("/%{username}/followings")
get "/:username/questions", to: "user#questions", as: :show_user_questions_alt
get "/feedback/consent", to: "feedback#consent", as: "feedback_consent"

View File

@ -17,7 +17,7 @@ module Retrospring
def month = 1
def day = 23
def day = 27
def patch = 0

View File

@ -47,6 +47,9 @@ self.addEventListener('install', function (event) {
});
self.addEventListener('fetch', function (event) {
const url = new URL(event.request.url);
if (event.request.method !== 'GET' || !OFFLINE_CACHE_PATHS.includes(url.pathname)) return;
event.respondWith(
(async () => {
try {

View File

@ -58,6 +58,10 @@ describe InboxController, type: :controller do
end
end
it "updates the inbox entry status" do
expect { subject }.to change { inbox_entry.reload.new? }.from(true).to(false)
end
context "when requested the turbo stream format" do
subject { get :show, format: :turbo_stream }

View File

@ -33,5 +33,9 @@ describe NotificationsController do
expect(response).to render_template(:index)
expect(controller.instance_variable_get(:@notifications)).to have_attributes(size: 2)
end
it "marks notifications as read" do
expect { subject }.to change { Notification.for(user).where(new: true).count }.from(2).to(0)
end
end
end

View File

@ -93,6 +93,7 @@ RSpec.describe User, type: :model do
include_examples "invalid email", "fritz.fantom@gmaile.com"
include_examples "invalid email", "fritz.fantom@gmaill.com"
include_examples "invalid email", "fritz.fantom@gmali.com"
include_examples "invalid email", "fritz.fantom@gmaul.com"
include_examples "invalid email", "fritz.fantom@gnail.com"
include_examples "invalid email", "fritz.fantom@hotamil.com"
include_examples "invalid email", "fritz.fantom@hotmai.com"