From 1889526e23ce41a37395fdcf89bd415bb2f7546f Mon Sep 17 00:00:00 2001 From: Matt Sweetman Date: Mon, 24 Sep 2018 04:44:01 +0100 Subject: Add user preference to always expand toots marked with content warnings (#8762) --- app/models/user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/models') diff --git a/app/models/user.rb b/app/models/user.rb index d83df28c2..541b5a638 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -96,7 +96,7 @@ class User < ApplicationRecord delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, :reduce_motion, :system_font_ui, :noindex, :theme, :display_sensitive_media, :hide_network, - :default_language, to: :settings, prefix: :setting, allow_nil: false + :expand_spoilers, :default_language, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code -- cgit From f7a6f9489da9b2a1820366654df47b8a52f5c5bc Mon Sep 17 00:00:00 2001 From: ふぁぼ原 Date: Tue, 25 Sep 2018 12:09:35 +0900 Subject: Add a new preference to always hide all media (#8569) --- app/controllers/settings/preferences_controller.rb | 2 +- .../mastodon/components/media_gallery.js | 4 +-- .../account_gallery/components/media_item.js | 4 +-- app/javascript/mastodon/features/video/index.js | 15 ++++++--- app/javascript/mastodon/initial_state.js | 2 +- app/lib/user_settings_decorator.rb | 36 +++++++++++----------- app/models/user.rb | 10 +++++- app/serializers/initial_state_serializer.rb | 16 +++++----- app/views/settings/preferences/show.html.haml | 2 +- .../stream_entries/_detailed_status.html.haml | 4 +-- app/views/stream_entries/_simple_status.html.haml | 4 +-- config/locales/simple_form.en.yml | 8 ++++- config/locales/simple_form.ja.yml | 8 ++++- config/settings.yml | 2 +- 14 files changed, 72 insertions(+), 45 deletions(-) (limited to 'app/models') diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index ab74ad9f2..b83900f07 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -40,7 +40,7 @@ class Settings::PreferencesController < ApplicationController :setting_boost_modal, :setting_delete_modal, :setting_auto_play_gif, - :setting_display_sensitive_media, + :setting_display_media, :setting_expand_spoilers, :setting_reduce_motion, :setting_system_font_ui, diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index a1785196f..ed0e4ff1b 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,7 +6,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; -import { autoPlayGif, displaySensitiveMedia } from '../initial_state'; +import { autoPlayGif, displayMedia } from '../initial_state'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, @@ -197,7 +197,7 @@ class MediaGallery extends React.PureComponent { }; state = { - visible: !this.props.sensitive || displaySensitiveMedia, + visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', }; componentWillReceiveProps (nextProps) { diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index f7a802dc7..7c330c430 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Permalink from '../../../components/permalink'; -import { displaySensitiveMedia } from '../../../initial_state'; +import { displayMedia } from '../../../initial_state'; export default class MediaItem extends ImmutablePureComponent { @@ -11,7 +11,7 @@ export default class MediaItem extends ImmutablePureComponent { }; state = { - visible: !this.props.media.getIn(['status', 'sensitive']) || displaySensitiveMedia, + visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all', }; handleClick = () => { diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index d17253957..67f7580b9 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -5,7 +5,7 @@ import { fromJS } from 'immutable'; import { throttle } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; -import { displaySensitiveMedia } from '../../initial_state'; +import { displayMedia } from '../../initial_state'; const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, @@ -111,7 +111,7 @@ class Video extends React.PureComponent { fullscreen: false, hovered: false, muted: false, - revealed: !this.props.sensitive || displaySensitiveMedia, + revealed: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', }; setPlayerRef = c => { @@ -272,7 +272,7 @@ class Video extends React.PureComponent { } render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed } = this.props; + const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props; const { containerWidth, currentTime, duration, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; const playerStyle = {}; @@ -296,6 +296,13 @@ class Video extends React.PureComponent { preload = 'none'; } + let warning; + if (sensitive) { + warning = ; + } else { + warning = ; + } + return (
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index e615de09c..262c93195 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -5,7 +5,7 @@ const getMeta = (prop) => initialState && initialState.meta && initialState.meta export const reduceMotion = getMeta('reduce_motion'); export const autoPlayGif = getMeta('auto_play_gif'); -export const displaySensitiveMedia = getMeta('display_sensitive_media'); +export const displayMedia = getMeta('display_media'); export const expandSpoilers = getMeta('expand_spoilers'); export const unfollowModal = getMeta('unfollow_modal'); export const boostModal = getMeta('boost_modal'); diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 7239dc194..40973c707 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -15,22 +15,22 @@ class UserSettingsDecorator private def process_update - user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails') - user.settings['interactions'] = merged_interactions if change?('interactions') - user.settings['default_privacy'] = default_privacy_preference if change?('setting_default_privacy') - user.settings['default_sensitive'] = default_sensitive_preference if change?('setting_default_sensitive') - user.settings['default_language'] = default_language_preference if change?('setting_default_language') - user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal') - user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal') - user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal') - user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif') - user.settings['display_sensitive_media'] = display_sensitive_media_preference if change?('setting_display_sensitive_media') - user.settings['expand_spoilers'] = expand_spoilers_preference if change?('setting_expand_spoilers') - user.settings['reduce_motion'] = reduce_motion_preference if change?('setting_reduce_motion') - user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') - user.settings['noindex'] = noindex_preference if change?('setting_noindex') - user.settings['theme'] = theme_preference if change?('setting_theme') - user.settings['hide_network'] = hide_network_preference if change?('setting_hide_network') + user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails') + user.settings['interactions'] = merged_interactions if change?('interactions') + user.settings['default_privacy'] = default_privacy_preference if change?('setting_default_privacy') + user.settings['default_sensitive'] = default_sensitive_preference if change?('setting_default_sensitive') + user.settings['default_language'] = default_language_preference if change?('setting_default_language') + user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal') + user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal') + user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal') + user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif') + user.settings['display_media'] = display_media_preference if change?('setting_display_media') + user.settings['expand_spoilers'] = expand_spoilers_preference if change?('setting_expand_spoilers') + user.settings['reduce_motion'] = reduce_motion_preference if change?('setting_reduce_motion') + user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') + user.settings['noindex'] = noindex_preference if change?('setting_noindex') + user.settings['theme'] = theme_preference if change?('setting_theme') + user.settings['hide_network'] = hide_network_preference if change?('setting_hide_network') end def merged_notification_emails @@ -69,8 +69,8 @@ class UserSettingsDecorator boolean_cast_setting 'setting_auto_play_gif' end - def display_sensitive_media_preference - boolean_cast_setting 'setting_display_sensitive_media' + def display_media_preference + settings['setting_display_media'] end def expand_spoilers_preference diff --git a/app/models/user.rb b/app/models/user.rb index 541b5a638..69fa0688a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -95,7 +95,7 @@ class User < ApplicationRecord has_many :session_activations, dependent: :destroy delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, - :reduce_motion, :system_font_ui, :noindex, :theme, :display_sensitive_media, :hide_network, + :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, :expand_spoilers, :default_language, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code @@ -316,6 +316,14 @@ class User < ApplicationRecord super end + def show_all_media? + setting_display_media == 'show_all' + end + + def hide_all_media? + setting_display_media == 'hide_all' + end + protected def send_devise_notification(notification, *args) diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 35b869e38..cdc470831 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -19,14 +19,14 @@ class InitialStateSerializer < ActiveModel::Serializer } if object.current_account - store[:me] = object.current_account.id.to_s - store[:unfollow_modal] = object.current_account.user.setting_unfollow_modal - store[:boost_modal] = object.current_account.user.setting_boost_modal - store[:delete_modal] = object.current_account.user.setting_delete_modal - store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif - store[:display_sensitive_media] = object.current_account.user.setting_display_sensitive_media - store[:expand_spoilers] = object.current_account.user.setting_expand_spoilers - store[:reduce_motion] = object.current_account.user.setting_reduce_motion + store[:me] = object.current_account.id.to_s + store[:unfollow_modal] = object.current_account.user.setting_unfollow_modal + store[:boost_modal] = object.current_account.user.setting_boost_modal + store[:delete_modal] = object.current_account.user.setting_delete_modal + store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif + store[:display_media] = object.current_account.user.setting_display_media + store[:expand_spoilers] = object.current_account.user.setting_expand_spoilers + store[:reduce_motion] = object.current_account.user.setting_reduce_motion end store diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index a7b1a52fc..608a290e7 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -47,7 +47,7 @@ .fields-group = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label - = f.input :setting_display_sensitive_media, as: :boolean, wrapper: :with_label + = f.input :setting_display_media, collection: ['default', 'show_all', 'hide_all'], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.defaults.setting_display_media_#{item}"), content_tag(:span, t("simple_form.hints.defaults.setting_display_media_#{item}"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' = f.input :setting_expand_spoilers, as: :boolean, wrapper: :with_label = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 77346dba7..6cedfb337 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -24,9 +24,9 @@ - if !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first - = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 670, height: 380, detailed: true, inline: true, alt: video.description + = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description - else - = react_component :media_gallery, height: 380, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } + = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } - elsif status.preview_cards.first = react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index 206cf0aec..7401f82c2 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -27,9 +27,9 @@ - unless status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first - = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 610, height: 343, inline: true, alt: video.description + = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description - else - = react_component :media_gallery, height: 343, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } + = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } .status__action-bar .status__action-bar__counter diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 5b1a07a4a..e3d84dd07 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -25,6 +25,9 @@ en: phrase: Will be matched regardless of casing in text or content warning of a toot scopes: Which APIs the application will be allowed to access. If you select a top-level scope, you don't need to select individual ones. setting_default_language: The language of your toots can be detected automatically, but it's not always accurate + setting_display_media_default: Hide media marked as sensitive + setting_display_media_hide_all: Always hide all media + setting_display_media_show_all: Always show media marked as sensitive setting_hide_network: Who you follow and who follows you will not be shown on your profile setting_noindex: Affects your public profile and status pages setting_theme: Affects how Mastodon looks when you're logged in from any device. @@ -72,7 +75,10 @@ en: setting_default_privacy: Post privacy setting_default_sensitive: Always mark media as sensitive setting_delete_modal: Show confirmation dialog before deleting a toot - setting_display_sensitive_media: Always show media marked as sensitive + setting_display_media: Media display + setting_display_media_default: Default + setting_display_media_hide_all: Hide all + setting_display_media_show_all: Show all setting_expand_spoilers: Always expand toots marked with content warnings setting_hide_network: Hide your network setting_noindex: Opt-out of search engine indexing diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index d96394d7a..3d9c50759 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -19,6 +19,9 @@ ja: phrase: トゥートの大文字小文字や閲覧注意に関係なく一致 scopes: アプリの API に許可するアクセス権を選択してください。最上位のスコープを選択する場合、個々のスコープを選択する必要はありません。 setting_default_language: トゥートの言語は自動的に検出されますが、必ずしも正確とは限りません + setting_display_media_default: 閲覧注意としてマークされたメディアは隠す + setting_display_media_hide_all: 全てのメディアを常に隠す + setting_display_media_show_all: 閲覧注意としてマークされたメディアも常に表示する setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします setting_noindex: 公開プロフィールおよび各投稿ページに影響します setting_theme: ログインしている全てのデバイスで適用されるデザインです。 @@ -65,7 +68,10 @@ ja: setting_default_privacy: 投稿の公開範囲 setting_default_sensitive: メディアを常に閲覧注意としてマークする setting_delete_modal: トゥートを削除する前に確認ダイアログを表示する - setting_display_sensitive_media: 閲覧注意としてマークされたメディアも常に表示する + setting_display_media: メディアの表示 + setting_display_media_default: 標準 + setting_display_media_hide_all: 非表示 + setting_display_media_show_all: 表示 setting_hide_network: 繋がりを隠す setting_noindex: 検索エンジンによるインデックスを拒否する setting_reduce_motion: アニメーションの動きを減らす diff --git a/config/settings.yml b/config/settings.yml index e2214b4b7..2bc9fe289 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -26,7 +26,7 @@ defaults: &defaults boost_modal: false delete_modal: true auto_play_gif: false - display_sensitive_media: false + display_media: 'default' expand_spoilers: false preview_sensitive_media: false reduce_motion: false -- cgit From f0fff3eb1051ff77ec3f37aa75f8c56720b626a3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 28 Sep 2018 02:23:45 +0200 Subject: Support min_id-based pagination in REST API (#8736) * Allow min_id pagination in Feed#get * Add min_id pagination to home and list timeline APIs * Add min_id pagination to account statuses, public and tag APIs * Remove unused stub in reports API * Use min_id pagination in notifications, favourites, and fix order * Fix HomeFeed#from_database not using paginate_by_id --- app/controllers/api/base_controller.rb | 4 ++++ app/controllers/api/v1/accounts/statuses_controller.rb | 7 +++---- app/controllers/api/v1/favourites_controller.rb | 7 +++---- app/controllers/api/v1/notifications_controller.rb | 7 +++---- app/controllers/api/v1/reports_controller.rb | 5 ----- app/controllers/api/v1/timelines/home_controller.rb | 5 +++-- app/controllers/api/v1/timelines/list_controller.rb | 5 +++-- app/controllers/api/v1/timelines/public_controller.rb | 7 +++---- app/controllers/api/v1/timelines/tag_controller.rb | 7 +++---- app/models/concerns/paginable.rb | 8 ++++++++ app/models/feed.rb | 16 ++++++++++------ app/models/home_feed.rb | 8 ++++---- config/routes.rb | 2 +- spec/controllers/api/v1/favourites_controller_spec.rb | 2 +- spec/controllers/api/v1/reports_controller_spec.rb | 10 ---------- 15 files changed, 49 insertions(+), 51 deletions(-) (limited to 'app/models') diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 90f42251e..ac8de5fc0 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -53,6 +53,10 @@ class Api::BaseController < ApplicationController [params[:limit].to_i.abs, default_limit * 2].min end + def params_slice(*keys) + params.slice(*keys).permit(*keys) + end + def current_resource_owner @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 06fa6c762..b68a8805f 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -28,10 +28,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses - statuses = statuses.paginate_by_max_id( + statuses = statuses.paginate_by_id( limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id] + params_slice(:max_id, :since_id, :min_id) ) statuses.merge!(only_media_scope) if truthy_param?(:only_media) @@ -82,7 +81,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def prev_path unless @statuses.empty? - api_v1_account_statuses_url pagination_params(since_id: pagination_since_id) + api_v1_account_statuses_url pagination_params(min_id: pagination_since_id) end end diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index ab5204355..db827f9d4 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -26,10 +26,9 @@ class Api::V1::FavouritesController < Api::BaseController end def results - @_results ||= account_favourites.paginate_by_max_id( + @_results ||= account_favourites.paginate_by_id( limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id] + params_slice(:max_id, :since_id, :min_id) ) end @@ -49,7 +48,7 @@ class Api::V1::FavouritesController < Api::BaseController def prev_path unless results.empty? - api_v1_favourites_url pagination_params(since_id: pagination_since_id) + api_v1_favourites_url pagination_params(min_id: pagination_since_id) end end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 593c8f9a9..e2dec62af 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -37,10 +37,9 @@ class Api::V1::NotificationsController < Api::BaseController end def paginated_notifications - browserable_account_notifications.paginate_by_max_id( + browserable_account_notifications.paginate_by_id( limit_param(DEFAULT_NOTIFICATIONS_LIMIT), - params[:max_id], - params[:since_id] + params_slice(:max_id, :since_id, :min_id) ) end @@ -64,7 +63,7 @@ class Api::V1::NotificationsController < Api::BaseController def prev_path unless @notifications.empty? - api_v1_notifications_url pagination_params(since_id: pagination_since_id) + api_v1_notifications_url pagination_params(min_id: pagination_since_id) end end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index a954101cb..9c6ee0a50 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -7,11 +7,6 @@ class Api::V1::ReportsController < Api::BaseController respond_to :json - def index - @reports = current_account.reports - render json: @reports, each_serializer: REST::ReportSerializer - end - def create @report = ReportService.new.call( current_account, diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 4412aaaa3..fcd0757f1 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -30,7 +30,8 @@ class Api::V1::Timelines::HomeController < Api::BaseController account_home_feed.get( limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], - params[:since_id] + params[:since_id], + params[:min_id] ) end @@ -51,7 +52,7 @@ class Api::V1::Timelines::HomeController < Api::BaseController end def prev_path - api_v1_timelines_home_url pagination_params(since_id: pagination_since_id) + api_v1_timelines_home_url pagination_params(min_id: pagination_since_id) end def pagination_max_id diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb index cfc5f3b5e..a15eae468 100644 --- a/app/controllers/api/v1/timelines/list_controller.rb +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -32,7 +32,8 @@ class Api::V1::Timelines::ListController < Api::BaseController list_feed.get( limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], - params[:since_id] + params[:since_id], + params[:min_id] ) end @@ -53,7 +54,7 @@ class Api::V1::Timelines::ListController < Api::BaseController end def prev_path - api_v1_timelines_list_url params[:id], pagination_params(since_id: pagination_since_id) + api_v1_timelines_list_url params[:id], pagination_params(min_id: pagination_since_id) end def pagination_max_id diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 13fe015b7..aabe24324 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -21,10 +21,9 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def public_statuses - statuses = public_timeline_statuses.paginate_by_max_id( + statuses = public_timeline_statuses.paginate_by_id( limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id] + params_slice(:max_id, :since_id, :min_id) ) if truthy_param?(:only_media) @@ -53,7 +52,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def prev_path - api_v1_timelines_public_url pagination_params(since_id: pagination_since_id) + api_v1_timelines_public_url pagination_params(min_id: pagination_since_id) end def pagination_max_id diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 7de49a5ed..cf58d5cf4 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -29,10 +29,9 @@ class Api::V1::Timelines::TagController < Api::BaseController if @tag.nil? [] else - statuses = tag_timeline_statuses.paginate_by_max_id( + statuses = tag_timeline_statuses.paginate_by_id( limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id] + params_slice(:max_id, :since_id, :min_id) ) if truthy_param?(:only_media) @@ -62,7 +61,7 @@ class Api::V1::Timelines::TagController < Api::BaseController end def prev_path - api_v1_timelines_tag_url params[:id], pagination_params(since_id: pagination_since_id) + api_v1_timelines_tag_url params[:id], pagination_params(min_id: pagination_since_id) end def pagination_max_id diff --git a/app/models/concerns/paginable.rb b/app/models/concerns/paginable.rb index 66695677e..52f186918 100644 --- a/app/models/concerns/paginable.rb +++ b/app/models/concerns/paginable.rb @@ -19,5 +19,13 @@ module Paginable query = query.where(arel_table[:id].gt(min_id)) if min_id.present? query } + + scope :paginate_by_id, ->(limit, **options) { + if options[:min_id].present? + paginate_by_min_id(limit, options[:min_id]).reverse + else + paginate_by_max_id(limit, options[:max_id], options[:since_id]) + end + } end end diff --git a/app/models/feed.rb b/app/models/feed.rb index d99f1ffb2..5bce88f25 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -6,16 +6,20 @@ class Feed @id = id end - def get(limit, max_id = nil, since_id = nil) - from_redis(limit, max_id, since_id) + def get(limit, max_id = nil, since_id = nil, min_id = nil) + from_redis(limit, max_id, since_id, min_id) end protected - def from_redis(limit, max_id, since_id) - max_id = '+inf' if max_id.blank? - since_id = '-inf' if since_id.blank? - unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i) + def from_redis(limit, max_id, since_id, min_id) + if min_id.blank? + max_id = '+inf' if max_id.blank? + since_id = '-inf' if since_id.blank? + unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i) + else + unhydrated = redis.zrangebyscore(key, "(#{min_id}", '+inf', limit: [0, limit], with_scores: true).map(&:first).map(&:to_i) + end Status.where(id: unhydrated).cache_ids end diff --git a/app/models/home_feed.rb b/app/models/home_feed.rb index b943a34ce..ba7564983 100644 --- a/app/models/home_feed.rb +++ b/app/models/home_feed.rb @@ -7,9 +7,9 @@ class HomeFeed < Feed @account = account end - def get(limit, max_id = nil, since_id = nil) + def get(limit, max_id = nil, since_id = nil, min_id = nil) if redis.exists("account:#{@account.id}:regeneration") - from_database(limit, max_id, since_id) + from_database(limit, max_id, since_id, min_id) else super end @@ -17,9 +17,9 @@ class HomeFeed < Feed private - def from_database(limit, max_id, since_id) + def from_database(limit, max_id, since_id, min_id) Status.as_home_timeline(@account) - .paginate_by_max_id(limit, max_id, since_id) + .paginate_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id) .reject { |status| FeedManager.instance.filter?(:home, status, @account.id) } end end diff --git a/config/routes.rb b/config/routes.rb index 877823c7f..35e4bdbf9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -268,7 +268,7 @@ Rails.application.routes.draw do resources :blocks, only: [:index] resources :mutes, only: [:index] resources :favourites, only: [:index] - resources :reports, only: [:index, :create] + resources :reports, only: [:create] resources :filters, only: [:index, :create, :show, :update, :destroy] resources :endorsements, only: [:index] diff --git a/spec/controllers/api/v1/favourites_controller_spec.rb b/spec/controllers/api/v1/favourites_controller_spec.rb index 2bdf927f2..231f76500 100644 --- a/spec/controllers/api/v1/favourites_controller_spec.rb +++ b/spec/controllers/api/v1/favourites_controller_spec.rb @@ -64,7 +64,7 @@ RSpec.describe Api::V1::FavouritesController, type: :controller do get :index, params: { limit: 1 } expect(response.headers['Link'].find_link(['rel', 'next']).href).to eq "http://test.host/api/v1/favourites?limit=1&max_id=#{favourite.id}" - expect(response.headers['Link'].find_link(['rel', 'prev']).href).to eq "http://test.host/api/v1/favourites?limit=1&since_id=#{favourite.id}" + expect(response.headers['Link'].find_link(['rel', 'prev']).href).to eq "http://test.host/api/v1/favourites?limit=1&min_id=#{favourite.id}" end it 'does not add pagination headers if not necessary' do diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb index ac93998c6..a3596cf8a 100644 --- a/spec/controllers/api/v1/reports_controller_spec.rb +++ b/spec/controllers/api/v1/reports_controller_spec.rb @@ -12,16 +12,6 @@ RSpec.describe Api::V1::ReportsController, type: :controller do allow(controller).to receive(:doorkeeper_token) { token } end - describe 'GET #index' do - let(:scopes) { 'read:reports' } - - it 'returns http success' do - get :index - - expect(response).to have_http_status(200) - end - end - describe 'POST #create' do let(:scopes) { 'write:reports' } let!(:status) { Fabricate(:status) } -- cgit From 6c835085a35d64be2c977b98de07801af5abec72 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 29 Sep 2018 19:03:33 +0200 Subject: Fix timeline pagination (#8827) Ruby's ** operator does not play well with non-Hash objects, which the params slice is Fix #8821 --- app/models/concerns/paginable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/models') diff --git a/app/models/concerns/paginable.rb b/app/models/concerns/paginable.rb index 52f186918..8863094f7 100644 --- a/app/models/concerns/paginable.rb +++ b/app/models/concerns/paginable.rb @@ -20,7 +20,7 @@ module Paginable query } - scope :paginate_by_id, ->(limit, **options) { + scope :paginate_by_id, ->(limit, options = {}) { if options[:min_id].present? paginate_by_min_id(limit, options[:min_id]).reverse else -- cgit From 7fe137d2f7792ed735be11eaca6d87fbc114043a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 4 Oct 2018 15:47:03 +0200 Subject: Fix link verification for remote accounts (#8868) --- app/models/account.rb | 26 +++++- app/serializers/rest/account_serializer.rb | 6 +- app/services/verify_link_service.rb | 2 +- spec/services/verify_link_service_spec.rb | 139 +++++++++++++++++------------ 4 files changed, 108 insertions(+), 65 deletions(-) (limited to 'app/models') diff --git a/app/models/account.rb b/app/models/account.rb index d8e5c7340..44963f3e6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -312,8 +312,8 @@ class Account < ApplicationRecord def initialize(account, attributes) @account = account @attributes = attributes - @name = attributes['name'].strip[0, 255] - @value = attributes['value'].strip[0, 255] + @name = attributes['name'].strip[0, string_limit] + @value = attributes['value'].strip[0, string_limit] @verified_at = attributes['verified_at']&.to_datetime @errors = {} end @@ -322,8 +322,18 @@ class Account < ApplicationRecord verified_at.present? end + def value_for_verification + @value_for_verification ||= begin + if account.local? + value + else + ActionController::Base.helpers.strip_tags(value) + end + end + end + def verifiable? - value.present? && value.start_with?('http://', 'https://') + value_for_verification.present? && value_for_verification.start_with?('http://', 'https://') end def mark_verified! @@ -334,6 +344,16 @@ class Account < ApplicationRecord def to_h { name: @name, value: @value, verified_at: @verified_at } end + + private + + def string_limit + if account.local? + 255 + else + 2047 + end + end end class << self diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index d84b48afb..12adc971c 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -11,11 +11,7 @@ class REST::AccountSerializer < ActiveModel::Serializer has_many :emojis, serializer: REST::CustomEmojiSerializer class FieldSerializer < ActiveModel::Serializer - attributes :name, :value - - attribute :verified_at, if: :verifiable? - - delegate :verifiable?, to: :object + attributes :name, :value, :verified_at def value Formatter.instance.format_field(object.account, object.value) diff --git a/app/services/verify_link_service.rb b/app/services/verify_link_service.rb index 7d53bc255..3453b54c5 100644 --- a/app/services/verify_link_service.rb +++ b/app/services/verify_link_service.rb @@ -3,7 +3,7 @@ class VerifyLinkService < BaseService def call(field) @link_back = ActivityPub::TagManager.instance.url_for(field.account) - @url = field.value + @url = field.value_for_verification perform_request! diff --git a/spec/services/verify_link_service_spec.rb b/spec/services/verify_link_service_spec.rb index 9b04d6136..2edcdb75f 100644 --- a/spec/services/verify_link_service_spec.rb +++ b/spec/services/verify_link_service_spec.rb @@ -3,80 +3,107 @@ require 'rails_helper' RSpec.describe VerifyLinkService, type: :service do subject { described_class.new } - let(:account) { Fabricate(:account, username: 'alice') } - let(:field) { Account::Field.new(account, 'name' => 'Website', 'value' => 'http://example.com') } + context 'given a local account' do + let(:account) { Fabricate(:account, username: 'alice') } + let(:field) { Account::Field.new(account, 'name' => 'Website', 'value' => 'http://example.com') } - before do - stub_request(:head, 'https://redirect.me/abc').to_return(status: 301, headers: { 'Location' => ActivityPub::TagManager.instance.url_for(account) }) - stub_request(:get, 'http://example.com').to_return(status: 200, body: html) - subject.call(field) - end - - context 'when a link contains an back' do - let(:html) do - <<-HTML - - - Follow me on Mastodon - - HTML + before do + stub_request(:head, 'https://redirect.me/abc').to_return(status: 301, headers: { 'Location' => ActivityPub::TagManager.instance.url_for(account) }) + stub_request(:get, 'http://example.com').to_return(status: 200, body: html) + subject.call(field) end - it 'marks the field as verified' do - expect(field.verified?).to be true + context 'when a link contains an back' do + let(:html) do + <<-HTML + + + Follow me on Mastodon + + HTML + end + + it 'marks the field as verified' do + expect(field.verified?).to be true + end end - end - context 'when a link contains an back' do - let(:html) do - <<-HTML - - - Follow me on Mastodon - - HTML + context 'when a link contains an back' do + let(:html) do + <<-HTML + + + Follow me on Mastodon + + HTML + end + + it 'marks the field as verified' do + expect(field.verified?).to be true + end end - it 'marks the field as verified' do - expect(field.verified?).to be true + context 'when a link contains a back' do + let(:html) do + <<-HTML + + + + + HTML + end + + it 'marks the field as verified' do + expect(field.verified?).to be true + end end - end - context 'when a link contains a back' do - let(:html) do - <<-HTML - - - - - HTML + context 'when a link goes through a redirect back' do + let(:html) do + <<-HTML + + + + + HTML + end + + it 'marks the field as verified' do + expect(field.verified?).to be true + end end - it 'marks the field as verified' do - expect(field.verified?).to be true + context 'when a link does not contain a link back' do + let(:html) { '' } + + it 'marks the field as verified' do + expect(field.verified?).to be false + end end end - context 'when a link goes through a redirect back' do - let(:html) do - <<-HTML - - - - - HTML - end + context 'given a remote account' do + let(:account) { Fabricate(:account, username: 'alice', domain: 'example.com', url: 'https://profile.example.com/alice') } + let(:field) { Account::Field.new(account, 'name' => 'Website', 'value' => 'example.com') } - it 'marks the field as verified' do - expect(field.verified?).to be true + before do + stub_request(:get, 'http://example.com').to_return(status: 200, body: html) + subject.call(field) end - end - context 'when a link does not contain a link back' do - let(:html) { '' } + context 'when a link contains an back' do + let(:html) do + <<-HTML + + + Follow me on Mastodon + + HTML + end - it 'marks the field as verified' do - expect(field.verified?).to be false + it 'marks the field as verified' do + expect(field.verified?).to be true + end end end end -- cgit From e645ae95610fcb69d15366bc32ab60014d2ebdcf Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 4 Oct 2018 16:05:38 +0200 Subject: Change admin accounts default sort to most recent (#8813) --- app/controllers/admin/accounts_controller.rb | 2 +- app/helpers/admin/filter_helper.rb | 2 +- app/models/account_filter.rb | 6 +++--- app/views/admin/accounts/index.html.haml | 4 ++-- spec/controllers/admin/accounts_controller_spec.rb | 4 ++-- spec/models/account_filter_spec.rb | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) (limited to 'app/models') diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index e7ca6b907..5d57fe361 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -95,7 +95,7 @@ module Admin :remote, :by_domain, :silenced, - :recent, + :alphabetic, :suspended, :username, :display_name, diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 359c43d0e..60e5142e3 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Admin::FilterHelper - ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip staff).freeze + ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended alphabetic username display_name email ip staff).freeze REPORT_FILTERS = %i(resolved account_id target_account_id).freeze INVITE_FILTER = %i(available expired).freeze CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb index dc7a03039..84364bf1b 100644 --- a/app/models/account_filter.rb +++ b/app/models/account_filter.rb @@ -8,7 +8,7 @@ class AccountFilter end def results - scope = Account.alphabetic + scope = Account.recent params.each do |key, value| scope.merge!(scope_for(key, value)) if value.present? @@ -29,8 +29,8 @@ class AccountFilter Account.where(domain: value) when 'silenced' Account.silenced - when 'recent' - Account.recent + when 'alphabetic' + Account.reorder(nil).alphabetic when 'suspended' Account.suspended when 'username' diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index 6aa39a80a..4bee73adc 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -38,8 +38,8 @@ .filter-subset %strong= t('admin.accounts.order.title') %ul - %li= filter_link_to t('admin.accounts.order.alphabetic'), recent: nil - %li= filter_link_to t('admin.accounts.order.most_recent'), recent: '1' + %li= filter_link_to t('admin.accounts.order.most_recent'), alphabetic: nil + %li= filter_link_to t('admin.accounts.order.alphabetic'), alphabetic: '1' = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do .fields-group diff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb index fa2786c9b..ae9e058c8 100644 --- a/spec/controllers/admin/accounts_controller_spec.rb +++ b/spec/controllers/admin/accounts_controller_spec.rb @@ -25,7 +25,7 @@ RSpec.describe Admin::AccountsController, type: :controller do expect(h[:remote]).to eq '1' expect(h[:by_domain]).to eq 'domain' expect(h[:silenced]).to eq '1' - expect(h[:recent]).to eq '1' + expect(h[:alphabetic]).to eq '1' expect(h[:suspended]).to eq '1' expect(h[:username]).to eq 'username' expect(h[:display_name]).to eq 'display name' @@ -40,7 +40,7 @@ RSpec.describe Admin::AccountsController, type: :controller do remote: '1', by_domain: 'domain', silenced: '1', - recent: '1', + alphabetic: '1', suspended: '1', username: 'username', display_name: 'display name', diff --git a/spec/models/account_filter_spec.rb b/spec/models/account_filter_spec.rb index 8441939c5..0a0252642 100644 --- a/spec/models/account_filter_spec.rb +++ b/spec/models/account_filter_spec.rb @@ -2,10 +2,10 @@ require 'rails_helper' describe AccountFilter do describe 'with empty params' do - it 'defaults to alphabetic account list' do + it 'defaults to recent account list' do filter = described_class.new({}) - expect(filter.results).to eq Account.alphabetic + expect(filter.results).to eq Account.recent end end @@ -60,7 +60,7 @@ describe AccountFilter do end describe 'that call account methods' do - %i(local remote silenced recent suspended).each do |option| + %i(local remote silenced alphabetic suspended).each do |option| it "delegates the #{option} option" do allow(Account).to receive(option).and_return(Account.none) filter = described_class.new({ option => true }) -- cgit From a46ab86adfc9e4ea182af9a555237f17071e194c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 4 Oct 2018 17:36:11 +0200 Subject: Limit the number of people that can be followed from one account (#8807) Configurable soft limit of 7,500, and above that, configurable ratio of 1.1 * followers, controlled by: - MAX_FOLLOWS_THRESHOLD - MAX_FOLLOWS_RATIO Fix #2311 --- app/models/follow.rb | 1 + app/models/follow_request.rb | 1 + app/validators/follow_limit_validator.rb | 27 +++++++++++++++++++++++++++ app/workers/import_worker.rb | 4 +++- config/locales/en.yml | 1 + spec/models/follow_spec.rb | 14 ++++++++++++++ 6 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 app/validators/follow_limit_validator.rb (limited to 'app/models') diff --git a/app/models/follow.rb b/app/models/follow.rb index 714f4e898..7ad56eb78 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -25,6 +25,7 @@ class Follow < ApplicationRecord has_one :notification, as: :activity, dependent: :destroy validates :account_id, uniqueness: { scope: :target_account_id } + validates_with FollowLimitValidator, on: :create scope :recent, -> { reorder(id: :desc) } diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 9c4875564..c5451a050 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -22,6 +22,7 @@ class FollowRequest < ApplicationRecord has_one :notification, as: :activity, dependent: :destroy validates :account_id, uniqueness: { scope: :target_account_id } + validates_with FollowLimitValidator, on: :create def authorize! account.follow!(target_account, reblogs: show_reblogs, uri: uri) diff --git a/app/validators/follow_limit_validator.rb b/app/validators/follow_limit_validator.rb new file mode 100644 index 000000000..eb083ed85 --- /dev/null +++ b/app/validators/follow_limit_validator.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class FollowLimitValidator < ActiveModel::Validator + LIMIT = ENV.fetch('MAX_FOLLOWS_THRESHOLD', 7_500).to_i + RATIO = ENV.fetch('MAX_FOLLOWS_RATIO', 1.1).to_f + + def validate(follow) + return if follow.account.nil? || !follow.account.local? + follow.errors.add(:base, I18n.t('users.follow_limit_reached', limit: self.class.limit_for_account(follow.account))) if limit_reached?(follow.account) + end + + class << self + def limit_for_account(account) + if account.following_count < LIMIT + LIMIT + else + account.followers_count * RATIO + end + end + end + + private + + def limit_reached?(account) + account.following_count >= self.class.limit_for_account(account) + end +end diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb index d7c126f75..aeb221cf6 100644 --- a/app/workers/import_worker.rb +++ b/app/workers/import_worker.rb @@ -37,6 +37,8 @@ class ImportWorker end def import_rows - CSV.new(import_contents).reject(&:blank?) + rows = CSV.new(import_contents).reject(&:blank?) + rows = rows.take(FollowLimitValidator.limit_for_account(@import.account)) if @import.type == 'following' + rows end end diff --git a/config/locales/en.yml b/config/locales/en.yml index f883b17a1..439c5627b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -917,6 +917,7 @@ en: tips: Tips title: Welcome aboard, %{name}! users: + follow_limit_reached: You cannot follow more than %{limit} people invalid_email: The e-mail address is invalid invalid_otp_token: Invalid two-factor code otp_lost_help_html: If you lost access to both, you may get in touch with %{email} diff --git a/spec/models/follow_spec.rb b/spec/models/follow_spec.rb index f221973b6..0c84e5e7b 100644 --- a/spec/models/follow_spec.rb +++ b/spec/models/follow_spec.rb @@ -23,6 +23,20 @@ RSpec.describe Follow, type: :model do follow.valid? expect(follow).to model_have_error_on_field(:target_account) end + + it 'is invalid if account already follows too many people' do + alice.update(following_count: FollowLimitValidator::LIMIT) + + expect(subject).to_not be_valid + expect(subject).to model_have_error_on_field(:base) + end + + it 'is valid if account is only on the brink of following too many people' do + alice.update(following_count: FollowLimitValidator::LIMIT - 1) + + expect(subject).to be_valid + expect(subject).to_not model_have_error_on_field(:base) + end end describe 'recent' do -- cgit From 774ac473736cbf348827cf6d861e7fbbb72d7623 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 7 Oct 2018 23:44:58 +0200 Subject: Add conversations API (#8832) * Add conversations API * Add web UI for conversations * Add test for conversations API * Add tests for ConversationAccount * Improve web UI * Rename ConversationAccount to AccountConversation * Remove conversations on block and mute * Change last_status_id to be a denormalization of status_ids * Add optimistic locking --- app/controllers/api/v1/conversations_controller.rb | 55 ++++++++++ app/javascript/mastodon/actions/conversations.js | 59 +++++++++++ app/javascript/mastodon/actions/streaming.js | 4 + app/javascript/mastodon/actions/timelines.js | 1 - app/javascript/mastodon/components/display_name.js | 11 +- .../direct_timeline/components/conversation.js | 85 ++++++++++++++++ .../components/conversations_list.js | 68 +++++++++++++ .../containers/conversation_container.js | 15 +++ .../containers/conversations_list_container.js | 15 +++ .../mastodon/features/direct_timeline/index.js | 27 ++--- app/javascript/mastodon/reducers/conversations.js | 79 +++++++++++++++ app/javascript/mastodon/reducers/index.js | 2 + app/javascript/mastodon/reducers/notifications.js | 2 +- app/javascript/styles/mastodon/components.scss | 42 ++++++++ app/lib/inline_renderer.rb | 2 + app/models/account_conversation.rb | 111 +++++++++++++++++++++ app/models/status.rb | 13 +++ app/serializers/rest/conversation_serializer.rb | 7 ++ app/services/after_block_service.rb | 37 ++++++- app/services/fan_out_on_write_service.rb | 6 ++ app/services/mute_service.rb | 4 +- app/services/notify_service.rb | 22 ++-- app/workers/block_worker.rb | 5 +- app/workers/mute_worker.rb | 12 +++ app/workers/push_conversation_worker.rb | 15 +++ config/environments/development.rb | 2 +- config/routes.rb | 1 + .../20180929222014_create_account_conversations.rb | 14 +++ db/schema.rb | 17 +++- .../api/v1/conversations_controller_spec.rb | 37 +++++++ .../fabricators/conversation_account_fabricator.rb | 6 ++ spec/models/account_conversation_spec.rb | 72 +++++++++++++ streaming/index.js | 12 ++- 33 files changed, 816 insertions(+), 44 deletions(-) create mode 100644 app/controllers/api/v1/conversations_controller.rb create mode 100644 app/javascript/mastodon/actions/conversations.js create mode 100644 app/javascript/mastodon/features/direct_timeline/components/conversation.js create mode 100644 app/javascript/mastodon/features/direct_timeline/components/conversations_list.js create mode 100644 app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js create mode 100644 app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js create mode 100644 app/javascript/mastodon/reducers/conversations.js create mode 100644 app/models/account_conversation.rb create mode 100644 app/serializers/rest/conversation_serializer.rb create mode 100644 app/workers/mute_worker.rb create mode 100644 app/workers/push_conversation_worker.rb create mode 100644 db/migrate/20180929222014_create_account_conversations.rb create mode 100644 spec/controllers/api/v1/conversations_controller_spec.rb create mode 100644 spec/fabricators/conversation_account_fabricator.rb create mode 100644 spec/models/account_conversation_spec.rb (limited to 'app/models') diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb new file mode 100644 index 000000000..736cb21ca --- /dev/null +++ b/app/controllers/api/v1/conversations_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Api::V1::ConversationsController < Api::BaseController + LIMIT = 20 + + before_action -> { doorkeeper_authorize! :read, :'read:statuses' } + before_action :require_user! + after_action :insert_pagination_headers + + respond_to :json + + def index + @conversations = paginated_conversations + render json: @conversations, each_serializer: REST::ConversationSerializer + end + + private + + def paginated_conversations + AccountConversation.where(account: current_account) + .paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_conversations_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @conversations.empty? + api_v1_conversations_url pagination_params(min_id: pagination_since_id) + end + end + + def pagination_max_id + @conversations.last.last_status_id + end + + def pagination_since_id + @conversations.first.last_status_id + end + + def records_continue? + @conversations.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js new file mode 100644 index 000000000..3840d23ca --- /dev/null +++ b/app/javascript/mastodon/actions/conversations.js @@ -0,0 +1,59 @@ +import api, { getLinks } from '../api'; +import { + importFetchedAccounts, + importFetchedStatuses, + importFetchedStatus, +} from './importer'; + +export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST'; +export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS'; +export const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL'; +export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; + +export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => { + dispatch(expandConversationsRequest()); + + const params = { max_id: maxId }; + + if (!maxId) { + params.since_id = getState().getIn(['conversations', 0, 'last_status']); + } + + api(getState).get('/api/v1/conversations', { params }) + .then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), []))); + dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x))); + dispatch(expandConversationsSuccess(response.data, next ? next.uri : null)); + }) + .catch(err => dispatch(expandConversationsFail(err))); +}; + +export const expandConversationsRequest = () => ({ + type: CONVERSATIONS_FETCH_REQUEST, +}); + +export const expandConversationsSuccess = (conversations, next) => ({ + type: CONVERSATIONS_FETCH_SUCCESS, + conversations, + next, +}); + +export const expandConversationsFail = error => ({ + type: CONVERSATIONS_FETCH_FAIL, + error, +}); + +export const updateConversations = conversation => dispatch => { + dispatch(importFetchedAccounts(conversation.accounts)); + + if (conversation.last_status) { + dispatch(importFetchedStatus(conversation.last_status)); + } + + dispatch({ + type: CONVERSATIONS_UPDATE, + conversation, + }); +}; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 32fc67e67..8cf055540 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -6,6 +6,7 @@ import { disconnectTimeline, } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; +import { updateConversations } from './conversations'; import { fetchFilters } from './filters'; import { getLocale } from '../locales'; @@ -31,6 +32,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) case 'notification': dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); break; + case 'conversation': + dispatch(updateConversations(JSON.parse(data.payload))); + break; case 'filters_changed': dispatch(fetchFilters()); break; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index e8fd441e1..c4fc6448c 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -76,7 +76,6 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); -export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index a1c56ae35..c3a9ab921 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/app/javascript/mastodon/components/display_name.js @@ -1,18 +1,25 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; export default class DisplayName extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, + withAcct: PropTypes.bool, + }; + + static defaultProps = { + withAcct: true, }; render () { - const displayNameHtml = { __html: this.props.account.get('display_name_html') }; + const { account, withAcct } = this.props; + const displayNameHtml = { __html: account.get('display_name_html') }; return ( - @{this.props.account.get('acct')} + {withAcct && @{account.get('acct')}} ); } diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js new file mode 100644 index 000000000..f9a8d4f72 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import StatusContent from '../../../components/status_content'; +import RelativeTimestamp from '../../../components/relative_timestamp'; +import DisplayName from '../../../components/display_name'; +import Avatar from '../../../components/avatar'; +import AttachmentList from '../../../components/attachment_list'; +import { HotKeys } from 'react-hotkeys'; + +export default class Conversation extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + conversationId: PropTypes.string.isRequired, + accounts: ImmutablePropTypes.list.isRequired, + lastStatus: ImmutablePropTypes.map.isRequired, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + }; + + handleClick = () => { + if (!this.context.router) { + return; + } + + const { lastStatus } = this.props; + this.context.router.history.push(`/statuses/${lastStatus.get('id')}`); + } + + handleHotkeyMoveUp = () => { + this.props.onMoveUp(this.props.conversationId); + } + + handleHotkeyMoveDown = () => { + this.props.onMoveDown(this.props.conversationId); + } + + render () { + const { accounts, lastStatus, lastAccount } = this.props; + + if (lastStatus === null) { + return null; + } + + const handlers = { + moveDown: this.handleHotkeyMoveDown, + moveUp: this.handleHotkeyMoveUp, + open: this.handleClick, + }; + + let media; + + if (lastStatus.get('media_attachments').size > 0) { + media = ; + } + + return ( + +
+
+
+
{accounts.map(account => )}
+
+ +
+ +
+ +
+
+ + + + {media} +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js new file mode 100644 index 000000000..4684548e0 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ConversationContainer from '../containers/conversation_container'; +import ScrollableList from '../../../components/scrollable_list'; +import { debounce } from 'lodash'; + +export default class ConversationsList extends ImmutablePureComponent { + + static propTypes = { + conversationIds: ImmutablePropTypes.list.isRequired, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + onLoadMore: PropTypes.func, + shouldUpdateScroll: PropTypes.func, + }; + + getCurrentIndex = id => this.props.conversationIds.indexOf(id) + + handleMoveUp = id => { + const elementIndex = this.getCurrentIndex(id) - 1; + this._selectChild(elementIndex); + } + + handleMoveDown = id => { + const elementIndex = this.getCurrentIndex(id) + 1; + this._selectChild(elementIndex); + } + + _selectChild (index) { + const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + element.focus(); + } + } + + setRef = c => { + this.node = c; + } + + handleLoadOlder = debounce(() => { + const last = this.props.conversationIds.last(); + + if (last) { + this.props.onLoadMore(last); + } + }, 300, { leading: true }) + + render () { + const { conversationIds, onLoadMore, ...other } = this.props; + + return ( + + {conversationIds.map(item => ( + + ))} + + ); + } + +} diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js new file mode 100644 index 000000000..4166ee2ac --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import Conversation from '../components/conversation'; + +const mapStateToProps = (state, { conversationId }) => { + const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); + const lastStatus = state.getIn(['statuses', conversation.get('last_status')], null); + + return { + accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), + lastStatus, + lastAccount: lastStatus === null ? null : state.getIn(['accounts', lastStatus.get('account')], null), + }; +}; + +export default connect(mapStateToProps)(Conversation); diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js new file mode 100644 index 000000000..81ea812ad --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import ConversationsList from '../components/conversations_list'; +import { expandConversations } from '../../../actions/conversations'; + +const mapStateToProps = state => ({ + conversationIds: state.getIn(['conversations', 'items']).map(x => x.get('id')), + isLoading: state.getIn(['conversations', 'isLoading'], true), + hasMore: state.getIn(['conversations', 'hasMore'], false), +}); + +const mapDispatchToProps = dispatch => ({ + onLoadMore: maxId => dispatch(expandConversations({ maxId })), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList); diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js index 3c7e2d007..4c8485690 100644 --- a/app/javascript/mastodon/features/direct_timeline/index.js +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -1,23 +1,19 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { expandDirectTimeline } from '../../actions/timelines'; +import { expandConversations } from '../../actions/conversations'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import { connectDirectStream } from '../../actions/streaming'; +import ConversationsListContainer from './containers/conversations_list_container'; const messages = defineMessages({ title: { id: 'column.direct', defaultMessage: 'Direct messages' }, }); -const mapStateToProps = state => ({ - hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, -}); - -export default @connect(mapStateToProps) +export default @connect() @injectIntl class DirectTimeline extends React.PureComponent { @@ -52,7 +48,7 @@ class DirectTimeline extends React.PureComponent { componentDidMount () { const { dispatch } = this.props; - dispatch(expandDirectTimeline()); + dispatch(expandConversations()); this.disconnect = dispatch(connectDirectStream()); } @@ -68,11 +64,11 @@ class DirectTimeline extends React.PureComponent { } handleLoadMore = maxId => { - this.props.dispatch(expandDirectTimeline({ maxId })); + this.props.dispatch(expandConversations({ maxId })); } render () { - const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props; + const { intl, hasUnread, columnId, multiColumn, shouldUpdateScroll } = this.props; const pinned = !!columnId; return ( @@ -88,14 +84,7 @@ class DirectTimeline extends React.PureComponent { multiColumn={multiColumn} /> - } - shouldUpdateScroll={shouldUpdateScroll} - /> + ); } diff --git a/app/javascript/mastodon/reducers/conversations.js b/app/javascript/mastodon/reducers/conversations.js new file mode 100644 index 000000000..f339abf56 --- /dev/null +++ b/app/javascript/mastodon/reducers/conversations.js @@ -0,0 +1,79 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + CONVERSATIONS_FETCH_REQUEST, + CONVERSATIONS_FETCH_SUCCESS, + CONVERSATIONS_FETCH_FAIL, + CONVERSATIONS_UPDATE, +} from '../actions/conversations'; +import compareId from '../compare_id'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + hasMore: true, +}); + +const conversationToMap = item => ImmutableMap({ + id: item.id, + accounts: ImmutableList(item.accounts.map(a => a.id)), + last_status: item.last_status.id, +}); + +const updateConversation = (state, item) => state.update('items', list => { + const index = list.findIndex(x => x.get('id') === item.id); + const newItem = conversationToMap(item); + + if (index === -1) { + return list.unshift(newItem); + } else { + return list.set(index, newItem); + } +}); + +const expandNormalizedConversations = (state, conversations, next) => { + let items = ImmutableList(conversations.map(conversationToMap)); + + return state.withMutations(mutable => { + if (!items.isEmpty()) { + mutable.update('items', list => { + list = list.map(oldItem => { + const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id')); + + if (newItemIndex === -1) { + return oldItem; + } + + const newItem = items.get(newItemIndex); + items = items.delete(newItemIndex); + + return newItem; + }); + + list = list.concat(items); + + return list.sortBy(x => x.get('last_status'), (a, b) => compareId(a, b) * -1); + }); + } + + if (!next) { + mutable.set('hasMore', false); + } + + mutable.set('isLoading', false); + }); +}; + +export default function conversations(state = initialState, action) { + switch (action.type) { + case CONVERSATIONS_FETCH_REQUEST: + return state.set('isLoading', true); + case CONVERSATIONS_FETCH_FAIL: + return state.set('isLoading', false); + case CONVERSATIONS_FETCH_SUCCESS: + return expandNormalizedConversations(state, action.conversations, action.next); + case CONVERSATIONS_UPDATE: + return updateConversation(state, action.conversation); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 4a981fada..d3b98d4f6 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -27,6 +27,7 @@ import custom_emojis from './custom_emojis'; import lists from './lists'; import listEditor from './list_editor'; import filters from './filters'; +import conversations from './conversations'; const reducers = { dropdown_menu, @@ -57,6 +58,7 @@ const reducers = { lists, listEditor, filters, + conversations, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 0b29f19fa..d71ae00ae 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -69,7 +69,7 @@ const expandNormalizedNotifications = (state, notifications, next) => { } if (!next) { - mutable.set('hasMore', true); + mutable.set('hasMore', false); } mutable.set('isLoading', false); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 90e2ed5a5..129bde856 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -825,6 +825,7 @@ &.status-direct { background: lighten($ui-base-color, 8%); + border-bottom-color: lighten($ui-base-color, 12%); } &.light { @@ -5496,3 +5497,44 @@ noscript { } } } + +.conversation { + padding: 14px 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + cursor: pointer; + + &__header { + display: flex; + margin-bottom: 15px; + } + + &__avatars { + overflow: hidden; + flex: 1 1 auto; + + & > div { + display: flex; + flex-wrap: none; + width: 900px; + } + + .account__avatar { + margin-right: 10px; + } + } + + &__time { + flex: 0 0 auto; + font-size: 14px; + color: $darker-text-color; + text-align: right; + + .display-name { + color: $secondary-text-color; + } + } + + .attachment-list.compact { + margin-top: 15px; + } +} diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb index 7cd9758ec..761a8822d 100644 --- a/app/lib/inline_renderer.rb +++ b/app/lib/inline_renderer.rb @@ -13,6 +13,8 @@ class InlineRenderer serializer = REST::StatusSerializer when :notification serializer = REST::NotificationSerializer + when :conversation + serializer = REST::ConversationSerializer else return end diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb new file mode 100644 index 000000000..a7205ec1a --- /dev/null +++ b/app/models/account_conversation.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_conversations +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# conversation_id :bigint(8) +# participant_account_ids :bigint(8) default([]), not null, is an Array +# status_ids :bigint(8) default([]), not null, is an Array +# last_status_id :bigint(8) +# lock_version :integer default(0), not null +# + +class AccountConversation < ApplicationRecord + after_commit :push_to_streaming_api + + belongs_to :account + belongs_to :conversation + belongs_to :last_status, class_name: 'Status' + + before_validation :set_last_status + + def participant_account_ids=(arr) + self[:participant_account_ids] = arr.sort + end + + def participant_accounts + if participant_account_ids.empty? + [account] + else + Account.where(id: participant_account_ids) + end + end + + class << self + def paginate_by_id(limit, options = {}) + if options[:min_id] + paginate_by_min_id(limit, options[:min_id]).reverse + else + paginate_by_max_id(limit, options[:max_id], options[:since_id]) + end + end + + def paginate_by_min_id(limit, min_id = nil) + query = order(arel_table[:last_status_id].asc).limit(limit) + query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present? + query + end + + def paginate_by_max_id(limit, max_id = nil, since_id = nil) + query = order(arel_table[:last_status_id].desc).limit(limit) + query = query.where(arel_table[:last_status_id].lt(max_id)) if max_id.present? + query = query.where(arel_table[:last_status_id].gt(since_id)) if since_id.present? + query + end + + def add_status(recipient, status) + conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status)) + conversation.status_ids << status.id + conversation.save + conversation + rescue ActiveRecord::StaleObjectError + retry + end + + def remove_status(recipient, status) + conversation = find_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status)) + + return if conversation.nil? + + conversation.status_ids.delete(status.id) + + if conversation.status_ids.empty? + conversation.destroy + else + conversation.save + end + + conversation + rescue ActiveRecord::StaleObjectError + retry + end + + private + + def participants_from_status(recipient, status) + ((status.mentions.pluck(:account_id) + [status.account_id]).uniq - [recipient.id]).sort + end + end + + private + + def set_last_status + self.status_ids = status_ids.sort + self.last_status_id = status_ids.last + end + + def push_to_streaming_api + return if destroyed? || !subscribed_to_timeline? + PushConversationWorker.perform_async(id) + end + + def subscribed_to_timeline? + Redis.current.exists("subscribed:#{streaming_channel}") + end + + def streaming_channel + "timeline:direct:#{account_id}" + end +end diff --git a/app/models/status.rb b/app/models/status.rb index f2b5cc6ce..f61bd0fee 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -24,6 +24,8 @@ # class Status < ApplicationRecord + before_destroy :unlink_from_conversations + include Paginable include Streamable include Cacheable @@ -473,4 +475,15 @@ class Status < ApplicationRecord reblog&.decrement_count!(:reblogs_count) if reblog? thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?) end + + def unlink_from_conversations + return unless direct_visibility? + + mentioned_accounts = mentions.includes(:account).map(&:account) + inbox_owners = mentioned_accounts.select(&:local?) + (account.local? ? [account] : []) + + inbox_owners.each do |inbox_owner| + AccountConversation.remove_status(inbox_owner, self) + end + end end diff --git a/app/serializers/rest/conversation_serializer.rb b/app/serializers/rest/conversation_serializer.rb new file mode 100644 index 000000000..08cea47d2 --- /dev/null +++ b/app/serializers/rest/conversation_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class REST::ConversationSerializer < ActiveModel::Serializer + attribute :id + has_many :participant_accounts, key: :accounts, serializer: REST::AccountSerializer + has_one :last_status, serializer: REST::StatusSerializer +end diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb index a7dce08c7..706db0d63 100644 --- a/app/services/after_block_service.rb +++ b/app/services/after_block_service.rb @@ -2,16 +2,43 @@ class AfterBlockService < BaseService def call(account, target_account) - FeedManager.instance.clear_from_timeline(account, target_account) + clear_home_feed(account, target_account) clear_notifications(account, target_account) + clear_conversations(account, target_account) end private + def clear_home_feed(account, target_account) + FeedManager.instance.clear_from_timeline(account, target_account) + end + + def clear_conversations(account, target_account) + AccountConversation.where(account: account) + .where('? = ANY(participant_account_ids)', target_account.id) + .in_batches + .destroy_all + end + def clear_notifications(account, target_account) - Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).delete_all - Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).delete_all - Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).delete_all - Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).delete_all + Notification.where(account: account) + .joins(:follow) + .where(activity_type: 'Follow', follows: { account_id: target_account.id }) + .delete_all + + Notification.where(account: account) + .joins(mention: :status) + .where(activity_type: 'Mention', statuses: { account_id: target_account.id }) + .delete_all + + Notification.where(account: account) + .joins(:favourite) + .where(activity_type: 'Favourite', favourites: { account_id: target_account.id }) + .delete_all + + Notification.where(account: account) + .joins(:status) + .where(activity_type: 'Status', statuses: { account_id: target_account.id }) + .delete_all end end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 5efd3edb2..ab520276b 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -13,6 +13,7 @@ class FanOutOnWriteService < BaseService if status.direct_visibility? deliver_to_mentioned_followers(status) deliver_to_direct_timelines(status) + deliver_to_own_conversation(status) else deliver_to_followers(status) deliver_to_lists(status) @@ -99,6 +100,11 @@ class FanOutOnWriteService < BaseService status.mentions.includes(:account).each do |mention| Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? end + Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local? end + + def deliver_to_own_conversation(status) + AccountConversation.add_status(status.account, status) + end end diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index c6122a152..676804cb9 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -5,11 +5,13 @@ class MuteService < BaseService return if account.id == target_account.id mute = account.mute!(target_account, notifications: notifications) + if mute.hide_notifications? BlockWorker.perform_async(account.id, target_account.id) else - FeedManager.instance.clear_from_timeline(account, target_account) + MuteWorker.perform_async(account.id, target_account.id) end + mute end end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 7d0dcc7ad..63bf8f17a 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -8,9 +8,10 @@ class NotifyService < BaseService return if recipient.user.nil? || blocked? - create_notification - push_notification if @notification.browserable? - send_email if email_enabled? + create_notification! + push_notification! if @notification.browserable? + push_to_conversation! if direct_message? + send_email! if email_enabled? rescue ActiveRecord::RecordInvalid return end @@ -100,18 +101,23 @@ class NotifyService < BaseService end end - def create_notification + def create_notification! @notification.save! end - def push_notification + def push_notification! return if @notification.activity.nil? Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification))) - send_push_notifications + send_push_notifications! end - def send_push_notifications + def push_to_conversation! + return if @notification.activity.nil? + AccountConversation.add_status(@recipient, @notification.target_status) + end + + def send_push_notifications! subscriptions_ids = ::Web::PushSubscription.where(user_id: @recipient.user.id) .select { |subscription| subscription.pushable?(@notification) } .map(&:id) @@ -121,7 +127,7 @@ class NotifyService < BaseService end end - def send_email + def send_email! return if @notification.activity.nil? NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes) end diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb index 0820490d3..25f5dd808 100644 --- a/app/workers/block_worker.rb +++ b/app/workers/block_worker.rb @@ -4,6 +4,9 @@ class BlockWorker include Sidekiq::Worker def perform(account_id, target_account_id) - AfterBlockService.new.call(Account.find(account_id), Account.find(target_account_id)) + AfterBlockService.new.call( + Account.find(account_id), + Account.find(target_account_id) + ) end end diff --git a/app/workers/mute_worker.rb b/app/workers/mute_worker.rb new file mode 100644 index 000000000..7bf0923a5 --- /dev/null +++ b/app/workers/mute_worker.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class MuteWorker + include Sidekiq::Worker + + def perform(account_id, target_account_id) + FeedManager.instance.clear_from_timeline( + Account.find(account_id), + Account.find(target_account_id) + ) + end +end diff --git a/app/workers/push_conversation_worker.rb b/app/workers/push_conversation_worker.rb new file mode 100644 index 000000000..16f538215 --- /dev/null +++ b/app/workers/push_conversation_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class PushConversationWorker + include Sidekiq::Worker + + def perform(conversation_account_id) + conversation = AccountConversation.find(conversation_account_id) + message = InlineRenderer.render(conversation, conversation.account, :conversation) + timeline_id = "timeline:direct:#{conversation.account_id}" + + Redis.current.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index b6478f16e..0791b82ab 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -87,7 +87,7 @@ Rails.application.configure do config.x.otp_secret = ENV.fetch('OTP_SECRET', '1fc2b87989afa6351912abeebe31ffc5c476ead9bf8b3d74cbc4a302c7b69a45b40b1bbef3506ddad73e942e15ed5ca4b402bf9a66423626051104f4b5f05109') end -ActiveRecordQueryTrace.enabled = ENV.fetch('QUERY_TRACE_ENABLED') { false } +ActiveRecordQueryTrace.enabled = ENV['QUERY_TRACE_ENABLED'] == 'true' module PrivateAddressCheck def self.private_address?(*) diff --git a/config/routes.rb b/config/routes.rb index 50c1d57fc..a2468c9bd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -261,6 +261,7 @@ Rails.application.routes.draw do resources :streaming, only: [:index] resources :custom_emojis, only: [:index] resources :suggestions, only: [:index, :destroy] + resources :conversations, only: [:index] get '/search', to: 'search#index', as: :search diff --git a/db/migrate/20180929222014_create_account_conversations.rb b/db/migrate/20180929222014_create_account_conversations.rb new file mode 100644 index 000000000..53fa137e1 --- /dev/null +++ b/db/migrate/20180929222014_create_account_conversations.rb @@ -0,0 +1,14 @@ +class CreateAccountConversations < ActiveRecord::Migration[5.2] + def change + create_table :account_conversations do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.belongs_to :conversation, foreign_key: { on_delete: :cascade } + t.bigint :participant_account_ids, array: true, null: false, default: [] + t.bigint :status_ids, array: true, null: false, default: [] + t.bigint :last_status_id, null: true, default: nil + t.integer :lock_version, null: false, default: 0 + end + + add_index :account_conversations, [:account_id, :conversation_id, :participant_account_ids], unique: true, name: 'index_unique_conversations' + end +end diff --git a/db/schema.rb b/db/schema.rb index 577296a6a..1458bd70f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,23 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2018_08_20_232245) do +ActiveRecord::Schema.define(version: 2018_09_29_222014) do + # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "account_conversations", force: :cascade do |t| + t.bigint "account_id" + t.bigint "conversation_id" + t.bigint "participant_account_ids", default: [], null: false, array: true + t.bigint "status_ids", default: [], null: false, array: true + t.bigint "last_status_id" + t.integer "lock_version", default: 0, null: false + t.index ["account_id", "conversation_id", "participant_account_ids"], name: "index_unique_conversations", unique: true + t.index ["account_id"], name: "index_account_conversations_on_account_id" + t.index ["conversation_id"], name: "index_account_conversations_on_conversation_id" + end + create_table "account_domain_blocks", force: :cascade do |t| t.string "domain" t.datetime "created_at", null: false @@ -597,6 +610,8 @@ ActiveRecord::Schema.define(version: 2018_08_20_232245) do t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true end + add_foreign_key "account_conversations", "accounts", on_delete: :cascade + add_foreign_key "account_conversations", "conversations", on_delete: :cascade add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade add_foreign_key "account_moderation_notes", "accounts" add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id" diff --git a/spec/controllers/api/v1/conversations_controller_spec.rb b/spec/controllers/api/v1/conversations_controller_spec.rb new file mode 100644 index 000000000..2e9525855 --- /dev/null +++ b/spec/controllers/api/v1/conversations_controller_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe Api::V1::ConversationsController, type: :controller do + render_views + + let!(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:other) { Fabricate(:user, account: Fabricate(:account, username: 'bob')) } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + let(:scopes) { 'read:statuses' } + + before do + PostStatusService.new.call(other.account, 'Hey @alice', nil, visibility: 'direct') + end + + it 'returns http success' do + get :index + expect(response).to have_http_status(200) + end + + it 'returns pagination headers' do + get :index, params: { limit: 1 } + expect(response.headers['Link'].links.size).to eq(2) + end + + it 'returns conversations' do + get :index + json = body_as_json + expect(json.size).to eq 1 + end + end +end diff --git a/spec/fabricators/conversation_account_fabricator.rb b/spec/fabricators/conversation_account_fabricator.rb new file mode 100644 index 000000000..f57ffd535 --- /dev/null +++ b/spec/fabricators/conversation_account_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:conversation_account) do + account nil + conversation nil + participant_account_ids "" + last_status nil +end diff --git a/spec/models/account_conversation_spec.rb b/spec/models/account_conversation_spec.rb new file mode 100644 index 000000000..70a76281e --- /dev/null +++ b/spec/models/account_conversation_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe AccountConversation, type: :model do + let!(:alice) { Fabricate(:account, username: 'alice') } + let!(:bob) { Fabricate(:account, username: 'bob') } + let!(:mark) { Fabricate(:account, username: 'mark') } + + describe '.add_status' do + it 'creates new record when no others exist' do + status = Fabricate(:status, account: alice, visibility: :direct) + status.mentions.create(account: bob) + + conversation = AccountConversation.add_status(alice, status) + + expect(conversation.participant_accounts).to include(bob) + expect(conversation.last_status).to eq status + expect(conversation.status_ids).to eq [status.id] + end + + it 'appends to old record when there is a match' do + last_status = Fabricate(:status, account: alice, visibility: :direct) + conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) + + status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status) + status.mentions.create(account: alice) + + new_conversation = AccountConversation.add_status(alice, status) + + expect(new_conversation.id).to eq conversation.id + expect(new_conversation.participant_accounts).to include(bob) + expect(new_conversation.last_status).to eq status + expect(new_conversation.status_ids).to eq [last_status.id, status.id] + end + + it 'creates new record when new participants are added' do + last_status = Fabricate(:status, account: alice, visibility: :direct) + conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) + + status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status) + status.mentions.create(account: alice) + status.mentions.create(account: mark) + + new_conversation = AccountConversation.add_status(alice, status) + + expect(new_conversation.id).to_not eq conversation.id + expect(new_conversation.participant_accounts).to include(bob, mark) + expect(new_conversation.last_status).to eq status + expect(new_conversation.status_ids).to eq [status.id] + end + end + + describe '.remove_status' do + it 'updates last status to a previous value' do + last_status = Fabricate(:status, account: alice, visibility: :direct) + status = Fabricate(:status, account: alice, visibility: :direct) + conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [status.id, last_status.id]) + last_status.mentions.create(account: bob) + last_status.destroy! + conversation.reload + expect(conversation.last_status).to eq status + expect(conversation.status_ids).to eq [status.id] + end + + it 'removes the record if no other statuses are referenced' do + last_status = Fabricate(:status, account: alice, visibility: :direct) + conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) + last_status.mentions.create(account: bob) + last_status.destroy! + expect(AccountConversation.where(id: conversation.id).count).to eq 0 + end + end +end diff --git a/streaming/index.js b/streaming/index.js index 1c6004b77..debf7c8bf 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -485,7 +485,8 @@ const startWorker = (workerId) => { }); app.get('/api/v1/streaming/direct', (req, res) => { - streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true); + const channel = `timeline:direct:${req.accountId}`; + streamFrom(channel, req, streamToHttp(req, res), streamHttpEnd(req, subscriptionHeartbeat(channel)), true); }); app.get('/api/v1/streaming/hashtag', (req, res) => { @@ -525,9 +526,11 @@ const startWorker = (workerId) => { ws.isAlive = true; }); + let channel; + switch(location.query.stream) { case 'user': - const channel = `timeline:${req.accountId}`; + channel = `timeline:${req.accountId}`; streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel))); break; case 'user:notification': @@ -546,7 +549,8 @@ const startWorker = (workerId) => { streamFrom('timeline:public:local:media', req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; case 'direct': - streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); + channel = `timeline:direct:${req.accountId}`; + streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)), true); break; case 'hashtag': streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); @@ -563,7 +567,7 @@ const startWorker = (workerId) => { return; } - const channel = `timeline:list:${listId}`; + channel = `timeline:list:${listId}`; streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel))); }); break; -- cgit From f194857ac9eeb85f9b27c056c556038aee23cb43 Mon Sep 17 00:00:00 2001 From: ashleyhull-versent Date: Mon, 8 Oct 2018 13:50:11 +1100 Subject: rubocop issues - Cleaning up (#8912) * cleanup pass * undo mistakes * fixed. * revert --- app/models/concerns/omniauthable.rb | 4 ++-- config/initializers/open_uri_redirection.rb | 2 +- lib/mastodon/migration_helpers.rb | 10 ++++----- spec/controllers/api/salmon_controller_spec.rb | 4 ++-- .../api/subscriptions_controller_spec.rb | 2 +- spec/fabricators/user_fabricator.rb | 2 +- spec/features/log_in_spec.rb | 2 +- spec/lib/ostatus/atom_serializer_spec.rb | 24 +++++++++++----------- spec/models/status_spec.rb | 2 +- spec/models/user_spec.rb | 2 +- spec/rails_helper.rb | 4 ++-- .../services/batched_remove_status_service_spec.rb | 2 +- spec/services/fetch_remote_account_service_spec.rb | 2 +- spec/services/process_feed_service_spec.rb | 2 +- .../services/update_remote_profile_service_spec.rb | 2 +- 15 files changed, 33 insertions(+), 33 deletions(-) (limited to 'app/models') diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb index 50288e700..f263fe7af 100644 --- a/app/models/concerns/omniauthable.rb +++ b/app/models/concerns/omniauthable.rb @@ -26,7 +26,7 @@ module Omniauthable # to prevent the identity being locked with accidentally created accounts. # Note that this may leave zombie accounts (with no associated identity) which # can be cleaned up at a later date. - user = signed_in_resource ? signed_in_resource : identity.user + user = signed_in_resource || identity.user user = create_for_oauth(auth) if user.nil? if identity.user.nil? @@ -61,7 +61,7 @@ module Omniauthable display_name = auth.info.full_name || [auth.info.first_name, auth.info.last_name].join(' ') { - email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com", + email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com", password: Devise.friendly_token[0, 20], account_attributes: { username: ensure_unique_username(auth.uid), diff --git a/config/initializers/open_uri_redirection.rb b/config/initializers/open_uri_redirection.rb index ea2dcffea..e9de85bdc 100644 --- a/config/initializers/open_uri_redirection.rb +++ b/config/initializers/open_uri_redirection.rb @@ -2,7 +2,7 @@ require 'open-uri' module OpenURI def self.redirectable?(uri1, uri2) # :nodoc: - uri1.scheme.downcase == uri2.scheme.downcase || + uri1.scheme.casecmp(uri2.scheme).zero? || (/\A(?:http|https|ftp)\z/i =~ uri1.scheme && /\A(?:http|https|ftp)\z/i =~ uri2.scheme) end end diff --git a/lib/mastodon/migration_helpers.rb b/lib/mastodon/migration_helpers.rb index 5c135685f..f5dc7e1c6 100644 --- a/lib/mastodon/migration_helpers.rb +++ b/lib/mastodon/migration_helpers.rb @@ -342,8 +342,8 @@ module Mastodon say "Migrating #{table_name}.#{column} (~#{total.to_i} rows)" - started_time = Time.now - last_time = Time.now + started_time = Time.zone.now + last_time = Time.zone.now migrated = 0 loop do stop_row = nil @@ -375,13 +375,13 @@ module Mastodon end migrated += batch_size - if Time.now - last_time > 1 + if Time.zone.now - last_time > 1 status = "Migrated #{migrated} rows" percentage = 100.0 * migrated / total status += " (~#{sprintf('%.2f', percentage)}%, " - remaining_time = (100.0 - percentage) * (Time.now - started_time) / percentage + remaining_time = (100.0 - percentage) * (Time.zone.now - started_time) / percentage status += "#{(remaining_time / 60).to_i}:" status += sprintf('%02d', remaining_time.to_i % 60) @@ -397,7 +397,7 @@ module Mastodon status += ')' say status, true - last_time = Time.now + last_time = Time.zone.now end # There are no more rows left to update. diff --git a/spec/controllers/api/salmon_controller_spec.rb b/spec/controllers/api/salmon_controller_spec.rb index 8ce4913a5..235a29af0 100644 --- a/spec/controllers/api/salmon_controller_spec.rb +++ b/spec/controllers/api/salmon_controller_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Api::SalmonController, type: :controller do describe 'POST #update' do context 'with valid post data' do before do - post :update, params: { id: account.id }, body: File.read(File.join(Rails.root, 'spec', 'fixtures', 'salmon', 'mention.xml')) + post :update, params: { id: account.id }, body: File.read(Rails.root.join('spec', 'fixtures', 'salmon', 'mention.xml')) end it 'contains XML in the request body' do @@ -54,7 +54,7 @@ RSpec.describe Api::SalmonController, type: :controller do service = double(call: false) allow(VerifySalmonService).to receive(:new).and_return(service) - post :update, params: { id: account.id }, body: File.read(File.join(Rails.root, 'spec', 'fixtures', 'salmon', 'mention.xml')) + post :update, params: { id: account.id }, body: File.read(Rails.root.join('spec', 'fixtures', 'salmon', 'mention.xml')) end it 'returns http client error' do diff --git a/spec/controllers/api/subscriptions_controller_spec.rb b/spec/controllers/api/subscriptions_controller_spec.rb index b46971a54..7a4252fe6 100644 --- a/spec/controllers/api/subscriptions_controller_spec.rb +++ b/spec/controllers/api/subscriptions_controller_spec.rb @@ -33,7 +33,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do end describe 'POST #update' do - let(:feed) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) } + let(:feed) { File.read(Rails.root.join('spec', 'fixtures', 'push', 'feed.atom')) } before do stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {}) diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb index cf51fe81d..7dfbdb52d 100644 --- a/spec/fabricators/user_fabricator.rb +++ b/spec/fabricators/user_fabricator.rb @@ -2,5 +2,5 @@ Fabricator(:user) do account email { sequence(:email) { |i| "#{i}#{Faker::Internet.email}" } } password "123456789" - confirmed_at { Time.now } + confirmed_at { Time.zone.now } end diff --git a/spec/features/log_in_spec.rb b/spec/features/log_in_spec.rb index ed626d880..53a1f9b12 100644 --- a/spec/features/log_in_spec.rb +++ b/spec/features/log_in_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" feature "Log in" do given(:email) { "test@examle.com" } given(:password) { "password" } - given(:confirmed_at) { Time.now } + given(:confirmed_at) { Time.zone.now } background do Fabricate(:user, email: email, password: password, confirmed_at: confirmed_at) diff --git a/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb index 3bc4b7efb..891871c1c 100644 --- a/spec/lib/ostatus/atom_serializer_spec.rb +++ b/spec/lib/ostatus/atom_serializer_spec.rb @@ -728,9 +728,9 @@ RSpec.describe OStatus::AtomSerializer do it 'appends id element with unique tag' do block = Fabricate(:block) - time_before = Time.now + time_before = Time.zone.now block_salmon = OStatus::AtomSerializer.new.block_salmon(block) - time_after = Time.now + time_after = Time.zone.now expect(block_salmon.id.text).to( eq(OStatus::TagManager.instance.unique_tag(time_before.utc, block.id, 'Block')) @@ -815,9 +815,9 @@ RSpec.describe OStatus::AtomSerializer do it 'appends id element with unique tag' do block = Fabricate(:block) - time_before = Time.now + time_before = Time.zone.now unblock_salmon = OStatus::AtomSerializer.new.unblock_salmon(block) - time_after = Time.now + time_after = Time.zone.now expect(unblock_salmon.id.text).to( eq(OStatus::TagManager.instance.unique_tag(time_before.utc, block.id, 'Block')) @@ -994,9 +994,9 @@ RSpec.describe OStatus::AtomSerializer do it 'appends id element with unique tag' do favourite = Fabricate(:favourite) - time_before = Time.now + time_before = Time.zone.now unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite) - time_after = Time.now + time_after = Time.zone.now expect(unfavourite_salmon.id.text).to( eq(OStatus::TagManager.instance.unique_tag(time_before.utc, favourite.id, 'Favourite')) @@ -1179,9 +1179,9 @@ RSpec.describe OStatus::AtomSerializer do follow = Fabricate(:follow) follow.destroy! - time_before = Time.now + time_before = Time.zone.now unfollow_salmon = OStatus::AtomSerializer.new.unfollow_salmon(follow) - time_after = Time.now + time_after = Time.zone.now expect(unfollow_salmon.id.text).to( eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow.id, 'Follow')) @@ -1327,9 +1327,9 @@ RSpec.describe OStatus::AtomSerializer do it 'appends id element with unique tag' do follow_request = Fabricate(:follow_request) - time_before = Time.now + time_before = Time.zone.now authorize_follow_request_salmon = OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request) - time_after = Time.now + time_after = Time.zone.now expect(authorize_follow_request_salmon.id.text).to( eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow_request.id, 'FollowRequest')) @@ -1396,9 +1396,9 @@ RSpec.describe OStatus::AtomSerializer do it 'appends id element with unique tag' do follow_request = Fabricate(:follow_request) - time_before = Time.now + time_before = Time.zone.now reject_follow_request_salmon = OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request) - time_after = Time.now + time_after = Time.zone.now expect(reject_follow_request_salmon.id.text).to( eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow_request.id, 'FollowRequest')) diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 9d8670129..e8cf18af9 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -154,7 +154,7 @@ RSpec.describe Status, type: :model do describe '#target' do it 'returns nil if the status is self-contained' do - expect(subject.target).to be_nil + expect(subject.target).to be_nil end it 'returns nil if the status is a reply' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 42198cb4d..8c6778edc 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -67,7 +67,7 @@ RSpec.describe User, type: :model do describe 'confirmed' do it 'returns an array of users who are confirmed' do user_1 = Fabricate(:user, confirmed_at: nil) - user_2 = Fabricate(:user, confirmed_at: Time.now) + user_2 = Fabricate(:user, confirmed_at: Time.zone.now) expect(User.confirmed).to match_array([user_2]) end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 79e80220c..1ded751ab 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -72,11 +72,11 @@ RSpec::Sidekiq.configure do |config| end def request_fixture(name) - File.read(File.join(Rails.root, 'spec', 'fixtures', 'requests', name)) + File.read(Rails.root.join('spec', 'fixtures', 'requests', name)) end def attachment_fixture(name) - File.open(File.join(Rails.root, 'spec', 'fixtures', 'files', name)) + File.open(Rails.root.join('spec', 'fixtures', 'files', name)) end def stub_jsonld_contexts! diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index 23c122e59..c66214555 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -19,7 +19,7 @@ RSpec.describe BatchedRemoveStatusService, type: :service do stub_request(:post, 'http://example.com/inbox').to_return(status: 200) Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now) - jeff.user.update(current_sign_in_at: Time.now) + jeff.user.update(current_sign_in_at: Time.zone.now) jeff.follow!(alice) hank.follow!(alice) diff --git a/spec/services/fetch_remote_account_service_spec.rb b/spec/services/fetch_remote_account_service_spec.rb index 20dd505d0..3cd86708b 100644 --- a/spec/services/fetch_remote_account_service_spec.rb +++ b/spec/services/fetch_remote_account_service_spec.rb @@ -19,7 +19,7 @@ RSpec.describe FetchRemoteAccountService, type: :service do end let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } - let(:xml) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'xml', 'mastodon.atom')) } + let(:xml) { File.read(Rails.root.join('spec', 'fixtures', 'xml', 'mastodon.atom')) } shared_examples 'return Account' do it { is_expected.to be_an Account } diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb index 1f26660ed..9d3465f3f 100644 --- a/spec/services/process_feed_service_spec.rb +++ b/spec/services/process_feed_service_spec.rb @@ -4,7 +4,7 @@ RSpec.describe ProcessFeedService, type: :service do subject { ProcessFeedService.new } describe 'processing a feed' do - let(:body) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'xml', 'mastodon.atom')) } + let(:body) { File.read(Rails.root.join('spec', 'fixtures', 'xml', 'mastodon.atom')) } let(:account) { Fabricate(:account, username: 'localhost', domain: 'kickass.zone') } before do diff --git a/spec/services/update_remote_profile_service_spec.rb b/spec/services/update_remote_profile_service_spec.rb index 7ac3a809a..f3ea70b80 100644 --- a/spec/services/update_remote_profile_service_spec.rb +++ b/spec/services/update_remote_profile_service_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe UpdateRemoteProfileService, type: :service do - let(:xml) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) } + let(:xml) { File.read(Rails.root.join('spec', 'fixtures', 'push', 'feed.atom')) } subject { UpdateRemoteProfileService.new } -- cgit From 3d5d899094ecf389ce6cf31a6f33dcf1894e662d Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Mon, 8 Oct 2018 15:00:27 +0200 Subject: Allow selecting both default flavour and theme Fixes #672 --- app/controllers/admin/settings_controller.rb | 9 ++++++++- app/lib/themes.rb | 6 ++++++ app/models/form/admin_settings.rb | 4 ++++ app/views/admin/settings/edit.html.haml | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) (limited to 'app/models') diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index 46e9b2c07..fe2720c48 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -18,6 +18,7 @@ module Admin bootstrap_timeline_accounts flavour skin + flavour_and_skin thumbnail hero mascot @@ -54,7 +55,13 @@ module Admin def update authorize :settings, :update? - settings_params.each do |key, value| + settings = settings_params + flavours_and_skin = settings.delete('flavour_and_skin') + if flavours_and_skin + settings['flavour'], settings['skin'] = flavours_and_skin.split('/', 2) + end + + settings.each do |key, value| if UPLOAD_SETTINGS.include?(key) upload = SiteUpload.where(var: key).first_or_initialize(var: key) upload.update(file: value) diff --git a/app/lib/themes.rb b/app/lib/themes.rb index 55824a5c4..2147904e4 100644 --- a/app/lib/themes.rb +++ b/app/lib/themes.rb @@ -80,4 +80,10 @@ class Themes def skins_for(name) @conf[name]['skin'].keys end + + def flavours_and_skins + flavours.map do |flavour| + [flavour, skins_for(flavour).map{ |skin| [flavour, skin] }] + end + end end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 9ea4ed322..8a39e09b7 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -48,4 +48,8 @@ class Form::AdminSettings :custom_css=, to: Setting ) + + def flavour_and_skin + "#{Setting.flavour}/#{Setting.skin}" + end end diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 361e249e0..b82555534 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -7,7 +7,7 @@ = f.input :site_title, wrapper: :with_label, label: t('admin.settings.site_title') .fields-group - = f.input :flavour, collection: Themes.instance.flavours, label_method: lambda { |flavour| I18n.t("flavours.#{flavour}.name", default: flavour) }, wrapper: :with_label, include_blank: false + = 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, include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last .fields-row .fields-row__column.fields-row__column-6.fields-group -- cgit