Merge branch 'main' into glitch-soc/merge-upstream

Conflicts:
- `app/views/admin/settings/appearance/show.html.haml`:
  Upstream enforced an uniform code style around lambdas, and glitch-soc
  had a different lambda due to its theming system.
  Applied the same code style changes.
- `app/views/settings/preferences/appearance/show.html.haml`:
  Upstream enforced an uniform code style around lambdas, and glitch-soc
  removed some code just after the lambda.
  Applied the same code style changes.
shrike
Claire 2023-07-17 19:02:23 +02:00
commit c04f2d0cf7
42 changed files with 824 additions and 486 deletions

View File

@ -23,5 +23,5 @@ jobs:
repoToken: '${{ secrets.GITHUB_TOKEN }}' repoToken: '${{ secrets.GITHUB_TOKEN }}'
commentOnClean: This pull request has resolved merge conflicts and is ready for review. commentOnClean: This pull request has resolved merge conflicts and is ready for review.
commentOnDirty: This pull request has merge conflicts that must be resolved before it can be merged. commentOnDirty: This pull request has merge conflicts that must be resolved before it can be merged.
retryMax: 10 retryMax: 30
continueOnMissingPermissions: false continueOnMissingPermissions: false

View File

@ -1,17 +1,13 @@
# This configuration was generated by # This configuration was generated by
# `haml-lint --auto-gen-config` # `haml-lint --auto-gen-config`
# on 2023-07-11 23:58:05 +0200 using Haml-Lint version 0.48.0. # on 2023-07-17 12:00:21 -0400 using Haml-Lint version 0.48.0.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the lints are removed from the code base. # one by one as the lints are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
# versions of Haml-Lint, may require this file to be generated again. # versions of Haml-Lint, may require this file to be generated again.
linters: linters:
# Offense count: 94 # Offense count: 959
RuboCop:
enabled: false
# Offense count: 960
LineLength: LineLength:
enabled: false enabled: false
@ -19,6 +15,10 @@ linters:
UnnecessaryStringOutput: UnnecessaryStringOutput:
enabled: false enabled: false
# Offense count: 67
RuboCop:
enabled: false
# Offense count: 3 # Offense count: 3
ViewLength: ViewLength:
exclude: exclude:

View File

@ -19,6 +19,7 @@ class Api::V1::TagsController < Api::BaseController
def unfollow def unfollow
TagFollow.find_by(account: current_account, tag: @tag)&.destroy! TagFollow.find_by(account: current_account, tag: @tag)&.destroy!
TagUnmergeWorker.perform_async(@tag.id, current_account.id)
render json: @tag, serializer: REST::TagSerializer render json: @tag, serializer: REST::TagSerializer
end end

View File

@ -23,9 +23,7 @@ export default class ColumnBackButton extends PureComponent {
if (onClick) { if (onClick) {
onClick(); onClick();
// Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201 } else if (router.history.location?.state?.fromMastodon) {
// When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location
} else if (router.route.location.key) {
router.history.goBack(); router.history.goBack();
} else { } else {
router.history.push('/'); router.history.push('/');

View File

@ -63,10 +63,12 @@ class ColumnHeader extends PureComponent {
}; };
handleBackClick = () => { handleBackClick = () => {
if (window.history && window.history.state) { const { router } = this.context;
this.context.router.history.goBack();
if (router.history.location?.state?.fromMastodon) {
router.history.goBack();
} else { } else {
this.context.router.history.push('/'); router.history.push('/');
} }
}; };
@ -83,6 +85,7 @@ class ColumnHeader extends PureComponent {
}; };
render () { render () {
const { router } = this.context;
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
const { collapsed, animating } = this.state; const { collapsed, animating } = this.state;
@ -126,7 +129,7 @@ class ColumnHeader extends PureComponent {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
} }
if (!pinned && (multiColumn || showBackButton)) { if (!pinned && ((multiColumn && router.history.location?.state?.fromMastodon) || showBackButton)) {
backButton = ( backButton = (
<button onClick={this.handleBackClick} className='column-header__back-button'> <button onClick={this.handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />

View File

@ -1,16 +1,26 @@
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import React from 'react'; import React from 'react';
import type { History } from 'history';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { Router as OriginalRouter } from 'react-router'; import { Router as OriginalRouter } from 'react-router';
import { layoutFromWindow } from 'mastodon/is_mobile'; import { layoutFromWindow } from 'mastodon/is_mobile';
const browserHistory = createBrowserHistory(); interface MastodonLocationState {
const originalPush = browserHistory.push.bind(browserHistory); fromMastodon?: boolean;
mastodonModalKey?: string;
}
const browserHistory = createBrowserHistory<
MastodonLocationState | undefined
>();
const originalPush = browserHistory.push.bind(browserHistory);
const originalReplace = browserHistory.replace.bind(browserHistory);
browserHistory.push = (path: string, state?: MastodonLocationState) => {
state = state ?? {};
state.fromMastodon = true;
browserHistory.push = (path: string, state: History.LocationState) => {
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) { if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
originalPush(`/deck${path}`, state); originalPush(`/deck${path}`, state);
} else { } else {
@ -18,6 +28,19 @@ browserHistory.push = (path: string, state: History.LocationState) => {
} }
}; };
browserHistory.replace = (path: string, state?: MastodonLocationState) => {
if (browserHistory.location.state?.fromMastodon) {
state = state ?? {};
state.fromMastodon = true;
}
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
originalReplace(`/deck${path}`, state);
} else {
originalReplace(path, state);
}
};
export const Router: React.FC<PropsWithChildren> = ({ children }) => { export const Router: React.FC<PropsWithChildren> = ({ children }) => {
return <OriginalRouter history={browserHistory}>{children}</OriginalRouter>; return <OriginalRouter history={browserHistory}>{children}</OriginalRouter>;
}; };

View File

@ -479,10 +479,12 @@ class UI extends PureComponent {
}; };
handleHotkeyBack = () => { handleHotkeyBack = () => {
if (window.history && window.history.state) { const { router } = this.context;
this.context.router.history.goBack();
if (router.history.location?.state?.fromMastodon) {
router.history.goBack();
} else { } else {
this.context.router.history.push('/'); router.history.push('/');
} }
}; };

View File

@ -205,6 +205,26 @@ class FeedManager
end end
end end
# Remove a tag's statuses from a home feed
# @param [Tag] from_tag
# @param [Account] into_account
# @return [void]
def unmerge_tag_from_home(from_tag, into_account)
timeline_key = key(:home, into_account.id)
timeline_status_ids = redis.zrange(timeline_key, 0, -1)
# This is a bit tricky because we need posts tagged with this hashtag that are not
# also tagged with another followed hashtag or from a followed user
scope = from_tag.statuses
.where(id: timeline_status_ids)
.where.not(account: into_account.following)
.tagged_with_none(TagFollow.where(account: into_account).pluck(:tag_id))
scope.select('id, reblog_of_id').reorder(nil).find_each do |status|
remove_from_feed(:home, into_account.id, status, aggregate_reblogs: into_account.user&.aggregates_reblogs?)
end
end
# Clear all statuses from or mentioning target_account from a home feed # Clear all statuses from or mentioning target_account from a home feed
# @param [Account] account # @param [Account] account
# @param [Account] target_account # @param [Account] target_account

View File

@ -8,6 +8,8 @@ class ActivityPub::ProcessCollectionService < BaseService
@json = original_json = Oj.load(body, mode: :strict) @json = original_json = Oj.load(body, mode: :strict)
@options = options @options = options
return unless @json.is_a?(Hash)
begin begin
@json = compact(@json) if @json['signature'].is_a?(Hash) @json = compact(@json) if @json['signature'].is_a?(Hash)
rescue JSON::LD::JsonLdError => e rescue JSON::LD::JsonLdError => e

View File

@ -10,4 +10,4 @@
= opengraph 'og:image:width', '400' = opengraph 'og:image:width', '400'
= opengraph 'og:image:height', '400' = opengraph 'og:image:height', '400'
= opengraph 'twitter:card', 'summary' = opengraph 'twitter:card', 'summary'
= opengraph 'profile:username', acct(account)[1..-1] = opengraph 'profile:username', acct(account)[1..]

View File

@ -9,7 +9,7 @@
= f.input :domain, wrapper: :with_label, label: t('admin.domain_blocks.domain'), hint: t('admin.domain_blocks.new.hint'), required: true, readonly: true, disabled: true = f.input :domain, wrapper: :with_label, label: t('admin.domain_blocks.domain'), hint: t('admin.domain_blocks.new.hint'), required: true, readonly: true, disabled: true
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| t("admin.domain_blocks.new.severity.#{type}") }, hint: t('admin.domain_blocks.new.severity.desc_html') = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false, label_method: ->(type) { t("admin.domain_blocks.new.severity.#{type}") }, hint: t('admin.domain_blocks.new.severity.desc_html')
.fields-group .fields-group
= f.input :reject_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_media'), hint: I18n.t('admin.domain_blocks.reject_media_hint') = f.input :reject_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_media'), hint: I18n.t('admin.domain_blocks.reject_media_hint')

View File

@ -9,7 +9,7 @@
= f.input :domain, wrapper: :with_label, label: t('admin.domain_blocks.domain'), hint: t('.hint'), required: true = f.input :domain, wrapper: :with_label, label: t('admin.domain_blocks.domain'), hint: t('.hint'), required: true
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| t(".severity.#{type}") }, hint: t('.severity.desc_html') = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false, label_method: ->(type) { t(".severity.#{type}") }, hint: t('.severity.desc_html')
.fields-group .fields-group
= f.input :reject_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_media'), hint: I18n.t('admin.domain_blocks.reject_media_hint') = f.input :reject_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_media'), hint: I18n.t('admin.domain_blocks.reject_media_hint')

View File

@ -8,10 +8,10 @@
= f.input :ip, as: :string, wrapper: :with_block_label, input_html: { placeholder: '192.0.2.0/24' } = f.input :ip, as: :string, wrapper: :with_block_label, input_html: { placeholder: '192.0.2.0/24' }
.fields-group .fields-group
= f.input :expires_in, wrapper: :with_block_label, collection: [1.day, 2.weeks, 1.month, 6.months, 1.year, 3.years].map(&:to_i), label_method: lambda { |i| I18n.t("admin.ip_blocks.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') = f.input :expires_in, wrapper: :with_block_label, collection: [1.day, 2.weeks, 1.month, 6.months, 1.year, 3.years].map(&:to_i), label_method: ->(i) { I18n.t("admin.ip_blocks.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
.fields-group .fields-group
= f.input :severity, as: :radio_buttons, collection: IpBlock.severities.keys, include_blank: false, wrapper: :with_block_label, label_method: lambda { |severity| safe_join([I18n.t("simple_form.labels.ip_block.severities.#{severity}"), content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint')]) } = f.input :severity, as: :radio_buttons, collection: IpBlock.severities.keys, include_blank: false, wrapper: :with_block_label, label_method: ->(severity) { safe_join([I18n.t("simple_form.labels.ip_block.severities.#{severity}"), content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint')]) }
.fields-group .fields-group
= f.input :comment, as: :string, wrapper: :with_block_label = f.input :comment, as: :string, wrapper: :with_block_label

View File

@ -32,7 +32,7 @@
- (@role.everyone? ? UserRole::Flags::CATEGORIES.slice(:invites) : UserRole::Flags::CATEGORIES).each do |category, permissions| - (@role.everyone? ? UserRole::Flags::CATEGORIES.slice(:invites) : UserRole::Flags::CATEGORIES).each do |category, permissions|
%h4= t(category, scope: 'admin.roles.categories') %h4= t(category, scope: 'admin.roles.categories')
= f.input :permissions_as_keys, collection: permissions, wrapper: :with_block_label, include_blank: false, label_method: lambda { |privilege| safe_join([t("admin.roles.privileges.#{privilege}"), content_tag(:span, t("admin.roles.privileges.#{privilege}_description"), class: 'hint')]) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label: false, hint: false, disabled: permissions.filter { |privilege| UserRole::FLAGS[privilege] & current_user.role.computed_permissions == 0 } = f.input :permissions_as_keys, collection: permissions, wrapper: :with_block_label, include_blank: false, label_method: ->(privilege) { safe_join([t("admin.roles.privileges.#{privilege}"), content_tag(:span, t("admin.roles.privileges.#{privilege}_description"), class: 'hint')]) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label: false, hint: false, disabled: permissions.filter { |privilege| UserRole::FLAGS[privilege] & current_user.role.computed_permissions == 0 }
%hr.spacer/ %hr.spacer/

View File

@ -19,9 +19,9 @@
.fields-row .fields-row
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :show_domain_blocks, wrapper: :with_label, collection: %i(disabled users all), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' = f.input :show_domain_blocks, wrapper: :with_label, collection: %i(disabled users all), label_method: ->(value) { t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :show_domain_blocks_rationale, wrapper: :with_label, collection: %i(disabled users all), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' = f.input :show_domain_blocks_rationale, wrapper: :with_label, collection: %i(disabled users all), label_method: ->(value) { t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
.fields-group .fields-group
= f.input :status_page_url, wrapper: :with_block_label, input_html: { placeholder: "https://status.#{Rails.configuration.x.local_domain}" } = f.input :status_page_url, wrapper: :with_block_label, input_html: { placeholder: "https://status.#{Rails.configuration.x.local_domain}" }

View File

@ -11,7 +11,7 @@
%p.lead= t('admin.settings.appearance.preamble') %p.lead= t('admin.settings.appearance.preamble')
.fields-group .fields-group
= f.input :flavour_and_skin, collection: Themes.instance.flavours_and_skins, group_label_method: lambda { |(flavour, _)| I18n.t("flavours.#{flavour}.name", default: flavour) }, wrapper: :with_label, label: t('admin.settings.flavour_and_skin.title'), include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last = f.input :flavour_and_skin, collection: Themes.instance.flavours_and_skins, group_label_method: -> (flavour_and_skin) { I18n.t("flavours.#{flavour_and_skin}.name", default: flavour_and_skin) }, wrapper: :with_label, label: t('admin.settings.flavour_and_skin.title'), include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last
.fields-group .fields-group
= f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 } = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }

View File

@ -12,7 +12,7 @@
.fields-row .fields-row
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :registrations_mode, collection: %w(open approved none), wrapper: :with_label, include_blank: false, label_method: lambda { |mode| I18n.t("admin.settings.registrations_mode.modes.#{mode}") } = f.input :registrations_mode, collection: %w(open approved none), wrapper: :with_label, include_blank: false, label_method: ->(mode) { I18n.t("admin.settings.registrations_mode.modes.#{mode}") }
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :require_invite_text, as: :boolean, wrapper: :with_label, disabled: !approved_registrations? = f.input :require_invite_text, as: :boolean, wrapper: :with_label, disabled: !approved_registrations?

View File

@ -2,10 +2,10 @@
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :title, as: :string, wrapper: :with_label, hint: false = f.input :title, as: :string, wrapper: :with_label, hint: false
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt') = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: ->(i) { I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt')
.fields-group .fields-group
= f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: ->(context) { I18n.t("filters.contexts.#{context}") }, include_blank: false
%hr.spacer/ %hr.spacer/

View File

@ -3,9 +3,9 @@
.fields-row .fields-row
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: ->(num) { I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt')
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: ->(i) { I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
.fields-group .fields-group
= f.input :autofollow, wrapper: :with_label = f.input :autofollow, wrapper: :with_label

View File

@ -15,4 +15,4 @@
%span.hint= t('simple_form.hints.defaults.scopes') %span.hint= t('simple_form.hints.defaults.scopes')
- Doorkeeper.configuration.scopes.group_by { |s| s.split(':').first }.each do |k, v| - Doorkeeper.configuration.scopes.group_by { |s| s.split(':').first }.each do |k, v|
= f.input :scopes, label: false, hint: false, collection: v.sort, wrapper: :with_block_label, include_blank: false, label_method: lambda { |scope| safe_join([content_tag(:samp, scope, class: class_for_scope(scope)), content_tag(:span, t("doorkeeper.scopes.#{scope}"), class: 'hint')]) }, selected: f.object.scopes.all, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' = f.input :scopes, label: false, hint: false, collection: v.sort, wrapper: :with_block_label, include_blank: false, label_method: ->(scope) { safe_join([content_tag(:samp, scope, class: class_for_scope(scope)), content_tag(:span, t("doorkeeper.scopes.#{scope}"), class: 'hint')]) }, selected: f.object.scopes.all, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'

View File

@ -1,13 +1,13 @@
- content_for :page_title do - content_for :page_title do
= t("imports.titles.#{@bulk_import.type.to_s}") = t("imports.titles.#{@bulk_import.type}")
- if @bulk_import.likely_mismatched? - if @bulk_import.likely_mismatched?
.flash-message.warning= t("imports.mismatched_types_warning") .flash-message.warning= t('imports.mismatched_types_warning')
- if @bulk_import.overwrite? - if @bulk_import.overwrite?
%p.hint= t("imports.overwrite_preambles.#{@bulk_import.type.to_s}_html", filename: @bulk_import.original_filename, total_items: @bulk_import.total_items) %p.hint= t("imports.overwrite_preambles.#{@bulk_import.type}_html", filename: @bulk_import.original_filename, total_items: @bulk_import.total_items)
- else - else
%p.hint= t("imports.preambles.#{@bulk_import.type.to_s}_html", filename: @bulk_import.original_filename, total_items: @bulk_import.total_items) %p.hint= t("imports.preambles.#{@bulk_import.type}_html", filename: @bulk_import.original_filename, total_items: @bulk_import.total_items)
.simple_form .simple_form
.actions .actions

View File

@ -7,7 +7,7 @@
= simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f| = simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f|
.fields-row .fields-row
.fields-group.fields-row__column.fields-row__column-6 .fields-group.fields-row__column.fields-row__column-6
= f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, selected: I18n.locale, hint: false = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: ->(locale) { native_locale_name(locale) }, selected: I18n.locale, hint: false
.fields-group.fields-row__column.fields-row__column-6 .fields-group.fields-row__column.fields-row__column-6
= f.input :time_zone, wrapper: :with_label, collection: ActiveSupport::TimeZone.all.map { |tz| ["(GMT#{tz.formatted_offset}) #{tz.name}", tz.tzinfo.name] }, hint: false = f.input :time_zone, wrapper: :with_label, collection: ActiveSupport::TimeZone.all.map { |tz| ["(GMT#{tz.formatted_offset}) #{tz.name}", tz.tzinfo.name] }, hint: false
@ -58,7 +58,7 @@
%h4= t 'appearance.sensitive_content' %h4= t 'appearance.sensitive_content'
.fields-group .fields-group
= ff.input :'web.display_media', collection: ['default', 'show_all', 'hide_all'],label_method: lambda { |item| t("simple_form.hints.defaults.setting_display_media_#{item}") }, hint: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', wrapper: :with_floating_label, label: I18n.t('simple_form.labels.defaults.setting_display_media') = ff.input :'web.display_media', collection: ['default', 'show_all', 'hide_all'], label_method: ->(item) { t("simple_form.hints.defaults.setting_display_media_#{item}") }, hint: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', wrapper: :with_floating_label, label: I18n.t('simple_form.labels.defaults.setting_display_media')
.fields-group .fields-group
= ff.input :'web.use_blurhash', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_use_blurhash'), hint: I18n.t('simple_form.hints.defaults.setting_use_blurhash') = ff.input :'web.use_blurhash', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_use_blurhash'), hint: I18n.t('simple_form.hints.defaults.setting_use_blurhash')

View File

@ -22,10 +22,10 @@
.fields-row .fields-row
.fields-group.fields-row__column.fields-row__column-6 .fields-group.fields-row__column.fields-row__column-6
= ff.input :default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_privacy') = ff.input :default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: ->(visibility) { safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_privacy')
.fields-group.fields-row__column.fields-row__column-6 .fields-group.fields-row__column.fields-row__column-6
= ff.input :default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_language') = ff.input :default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: ->(locale) { locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_language')
.fields-group .fields-group
= ff.input :default_sensitive, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_default_sensitive'), hint: I18n.t('simple_form.hints.defaults.setting_default_sensitive') = ff.input :default_sensitive, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_default_sensitive'), hint: I18n.t('simple_form.hints.defaults.setting_default_sensitive')
@ -39,7 +39,7 @@
%h4= t 'preferences.public_timelines' %h4= t 'preferences.public_timelines'
.fields-group .fields-group
= f.input :chosen_languages, collection: filterable_languages, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' = f.input :chosen_languages, collection: filterable_languages, wrapper: :with_block_label, include_blank: false, label_method: ->(locale) { native_locale_name(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
.actions .actions
= f.button :button, t('generic.save_changes'), type: :submit = f.button :button, t('generic.save_changes'), type: :submit

View File

@ -17,7 +17,7 @@
%span.poll__voted %span.poll__voted
%i.poll__voted__mark.fa.fa-check %i.poll__voted__mark.fa.fa-check
%progress{ max: 100, value: percent < 1 ? 1 : percent, 'aria-hidden': 'true' } %progress{ max: 100, value: [percent, 1].max, 'aria-hidden': 'true' }
%span.poll__chart %span.poll__chart
- else - else
%label.poll__option>< %label.poll__option><

View File

@ -13,7 +13,7 @@
= opengraph 'og:title', "#{display_name(@account)} (#{acct(@account)})" = opengraph 'og:title', "#{display_name(@account)} (#{acct(@account)})"
= opengraph 'og:url', short_account_status_url(@account, @status) = opengraph 'og:url', short_account_status_url(@account, @status)
= opengraph 'og:published_time', @status.created_at.iso8601 = opengraph 'og:published_time', @status.created_at.iso8601
= opengraph 'profile:username', acct(@account)[1..-1] = opengraph 'profile:username', acct(@account)[1..]
= render 'og_description', activity: @status = render 'og_description', activity: @status
= render 'og_image', activity: @status, account: @account = render 'og_image', activity: @status, account: @account

View File

@ -10,7 +10,7 @@
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :enabled, as: :boolean, wrapper: :with_label, label: t('statuses_cleanup.enabled'), hint: t('statuses_cleanup.enabled_hint') = f.input :enabled, as: :boolean, wrapper: :with_label, label: t('statuses_cleanup.enabled'), hint: t('statuses_cleanup.enabled_hint')
.fields-row__column.fields-row__column-6.fields-group .fields-row__column.fields-row__column-6.fields-group
= f.input :min_status_age, wrapper: :with_label, label: t('statuses_cleanup.min_age_label'), collection: AccountStatusesCleanupPolicy::ALLOWED_MIN_STATUS_AGE.map(&:to_i), label_method: lambda { |i| t("statuses_cleanup.min_age.#{i}") }, include_blank: false, hint: false = f.input :min_status_age, wrapper: :with_label, label: t('statuses_cleanup.min_age_label'), collection: AccountStatusesCleanupPolicy::ALLOWED_MIN_STATUS_AGE.map(&:to_i), label_method: ->(i) { t("statuses_cleanup.min_age.#{i}") }, include_blank: false, hint: false
.flash-message= t('statuses_cleanup.explanation') .flash-message= t('statuses_cleanup.explanation')

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class TagUnmergeWorker
include Sidekiq::Worker
include DatabaseHelper
sidekiq_options queue: 'pull'
def perform(from_tag_id, into_account_id)
with_primary do
@from_tag = Tag.find(from_tag_id)
@into_account = Account.find(into_account_id)
end
with_read_replica do
FeedManager.instance.unmerge_tag_from_home(@from_tag, @into_account)
end
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -1,37 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::V1::PollsController do
render_views
let(:user) { Fabricate(:user) }
let(:scopes) { 'read:statuses' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
before { allow(controller).to receive(:doorkeeper_token) { token } }
describe 'GET #show' do
let(:poll) { Fabricate(:poll, status: Fabricate(:status, visibility: visibility)) }
before do
get :show, params: { id: poll.id }
end
context 'when parent status is public' do
let(:visibility) { 'public' }
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
context 'when parent status is private' do
let(:visibility) { 'private' }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
end

View File

@ -1,113 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::V1::Statuses::BookmarksController do
render_views
let(:user) { Fabricate(:user) }
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:bookmarks', application: app) }
context 'with an oauth token' do
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'POST #create' do
let(:status) { Fabricate(:status, account: user.account) }
before do
post :create, params: { status_id: status.id }
end
context 'with public status' do
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'updates the bookmarked attribute' do
expect(user.account.bookmarked?(status)).to be true
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:bookmarked]).to be true
end
end
context 'with private status of not-followed account' do
let(:status) { Fabricate(:status, visibility: :private) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'POST #destroy' do
context 'with public status' do
let(:status) { Fabricate(:status, account: user.account) }
before do
Bookmark.find_or_create_by!(account: user.account, status: status)
post :destroy, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'updates the bookmarked attribute' do
expect(user.account.bookmarked?(status)).to be false
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:bookmarked]).to be false
end
end
context 'with public status when blocked by its author' do
let(:status) { Fabricate(:status) }
before do
Bookmark.find_or_create_by!(account: user.account, status: status)
status.account.block!(user.account)
post :destroy, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the bookmarked attribute' do
expect(user.account.bookmarked?(status)).to be false
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:bookmarked]).to be false
end
end
context 'with private status that was not bookmarked' do
let(:status) { Fabricate(:status, visibility: :private) }
before do
post :destroy, params: { status_id: status.id }
end
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
end
end

View File

@ -1,123 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::V1::Statuses::FavouritesController do
render_views
let(:user) { Fabricate(:user) }
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:favourites', application: app) }
context 'with an oauth token' do
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'POST #create' do
let(:status) { Fabricate(:status, account: user.account) }
before do
post :create, params: { status_id: status.id }
end
context 'with public status' do
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the favourites count' do
expect(status.favourites.count).to eq 1
end
it 'updates the favourited attribute' do
expect(user.account.favourited?(status)).to be true
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:favourites_count]).to eq 1
expect(hash_body[:favourited]).to be true
end
end
context 'with private status of not-followed account' do
let(:status) { Fabricate(:status, visibility: :private) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'POST #destroy' do
context 'with public status' do
let(:status) { Fabricate(:status, account: user.account) }
before do
FavouriteService.new.call(user.account, status)
post :destroy, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the favourites count' do
expect(status.favourites.count).to eq 0
end
it 'updates the favourited attribute' do
expect(user.account.favourited?(status)).to be false
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:favourites_count]).to eq 0
expect(hash_body[:favourited]).to be false
end
end
context 'with public status when blocked by its author' do
let(:status) { Fabricate(:status) }
before do
FavouriteService.new.call(user.account, status)
status.account.block!(user.account)
post :destroy, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the favourite attribute' do
expect(user.account.favourited?(status)).to be false
end
it 'returns json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:favourited]).to be false
end
end
context 'with private status that was not favourited' do
let(:status) { Fabricate(:status, visibility: :private) }
before do
post :destroy, params: { status_id: status.id }
end
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
end
end

View File

@ -1,57 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::V1::Statuses::PinsController do
render_views
let(:user) { Fabricate(:user) }
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:accounts', application: app) }
context 'with an oauth token' do
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'POST #create' do
let(:status) { Fabricate(:status, account: user.account) }
before do
post :create, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the pinned attribute' do
expect(user.account.pinned?(status)).to be true
end
it 'return json with updated attributes' do
hash_body = body_as_json
expect(hash_body[:id]).to eq status.id.to_s
expect(hash_body[:pinned]).to be true
end
end
describe 'POST #destroy' do
let(:status) { Fabricate(:status, account: user.account) }
before do
Fabricate(:status_pin, status: status, account: user.account)
post :destroy, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the pinned attribute' do
expect(user.account.pinned?(status)).to be false
end
end
end
end

View File

@ -1,44 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::V1::Timelines::HomeController do
render_views
let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
context 'with a user context' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') }
describe 'GET #show' do
before do
follow = Fabricate(:follow, account: user.account)
PostStatusService.new.call(follow.target_account, text: 'New status for user home timeline.')
end
it 'returns http success' do
get :show
expect(response).to have_http_status(200)
expect(response.headers['Link'].links.size).to eq(2)
end
end
end
context 'without a user context' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: 'read') }
describe 'GET #show' do
it 'returns http unprocessable entity' do
get :show
expect(response).to have_http_status(422)
expect(response.headers['Link']).to be_nil
end
end
end
end

View File

@ -2,27 +2,34 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Api::V1::Emails::ConfirmationsController do RSpec.describe 'Confirmations' do
let(:confirmed_at) { nil } let(:confirmed_at) { nil }
let(:user) { Fabricate(:user, confirmed_at: confirmed_at) } let(:user) { Fabricate(:user, confirmed_at: confirmed_at) }
let(:app) { Fabricate(:application) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes, application: app) } let(:scopes) { 'read:accounts write:accounts' }
let(:scopes) { 'write' } let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'POST /api/v1/emails/confirmations' do
subject do
post '/api/v1/emails/confirmations', headers: headers, params: params
end
let(:params) { {} }
it_behaves_like 'forbidden for wrong scope', 'read read:accounts'
describe '#create' do
context 'with an oauth token' do context 'with an oauth token' do
before do context 'when user was created by a different application' do
allow(controller).to receive(:doorkeeper_token) { token } let(:user) { Fabricate(:user, confirmed_at: confirmed_at, created_by_application: Fabricate(:application)) }
end
context 'when from a random app' do
it 'returns http forbidden' do it 'returns http forbidden' do
post :create subject
expect(response).to have_http_status(403) expect(response).to have_http_status(403)
end end
end end
context 'when from an app that created the account' do context 'when user was created by the same application' do
before do before do
user.update(created_by_application: token.application) user.update(created_by_application: token.application)
end end
@ -31,55 +38,79 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do
let(:confirmed_at) { Time.now.utc } let(:confirmed_at) { Time.now.utc }
it 'returns http forbidden' do it 'returns http forbidden' do
post :create subject
expect(response).to have_http_status(403) expect(response).to have_http_status(403)
end end
context 'with user changed e-mail and has not confirmed it' do context 'when user changed e-mail and has not confirmed it' do
before do before do
user.update(email: 'foo@bar.com') user.update(email: 'foo@bar.com')
end end
it 'returns http success' do it 'returns http success' do
post :create subject
expect(response).to have_http_status(:success)
expect(response).to have_http_status(200)
end end
end end
end end
context 'when the account is unconfirmed' do context 'when the account is unconfirmed' do
it 'returns http success' do it 'returns http success' do
post :create subject
expect(response).to have_http_status(:success)
expect(response).to have_http_status(200)
end
end
context 'with email param' do
let(:params) { { email: 'foo@bar.com' } }
it "updates the user's e-mail address", :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.reload.unconfirmed_email).to eq('foo@bar.com')
end
end
context 'with invalid email param' do
let(:params) { { email: 'invalid' } }
it 'returns http unprocessable entity' do
subject
expect(response).to have_http_status(422)
end end
end end
end end
end end
context 'without an oauth token' do context 'without an oauth token' do
let(:headers) { {} }
it 'returns http unauthorized' do it 'returns http unauthorized' do
post :create subject
expect(response).to have_http_status(401) expect(response).to have_http_status(401)
end end
end end
end end
describe '#check' do describe 'GET /api/v1/emails/check_confirmation' do
let(:scopes) { 'read' } subject do
get '/api/v1/emails/check_confirmation', headers: headers
end
it_behaves_like 'forbidden for wrong scope', 'write'
context 'with an oauth token' do context 'with an oauth token' do
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
context 'when the account is not confirmed' do context 'when the account is not confirmed' do
it 'returns http success' do it 'returns the confirmation status successfully', :aggregate_failures do
get :check subject
expect(response).to have_http_status(200)
end
it 'returns false' do expect(response).to have_http_status(200)
get :check
expect(body_as_json).to be false expect(body_as_json).to be false
end end
end end
@ -87,31 +118,27 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do
context 'when the account is confirmed' do context 'when the account is confirmed' do
let(:confirmed_at) { Time.now.utc } let(:confirmed_at) { Time.now.utc }
it 'returns http success' do it 'returns the confirmation status successfully', :aggregate_failures do
get :check subject
expect(response).to have_http_status(200)
end
it 'returns true' do expect(response).to have_http_status(200)
get :check
expect(body_as_json).to be true expect(body_as_json).to be true
end end
end end
end end
context 'with an authentication cookie' do context 'with an authentication cookie' do
let(:headers) { {} }
before do before do
sign_in user, scope: :user sign_in user, scope: :user
end end
context 'when the account is not confirmed' do context 'when the account is not confirmed' do
it 'returns http success' do it 'returns the confirmation status successfully', :aggregate_failures do
get :check subject
expect(response).to have_http_status(200)
end
it 'returns false' do expect(response).to have_http_status(200)
get :check
expect(body_as_json).to be false expect(body_as_json).to be false
end end
end end
@ -119,21 +146,20 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do
context 'when the account is confirmed' do context 'when the account is confirmed' do
let(:confirmed_at) { Time.now.utc } let(:confirmed_at) { Time.now.utc }
it 'returns http success' do it 'returns the confirmation status successfully', :aggregate_failures do
get :check subject
expect(response).to have_http_status(200)
end
it 'returns true' do expect(response).to have_http_status(200)
get :check
expect(body_as_json).to be true expect(body_as_json).to be true
end end
end end
end end
context 'without an oauth token and an authentication cookie' do context 'without an oauth token and an authentication cookie' do
let(:headers) { {} }
it 'returns http unauthorized' do it 'returns http unauthorized' do
get :check subject
expect(response).to have_http_status(401) expect(response).to have_http_status(401)
end end

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Polls' do
let(:user) { Fabricate(:user) }
let(:scopes) { 'read:statuses' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'GET /api/v1/polls/:id' do
subject do
get "/api/v1/polls/#{poll.id}", headers: headers
end
let(:poll) { Fabricate(:poll, status: Fabricate(:status, visibility: visibility)) }
let(:visibility) { 'public' }
it_behaves_like 'forbidden for wrong scope', 'write write:statuses'
context 'when parent status is public' do
it 'returns the poll data successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_as_json).to match(
a_hash_including(
id: poll.id.to_s,
voted: false,
voters_count: poll.voters_count,
votes_count: poll.votes_count
)
)
end
end
context 'when parent status is private' do
let(:visibility) { 'private' }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
end

View File

@ -0,0 +1,155 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Bookmarks' do
let(:user) { Fabricate(:user) }
let(:scopes) { 'write:bookmarks' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'POST /api/v1/statuses/:status_id/bookmark' do
subject do
post "/api/v1/statuses/#{status.id}/bookmark", headers: headers
end
let(:status) { Fabricate(:status) }
it_behaves_like 'forbidden for wrong scope', 'read'
context 'with public status' do
it 'bookmarks the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.bookmarked?(status)).to be true
end
it 'returns json with updated attributes' do
subject
expect(body_as_json).to match(
a_hash_including(id: status.id.to_s, bookmarked: true)
)
end
end
context 'with private status of not-followed account' do
let(:status) { Fabricate(:status, visibility: :private) }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
context 'with private status of followed account' do
let(:status) { Fabricate(:status, visibility: :private) }
before do
user.account.follow!(status.account)
end
it 'bookmarks the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.bookmarked?(status)).to be true
end
end
context 'when the status does not exist' do
it 'returns http not found' do
post '/api/v1/statuses/-1/bookmark', headers: headers
expect(response).to have_http_status(404)
end
end
context 'without an authorization header' do
let(:headers) { {} }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
end
describe 'POST /api/v1/statuses/:status_id/unbookmark' do
subject do
post "/api/v1/statuses/#{status.id}/unbookmark", headers: headers
end
let(:status) { Fabricate(:status) }
it_behaves_like 'forbidden for wrong scope', 'read'
context 'with public status' do
context 'when the status was previously bookmarked' do
before do
Bookmark.find_or_create_by!(account: user.account, status: status)
end
it 'unbookmarks the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.bookmarked?(status)).to be false
end
it 'returns json with updated attributes' do
subject
expect(body_as_json).to match(
a_hash_including(id: status.id.to_s, bookmarked: false)
)
end
end
context 'when the requesting user was blocked by the status author' do
let(:status) { Fabricate(:status) }
before do
Bookmark.find_or_create_by!(account: user.account, status: status)
status.account.block!(user.account)
end
it 'unbookmarks the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.bookmarked?(status)).to be false
end
it 'returns json with updated attributes' do
subject
expect(body_as_json).to match(
a_hash_including(id: status.id.to_s, bookmarked: false)
)
end
end
context 'when the status is not bookmarked' do
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
end
end
context 'with private status that was not bookmarked' do
let(:status) { Fabricate(:status, visibility: :private) }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
end

View File

@ -0,0 +1,143 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Favourites' do
let(:user) { Fabricate(:user) }
let(:scopes) { 'write:favourites' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'POST /api/v1/statuses/:status_id/favourite' do
subject do
post "/api/v1/statuses/#{status.id}/favourite", headers: headers
end
let(:status) { Fabricate(:status) }
it_behaves_like 'forbidden for wrong scope', 'read read:favourites'
context 'with public status' do
it 'favourites the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.favourited?(status)).to be true
end
it 'returns json with updated attributes' do
subject
expect(body_as_json).to match(
a_hash_including(id: status.id.to_s, favourites_count: 1, favourited: true)
)
end
end
context 'with private status of not-followed account' do
let(:status) { Fabricate(:status, visibility: :private) }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
context 'with private status of followed account' do
let(:status) { Fabricate(:status, visibility: :private) }
before do
user.account.follow!(status.account)
end
it 'favourites the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.favourited?(status)).to be true
end
end
context 'without an authorization header' do
let(:headers) { {} }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
end
describe 'POST /api/v1/statuses/:status_id/unfavourite' do
subject do
post "/api/v1/statuses/#{status.id}/unfavourite", headers: headers
end
let(:status) { Fabricate(:status) }
it_behaves_like 'forbidden for wrong scope', 'read read:favourites'
context 'with public status' do
before do
FavouriteService.new.call(user.account, status)
end
it 'unfavourites the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.favourited?(status)).to be false
end
it 'returns json with updated attributes' do
subject
expect(body_as_json).to match(
a_hash_including(id: status.id.to_s, favourites_count: 0, favourited: false)
)
end
end
context 'when the requesting user was blocked by the status author' do
before do
FavouriteService.new.call(user.account, status)
status.account.block!(user.account)
end
it 'unfavourites the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.favourited?(status)).to be false
end
it 'returns json with updated attributes' do
subject
expect(body_as_json).to match(
a_hash_including(id: status.id.to_s, favourites_count: 0, favourited: false)
)
end
end
context 'when status is not favourited' do
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
end
context 'with private status that was not favourited' do
let(:status) { Fabricate(:status, visibility: :private) }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
end

View File

@ -0,0 +1,131 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Pins' do
let(:user) { Fabricate(:user) }
let(:scopes) { 'write:accounts' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'POST /api/v1/statuses/:status_id/pin' do
subject do
post "/api/v1/statuses/#{status.id}/pin", headers: headers
end
let(:status) { Fabricate(:status, account: user.account) }
it_behaves_like 'forbidden for wrong scope', 'read read:accounts'
context 'when the status is public' do
it 'pins the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.pinned?(status)).to be true
end
it 'return json with updated attributes' do
subject
expect(body_as_json).to match(
a_hash_including(id: status.id.to_s, pinned: true)
)
end
end
context 'when the status is private' do
let(:status) { Fabricate(:status, account: user.account, visibility: :private) }
it 'pins the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.pinned?(status)).to be true
end
end
context 'when the status belongs to somebody else' do
let(:status) { Fabricate(:status) }
it 'returns http unprocessable entity' do
subject
expect(response).to have_http_status(422)
end
end
context 'when the status does not exist' do
it 'returns http not found' do
post '/api/v1/statuses/-1/pin', headers: headers
expect(response).to have_http_status(404)
end
end
context 'without an authorization header' do
let(:headers) { {} }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
end
describe 'POST /api/v1/statuses/:status_id/unpin' do
subject do
post "/api/v1/statuses/#{status.id}/unpin", headers: headers
end
let(:status) { Fabricate(:status, account: user.account) }
context 'when the status is pinned' do
before do
Fabricate(:status_pin, status: status, account: user.account)
end
it 'unpins the status successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(user.account.pinned?(status)).to be false
end
it 'return json with updated attributes' do
subject
expect(body_as_json).to match(
a_hash_including(id: status.id.to_s, pinned: false)
)
end
end
context 'when the status is not pinned' do
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
end
context 'when the status does not exist' do
it 'returns http not found' do
post '/api/v1/statuses/-1/unpin', headers: headers
expect(response).to have_http_status(404)
end
end
context 'without an authorization header' do
let(:headers) { {} }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
end
end

View File

@ -0,0 +1,101 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Home' do
let(:user) { Fabricate(:user) }
let(:scopes) { 'read:statuses' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'GET /api/v1/timelines/home' do
subject do
get '/api/v1/timelines/home', headers: headers, params: params
end
let(:params) { {} }
it_behaves_like 'forbidden for wrong scope', 'write write:statuses'
context 'when the timeline is available' do
let(:home_statuses) { bob.statuses + ana.statuses }
let!(:bob) { Fabricate(:account) }
let!(:tim) { Fabricate(:account) }
let!(:ana) { Fabricate(:account) }
before do
user.account.follow!(bob)
user.account.follow!(ana)
PostStatusService.new.call(bob, text: 'New toot from bob.')
PostStatusService.new.call(tim, text: 'New toot from tim.')
PostStatusService.new.call(ana, text: 'New toot from ana.')
end
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'returns the statuses of followed users' do
subject
expect(body_as_json.pluck(:id)).to match_array(home_statuses.map { |status| status.id.to_s })
end
context 'with limit param' do
let(:params) { { limit: 1 } }
it 'returns only the requested number of statuses' do
subject
expect(body_as_json.size).to eq(params[:limit])
end
it 'sets the correct pagination headers', :aggregate_failures do
subject
headers = response.headers['Link']
expect(headers.find_link(%w(rel prev)).href).to eq(api_v1_timelines_home_url(limit: 1, min_id: ana.statuses.first.id.to_s))
expect(headers.find_link(%w(rel next)).href).to eq(api_v1_timelines_home_url(limit: 1, max_id: ana.statuses.first.id.to_s))
end
end
end
context 'when the timeline is regenerating' do
let(:timeline) { instance_double(HomeFeed, regenerating?: true, get: []) }
before do
allow(HomeFeed).to receive(:new).and_return(timeline)
end
it 'returns http partial content' do
subject
expect(response).to have_http_status(206)
end
end
context 'without an authorization header' do
let(:headers) { {} }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
context 'without a user context' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: scopes) }
it 'returns http unprocessable entity', :aggregate_failures do
subject
expect(response).to have_http_status(422)
expect(response.headers['Link']).to be_nil
end
end
end
end

View File

@ -34,11 +34,11 @@ RSpec.describe BatchedRemoveStatusService, type: :service do
end end
it 'removes statuses from author\'s home feed' do it 'removes statuses from author\'s home feed' do
expect(HomeFeed.new(alice).get(10)).to_not include([status_alice_hello.id, status_alice_other.id]) expect(HomeFeed.new(alice).get(10).pluck(:id)).to_not include(status_alice_hello.id, status_alice_other.id)
end end
it 'removes statuses from local follower\'s home feed' do it 'removes statuses from local follower\'s home feed' do
expect(HomeFeed.new(jeff).get(10)).to_not include([status_alice_hello.id, status_alice_other.id]) expect(HomeFeed.new(jeff).get(10).pluck(:id)).to_not include(status_alice_hello.id, status_alice_other.id)
end end
it 'notifies streaming API of followers' do it 'notifies streaming API of followers' do

View File

@ -28,12 +28,12 @@ RSpec.describe RemoveStatusService, type: :service do
it 'removes status from author\'s home feed' do it 'removes status from author\'s home feed' do
subject.call(@status) subject.call(@status)
expect(HomeFeed.new(alice).get(10)).to_not include(@status.id) expect(HomeFeed.new(alice).get(10).pluck(:id)).to_not include(@status.id)
end end
it 'removes status from local follower\'s home feed' do it 'removes status from local follower\'s home feed' do
subject.call(@status) subject.call(@status)
expect(HomeFeed.new(jeff).get(10)).to_not include(@status.id) expect(HomeFeed.new(jeff).get(10).pluck(:id)).to_not include(@status.id)
end end
it 'sends Delete activity to followers' do it 'sends Delete activity to followers' do

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'rails_helper'
describe TagUnmergeWorker do
subject { described_class.new }
describe 'perform' do
let(:follower) { Fabricate(:account) }
let(:followed) { Fabricate(:account) }
let(:followed_tag) { Fabricate(:tag) }
let(:unchanged_followed_tag) { Fabricate(:tag) }
let(:status_from_followed) { Fabricate(:status, created_at: 2.hours.ago, account: followed) }
let(:tagged_status) { Fabricate(:status, created_at: 1.hour.ago) }
let(:unchanged_tagged_status) { Fabricate(:status) }
before do
tagged_status.tags << followed_tag
unchanged_tagged_status.tags << followed_tag
unchanged_tagged_status.tags << unchanged_followed_tag
tag_follow = TagFollow.create_with(rate_limit: false).find_or_create_by!(tag: followed_tag, account: follower)
TagFollow.create_with(rate_limit: false).find_or_create_by!(tag: unchanged_followed_tag, account: follower)
FeedManager.instance.push_to_home(follower, status_from_followed, update: false)
FeedManager.instance.push_to_home(follower, tagged_status, update: false)
FeedManager.instance.push_to_home(follower, unchanged_tagged_status, update: false)
tag_follow.destroy!
end
it 'removes the expected status from the feed' do
expect { subject.perform(followed_tag.id, follower.id) }
.to change { HomeFeed.new(follower).get(10).pluck(:id) }
.from([unchanged_tagged_status.id, tagged_status.id, status_from_followed.id])
.to([unchanged_tagged_status.id, status_from_followed.id])
end
end
end

View File

@ -1279,17 +1279,17 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.42.0.tgz#484a1d638de2911e6f5a30c12f49c7e4a3270fb6" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.42.0.tgz#484a1d638de2911e6f5a30c12f49c7e4a3270fb6"
integrity sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw== integrity sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw==
"@floating-ui/core@^1.3.0": "@floating-ui/core@^1.3.1":
version "1.3.0" version "1.3.1"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.3.0.tgz#113bc85fa102cf890ae801668f43ee265c547a09" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.3.1.tgz#4d795b649cc3b1cbb760d191c80dcb4353c9a366"
integrity sha512-vX1WVAdPjZg9DkDkC+zEx/tKtnST6/qcNpwcjeBgco3XRNHz5PUA+ivi/yr6G3o0kMR60uKBJcfOdfzOFI7PMQ== integrity sha512-Bu+AMaXNjrpjh41znzHqaz3r2Nr8hHuHZT6V2LBKMhyMl0FgKA62PNYbqnfgmzOhoWZj70Zecisbo4H1rotP5g==
"@floating-ui/dom@^1.0.1": "@floating-ui/dom@^1.0.1":
version "1.3.0" version "1.4.5"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.3.0.tgz#69456f2164fc3d33eb40837686eaf71537235ac9" resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.4.5.tgz#336dfb9870c98b471ff5802002982e489b8bd1c5"
integrity sha512-qIAwejE3r6NeA107u4ELDKkH8+VtgRKdXqtSPaKflL2S2V+doyN+Wt9s5oHKXPDo4E8TaVXaHT3+6BbagH31xw== integrity sha512-96KnRWkRnuBSSFbj0sFGwwOUd8EkiecINVl0O9wiZlZ64EkpyAOG3Xc2vKKNJmru0Z7RqWNymA+6b8OZqjgyyw==
dependencies: dependencies:
"@floating-ui/core" "^1.3.0" "@floating-ui/core" "^1.3.1"
"@formatjs/cli@^6.1.1": "@formatjs/cli@^6.1.1":
version "6.1.3" version "6.1.3"
@ -9701,9 +9701,9 @@ react-router@^4.3.1:
warning "^4.0.1" warning "^4.0.1"
react-select@*, react-select@^5.7.3: react-select@*, react-select@^5.7.3:
version "5.7.3" version "5.7.4"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.3.tgz#fa0dc9a23cad6ff3871ad3829f6083a4b54961a2" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.4.tgz#d8cad96e7bc9d6c8e2709bdda8f4363c5dd7ea7d"
integrity sha512-z8i3NCuFFWL3w27xq92rBkVI2onT0jzIIPe480HlBjXJ3b5o6Q+Clp4ydyeKrj9DZZ3lrjawwLC5NGl0FSvUDg== integrity sha512-NhuE56X+p9QDFh4BgeygHFIvJJszO1i1KSkg/JPcIJrbovyRtI+GuOEa4XzFCEpZRAEoEI8u/cAHK+jG/PgUzQ==
dependencies: dependencies:
"@babel/runtime" "^7.12.0" "@babel/runtime" "^7.12.0"
"@emotion/cache" "^11.4.0" "@emotion/cache" "^11.4.0"