diff --git a/Dockerfile b/Dockerfile index 3e54b6555..826e237c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:8.12.0-alpine as node +FROM node:8.14.0-alpine as node FROM ruby:2.4.5-alpine3.8 LABEL maintainer="https://github.com/tootsuite/mastodon" \ diff --git a/Gemfile b/Gemfile index ee2de58c9..63ea35a65 100644 --- a/Gemfile +++ b/Gemfile @@ -40,7 +40,7 @@ end gem 'net-ldap', '~> 0.10' gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' -gem 'omniauth', '~> 1.2' +gem 'omniauth', '~> 1.9' gem 'doorkeeper', '~> 5.0' gem 'fast_blank', '~> 1.0' @@ -58,7 +58,7 @@ gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.1' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.2', require: 'mime/types/columnar' -gem 'nokogiri', '~> 1.8' +gem 'nokogiri', '~> 1.9' gem 'nsa', '~> 0.2' gem 'oj', '~> 3.7' gem 'ostatus2', '~> 2.0' diff --git a/Gemfile.lock b/Gemfile.lock index a5dac694a..99db89693 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -251,7 +251,7 @@ GEM hamster (3.0.0) concurrent-ruby (~> 1.0) hashdiff (0.3.7) - hashie (3.5.7) + hashie (3.6.0) heapy (0.1.4) highline (2.0.0) hiredis (0.6.3) @@ -345,7 +345,7 @@ GEM mime-types-data (3.2018.0812) mimemagic (0.3.2) mini_mime (1.0.1) - mini_portile2 (2.3.0) + mini_portile2 (2.4.0) minitest (5.11.3) msgpack (1.2.4) multi_json (1.13.1) @@ -356,8 +356,8 @@ GEM net-ssh (>= 2.6.5) net-ssh (5.0.2) nio4r (2.3.1) - nokogiri (1.8.5) - mini_portile2 (~> 2.3.0) + nokogiri (1.9.1) + mini_portile2 (~> 2.4.0) nokogumbo (2.0.0) nokogiri (~> 1.8, >= 1.8.4) nsa (0.2.4) @@ -366,8 +366,8 @@ GEM sidekiq (>= 3.5.0) statsd-ruby (~> 1.2.0) oj (3.7.4) - omniauth (1.8.1) - hashie (>= 3.4.6, < 3.6.0) + omniauth (1.9.0) + hashie (>= 3.4.6, < 3.7.0) rack (>= 1.6.2, < 3) omniauth-cas (1.1.1) addressable (~> 2.3) @@ -712,10 +712,10 @@ DEPENDENCIES microformats (~> 4.0) mime-types (~> 3.2) net-ldap (~> 0.10) - nokogiri (~> 1.8) + nokogiri (~> 1.9) nsa (~> 0.2) oj (~> 3.7) - omniauth (~> 1.2) + omniauth (~> 1.9) omniauth-cas (~> 1.1) omniauth-saml (~> 1.10) ostatus2 (~> 2.0) diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index f2190ddf9..cc6cd51f0 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -20,5 +20,9 @@ module Admin def set_pack use_pack 'admin' end + + def set_user + @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) + end end end diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index 8d3477e66..efe7dcbd4 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -25,10 +25,6 @@ module Admin private - def set_user - @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) - end - def check_confirmation if @user.confirmed? flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') diff --git a/app/controllers/admin/resets_controller.rb b/app/controllers/admin/resets_controller.rb index 3e27d01ac..db8f61d64 100644 --- a/app/controllers/admin/resets_controller.rb +++ b/app/controllers/admin/resets_controller.rb @@ -10,11 +10,5 @@ module Admin log_action :reset_password, @user redirect_to admin_accounts_path end - - private - - def set_user - @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) - end end end diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index af7ec0740..13f56e9be 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -17,11 +17,5 @@ module Admin log_action :demote, @user redirect_to admin_account_path(@user.account_id) end - - private - - def set_user - @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) - end end end diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb index 022107203..2577a4b17 100644 --- a/app/controllers/admin/two_factor_authentications_controller.rb +++ b/app/controllers/admin/two_factor_authentications_controller.rb @@ -2,7 +2,7 @@ module Admin class TwoFactorAuthenticationsController < BaseController - before_action :set_user + before_action :set_target_user def destroy authorize @user, :disable_2fa? @@ -13,7 +13,7 @@ module Admin private - def set_user + def set_target_user @user = User.find(params[:user_id]) end end diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb index 1c8ebdac9..e91e784a5 100644 --- a/app/controllers/directories_controller.rb +++ b/app/controllers/directories_controller.rb @@ -37,22 +37,12 @@ class DirectoriesController < ApplicationController end def set_accounts - @accounts = Account.searchable.discoverable.page(params[:page]).per(50).tap do |query| + @accounts = Account.discoverable.page(params[:page]).per(30).tap do |query| query.merge!(Account.tagged_with(@tag.id)) if @tag - - if popular_requested? - query.merge!(Account.popular) - else - query.merge!(Account.by_recent_status) - end end end def set_instance_presenter @instance_presenter = InstancePresenter.new end - - def popular_requested? - request.path.ends_with?('/popular') - end end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 88c7232dd..8e1624ce1 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -6,12 +6,17 @@ class MediaController < ApplicationController before_action :set_media_attachment before_action :verify_permitted_status! + content_security_policy only: :player do |p| + p.frame_ancestors(false) + end + def show redirect_to @media_attachment.file.url(:original) end def player @body_classes = 'player' + response.headers['X-Frame-Options'] = 'ALLOWALL' raise ActiveRecord::RecordNotFound unless @media_attachment.video? || @media_attachment.gifv? end diff --git a/app/javascript/images/screen_federation.svg b/app/javascript/images/screen_federation.svg new file mode 100644 index 000000000..7019a7356 --- /dev/null +++ b/app/javascript/images/screen_federation.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/images/screen_hello.svg b/app/javascript/images/screen_hello.svg new file mode 100644 index 000000000..7bcdd0afd --- /dev/null +++ b/app/javascript/images/screen_hello.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/images/screen_interactions.svg b/app/javascript/images/screen_interactions.svg new file mode 100644 index 000000000..41873371a --- /dev/null +++ b/app/javascript/images/screen_interactions.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index d24f39ad2..4c145febc 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -8,6 +8,7 @@ import { importFetchedStatuses, } from './importer'; import { defineMessages } from 'react-intl'; +import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from '../utils/html'; import { getFilters, regexFromFilters } from '../selectors'; @@ -18,6 +19,8 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; +export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; + export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; @@ -88,10 +91,16 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); +const excludeTypesFromFilter = filter => { + const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']); + return allTypes.filterNot(item => item === filter).toJS(); +}; + const noOp = () => {}; export function expandNotifications({ maxId } = {}, done = noOp) { return (dispatch, getState) => { + const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const notifications = getState().get('notifications'); const isLoadingMore = !!maxId; @@ -102,7 +111,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) { const params = { max_id: maxId, - exclude_types: excludeTypesFromSettings(getState()), + exclude_types: activeFilter === 'all' + ? excludeTypesFromSettings(getState()) + : excludeTypesFromFilter(activeFilter), }; if (!maxId && notifications.get('items').size > 0) { @@ -167,3 +178,14 @@ export function scrollTopNotifications(top) { top, }; }; + +export function setFilter (filterType) { + return dispatch => { + dispatch({ + type: NOTIFICATIONS_FILTER_SET, + path: ['notifications', 'quickFilter', 'active'], + value: filterType, + }); + dispatch(expandNotifications()); + }; +}; diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js index a161c50ef..a1dd3a731 100644 --- a/app/javascript/mastodon/actions/onboarding.js +++ b/app/javascript/mastodon/actions/onboarding.js @@ -1,14 +1,8 @@ -import { openModal } from './modal'; import { changeSetting, saveSettings } from './settings'; -export function showOnboardingOnce() { - return (dispatch, getState) => { - const alreadySeen = getState().getIn(['settings', 'onboarded']); +export const INTRODUCTION_VERSION = 20181216044202; - if (!alreadySeen) { - dispatch(openModal('ONBOARDING')); - dispatch(changeSetting(['onboarded'], true)); - dispatch(saveSettings()); - } - }; +export const closeOnboarding = () => dispatch => { + dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); + dispatch(saveSettings()); }; diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 457508d13..f68e4155e 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -37,6 +37,14 @@ class ColumnHeader extends React.PureComponent { animating: false, }; + historyBack = () => { + if (window.history && window.history.length === 1) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } + } + handleToggleClick = (e) => { e.stopPropagation(); this.setState({ collapsed: !this.state.collapsed, animating: true }); @@ -55,16 +63,22 @@ class ColumnHeader extends React.PureComponent { } handleBackClick = () => { - if (window.history && window.history.length === 1) this.context.router.history.push('/'); - else this.context.router.history.goBack(); + this.historyBack(); } handleTransitionEnd = () => { this.setState({ animating: false }); } + handlePin = () => { + if (!this.props.pinned) { + this.historyBack(); + } + this.props.onPin(); + } + render () { - const { title, icon, active, children, pinned, onPin, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -95,7 +109,7 @@ class ColumnHeader extends React.PureComponent { } if (multiColumn && pinned) { - pinButton = ; + pinButton = ; moveButtons = (
@@ -104,7 +118,7 @@ class ColumnHeader extends React.PureComponent {
); } else if (multiColumn) { - pinButton = ; + pinButton = ; } if (!pinned && (multiColumn || showBackButton)) { diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index b2b0265aa..2912540a0 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -1,11 +1,12 @@ import React from 'react'; -import { Provider } from 'react-redux'; +import { Provider, connect } from 'react-redux'; import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; -import { showOnboardingOnce } from '../actions/onboarding'; +import { INTRODUCTION_VERSION } from '../actions/onboarding'; import { BrowserRouter, Route } from 'react-router-dom'; import { ScrollContext } from 'react-router-scroll-4'; import UI from '../features/ui'; +import Introduction from '../features/introduction'; import { fetchCustomEmojis } from '../actions/custom_emojis'; import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; @@ -18,11 +19,39 @@ addLocaleData(localeData); export const store = configureStore(); const hydrateAction = hydrateStore(initialState); -store.dispatch(hydrateAction); -// load custom emojis +store.dispatch(hydrateAction); store.dispatch(fetchCustomEmojis()); +const mapStateToProps = state => ({ + showIntroduction: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, +}); + +@connect(mapStateToProps) +class MastodonMount extends React.PureComponent { + + static propTypes = { + showIntroduction: PropTypes.bool, + }; + + render () { + const { showIntroduction } = this.props; + + if (showIntroduction) { + return ; + } + + return ( + + + + + + ); + } + +} + export default class Mastodon extends React.PureComponent { static propTypes = { @@ -31,14 +60,6 @@ export default class Mastodon extends React.PureComponent { componentDidMount() { this.disconnect = store.dispatch(connectUserStream()); - - // Desktop notifications - // Ask after 1 minute - if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - window.setTimeout(() => Notification.requestPermission(), 60 * 1000); - } - - store.dispatch(showOnboardingOnce()); } componentWillUnmount () { @@ -54,11 +75,7 @@ export default class Mastodon extends React.PureComponent { return ( - - - - - + ); diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 0d66868ed..96051818b 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -103,7 +103,7 @@ class AccountGallery extends ImmutablePureComponent { ); } - if (hasMore) { + if (hasMore && !(isLoading && medias.size === 0)) { loadOlder = ; } diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 4b56c7fdd..d6add9b0d 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -47,6 +47,7 @@ class ComposeForm extends ImmutablePureComponent { caretPosition: PropTypes.number, preselectDate: PropTypes.instanceOf(Date), is_submitting: PropTypes.bool, + is_changing_upload: PropTypes.bool, is_uploading: PropTypes.bool, onChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, @@ -82,10 +83,10 @@ class ComposeForm extends ImmutablePureComponent { } // Submit disabled: - const { is_submitting, is_uploading, anyMedia } = this.props; + const { is_submitting, is_changing_upload, is_uploading, anyMedia } = this.props; const fulltext = [this.props.spoiler_text, countableText(this.props.text)].join(''); - if (is_submitting || is_uploading || length(fulltext) > maxChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { + if (is_submitting || is_uploading || is_changing_upload || length(fulltext) > maxChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { return; } @@ -161,7 +162,7 @@ class ComposeForm extends ImmutablePureComponent { const { intl, onPaste, showSearch, anyMedia } = this.props; const disabled = this.props.is_submitting; const text = [this.props.spoiler_text, countableText(this.props.text)].join(''); - const disabledButton = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0 && !anyMedia); + const disabledButton = disabled || this.props.is_uploading || this.props.is_changing_upload || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0 && !anyMedia); let publishText = ''; if (this.props.privacy === 'private' || this.props.privacy === 'direct') { diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 5d7fb8852..b4a1c4b44 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -22,6 +22,7 @@ const mapStateToProps = state => ({ caretPosition: state.getIn(['compose', 'caretPosition']), preselectDate: state.getIn(['compose', 'preselectDate']), is_submitting: state.getIn(['compose', 'is_submitting']), + is_changing_upload: state.getIn(['compose', 'is_changing_upload']), is_uploading: state.getIn(['compose', 'is_uploading']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, diff --git a/app/javascript/mastodon/features/introduction/index.js b/app/javascript/mastodon/features/introduction/index.js new file mode 100644 index 000000000..6e0617f72 --- /dev/null +++ b/app/javascript/mastodon/features/introduction/index.js @@ -0,0 +1,196 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ReactSwipeableViews from 'react-swipeable-views'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { closeOnboarding } from '../../actions/onboarding'; +import screenHello from '../../../images/screen_hello.svg'; +import screenFederation from '../../../images/screen_federation.svg'; +import screenInteractions from '../../../images/screen_interactions.svg'; +import logoTransparent from '../../../images/logo_transparent.svg'; + +const FrameWelcome = ({ domain, onNext }) => ( +
+
+ +
+ +
+

+

{domain} }} />

+
+ +
+ +
+
+); + +FrameWelcome.propTypes = { + domain: PropTypes.string.isRequired, + onNext: PropTypes.func.isRequired, +}; + +const FrameFederation = ({ onNext }) => ( +
+
+ +
+ +
+
+

+

+
+ +
+

+

+
+ +
+

+

+
+
+ +
+ +
+
+); + +FrameFederation.propTypes = { + onNext: PropTypes.func.isRequired, +}; + +const FrameInteractions = ({ onNext }) => ( +
+
+ +
+ +
+
+

+

+
+ +
+

+

+
+ +
+

+

+
+
+ +
+ +
+
+); + +FrameInteractions.propTypes = { + onNext: PropTypes.func.isRequired, +}; + +@connect(state => ({ domain: state.getIn(['meta', 'domain']) })) +export default class Introduction extends React.PureComponent { + + static propTypes = { + domain: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + }; + + state = { + currentIndex: 0, + }; + + componentWillMount () { + this.pages = [ + , + , + , + ]; + } + + componentDidMount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + componentWillUnmount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + handleDot = (e) => { + const i = Number(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.setState({ currentIndex: i }); + } + + handlePrev = () => { + this.setState(({ currentIndex }) => ({ + currentIndex: Math.max(0, currentIndex - 1), + })); + } + + handleNext = () => { + const { pages } = this; + + this.setState(({ currentIndex }) => ({ + currentIndex: Math.min(currentIndex + 1, pages.length - 1), + })); + } + + handleSwipe = (index) => { + this.setState({ currentIndex: index }); + } + + handleFinish = () => { + this.props.dispatch(closeOnboarding()); + } + + handleKeyUp = ({ key }) => { + switch (key) { + case 'ArrowLeft': + this.handlePrev(); + break; + case 'ArrowRight': + this.handleNext(); + break; + } + } + + render () { + const { currentIndex } = this.state; + const { pages } = this; + + return ( +
+ + {pages.map((page, i) => ( +
{page}
+ ))} +
+ +
+ {pages.map((_, i) => ( +
+ ))} +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index fcdf5c6e6..a334fd63c 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -21,9 +21,11 @@ export default class ColumnSettings extends React.PureComponent { render () { const { settings, pushSettings, onChange, onClear } = this.props; - const alertStr = ; - const showStr = ; - const soundStr = ; + const filterShowStr = ; + const filterAdvancedStr = ; + const alertStr = ; + const showStr = ; + const soundStr = ; const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); const pushStr = showPushSettings && ; @@ -34,6 +36,16 @@ export default class ColumnSettings extends React.PureComponent {
+
+ + + +
+ + +
+
+
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js new file mode 100644 index 000000000..f95a2c9de --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const tooltips = defineMessages({ + mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, + favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, + boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, + follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, +}); + +export default @injectIntl +class FilterBar extends React.PureComponent { + + static propTypes = { + selectFilter: PropTypes.func.isRequired, + selectedFilter: PropTypes.string.isRequired, + advancedMode: PropTypes.bool.isRequired, + intl: PropTypes.object.isRequired, + }; + + onClick (notificationType) { + return () => this.props.selectFilter(notificationType); + } + + render () { + const { selectedFilter, advancedMode, intl } = this.props; + const renderedElement = !advancedMode ? ( +
+ + +
+ ) : ( +
+ + + + + +
+ ); + return renderedElement; + } + +} diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index e9cef0a7b..a67f26295 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -2,6 +2,7 @@ import { connect } from 'react-redux'; import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting } from '../../../actions/settings'; +import { setFilter } from '../../../actions/notifications'; import { clearNotifications } from '../../../actions/notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; @@ -21,6 +22,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (path, checked) { if (path[0] === 'push') { dispatch(changePushNotifications(path.slice(1), checked)); + } else if (path[0] === 'quickFilter') { + dispatch(changeSetting(['notifications', ...path], checked)); + dispatch(setFilter('all')); } else { dispatch(changeSetting(['notifications', ...path], checked)); } diff --git a/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js b/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js new file mode 100644 index 000000000..4d495c290 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import FilterBar from '../components/filter_bar'; +import { setFilter } from '../../../actions/notifications'; + +const makeMapStateToProps = state => ({ + selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']), + advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']), +}); + +const mapDispatchToProps = (dispatch) => ({ + selectFilter (newActiveFilter) { + dispatch(setFilter(newActiveFilter)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index aa82dbbb9..9430b2050 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -9,6 +9,7 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import NotificationContainer from './containers/notification_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; +import FilterBarContainer from './containers/filter_bar_container'; import { createSelector } from 'reselect'; import { List as ImmutableList } from 'immutable'; import { debounce } from 'lodash'; @@ -20,11 +21,22 @@ const messages = defineMessages({ }); const getNotifications = createSelector([ + state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), + state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), state => state.getIn(['notifications', 'items']), -], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')))); +], (showFilterBar, allowedType, excludedTypes, notifications) => { + if (!showFilterBar || allowedType === 'all') { + // used if user changed the notification settings after loading the notifications from the server + // otherwise a list of notifications will come pre-filtered from the backend + // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category + return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); + } + return notifications.filter(item => item !== null && allowedType === item.get('type')); +}); const mapStateToProps = state => ({ + showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']), notifications: getNotifications(state), isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, @@ -38,6 +50,7 @@ class Notifications extends React.PureComponent { static propTypes = { columnId: PropTypes.string, notifications: ImmutablePropTypes.list.isRequired, + showFilterBar: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, shouldUpdateScroll: PropTypes.func, intl: PropTypes.object.isRequired, @@ -117,12 +130,16 @@ class Notifications extends React.PureComponent { } render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, showFilterBar } = this.props; const pinned = !!columnId; const emptyMessage = ; let scrollableContent = null; + const filterBarContainer = showFilterBar + ? () + : null; + if (isLoading && this.scrollableContent) { scrollableContent = this.scrollableContent; } else if (notifications.size > 0 || hasMore) { @@ -179,7 +196,7 @@ class Notifications extends React.PureComponent { > - + {filterBarContainer} {scrollContainer} ); diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index b3b1ea862..cc2ab6c8c 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -11,7 +11,6 @@ import BoostModal from './boost_modal'; import ConfirmationModal from './confirmation_modal'; import FocalPointModal from './focal_point_modal'; import { - OnboardingModal, MuteModal, ReportModal, EmbedModal, @@ -21,7 +20,6 @@ import { const MODAL_COMPONENTS = { 'MEDIA': () => Promise.resolve({ default: MediaModal }), - 'ONBOARDING': OnboardingModal, 'VIDEO': () => Promise.resolve({ default: VideoModal }), 'BOOST': () => Promise.resolve({ default: BoostModal }), 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js deleted file mode 100644 index 4a5b249c9..000000000 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ /dev/null @@ -1,324 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import ReactSwipeableViews from 'react-swipeable-views'; -import classNames from 'classnames'; -import Permalink from '../../../components/permalink'; -import ComposeForm from '../../compose/components/compose_form'; -import Search from '../../compose/components/search'; -import NavigationBar from '../../compose/components/navigation_bar'; -import ColumnHeader from './column_header'; -import { List as ImmutableList } from 'immutable'; -import { me } from '../../../initial_state'; - -const noop = () => { }; - -const messages = defineMessages({ - home_title: { id: 'column.home', defaultMessage: 'Home' }, - notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' }, - local_title: { id: 'column.community', defaultMessage: 'Local timeline' }, - federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' }, -}); - -const PageOne = ({ acct, domain }) => ( -
-
-

-

-
- -
-
-
- -
- -
- @{acct}@{domain} -
-
- -

-
-
-); - -PageOne.propTypes = { - acct: PropTypes.string.isRequired, - domain: PropTypes.string.isRequired, -}; - -const PageTwo = ({ myAccount }) => ( -
-
-
- - - -
-
- -

-
-); - -PageTwo.propTypes = { - myAccount: ImmutablePropTypes.map.isRequired, -}; - -const PageThree = ({ myAccount }) => ( -
-
- - -
- -
-
- -

#illustration, introductions: #introductions }} />

-

-
-); - -PageThree.propTypes = { - myAccount: ImmutablePropTypes.map.isRequired, -}; - -const PageFour = ({ domain, intl }) => ( -
-
-
-
-
-

-
- -
-
-

-
-
- -
-
-
-
- -
-
-
-
- -

-
-
-); - -PageFour.propTypes = { - domain: PropTypes.string.isRequired, - intl: PropTypes.object.isRequired, -}; - -const PageSix = ({ admin, domain }) => { - let adminSection = ''; - - if (admin) { - adminSection = ( -

- @{admin.get('acct')} }} /> -
- }} /> -

- ); - } - - return ( -
-

- {adminSection} -

GitHub }} />

-

}} />

-

-
- ); -}; - -PageSix.propTypes = { - admin: ImmutablePropTypes.map, - domain: PropTypes.string.isRequired, -}; - -const mapStateToProps = state => ({ - myAccount: state.getIn(['accounts', me]), - admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), - domain: state.getIn(['meta', 'domain']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class OnboardingModal extends React.PureComponent { - - static propTypes = { - onClose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - myAccount: ImmutablePropTypes.map.isRequired, - domain: PropTypes.string.isRequired, - admin: ImmutablePropTypes.map, - }; - - state = { - currentIndex: 0, - }; - - componentWillMount() { - const { myAccount, admin, domain, intl } = this.props; - this.pages = [ - , - , - , - , - , - ]; - }; - - componentDidMount() { - window.addEventListener('keyup', this.handleKeyUp); - } - - componentWillUnmount() { - window.addEventListener('keyup', this.handleKeyUp); - } - - handleSkip = (e) => { - e.preventDefault(); - this.props.onClose(); - } - - handleDot = (e) => { - const i = Number(e.currentTarget.getAttribute('data-index')); - e.preventDefault(); - this.setState({ currentIndex: i }); - } - - handlePrev = () => { - this.setState(({ currentIndex }) => ({ - currentIndex: Math.max(0, currentIndex - 1), - })); - } - - handleNext = () => { - const { pages } = this; - this.setState(({ currentIndex }) => ({ - currentIndex: Math.min(currentIndex + 1, pages.length - 1), - })); - } - - handleSwipe = (index) => { - this.setState({ currentIndex: index }); - } - - handleKeyUp = ({ key }) => { - switch (key) { - case 'ArrowLeft': - this.handlePrev(); - break; - case 'ArrowRight': - this.handleNext(); - break; - } - } - - handleClose = () => { - this.props.onClose(); - } - - render () { - const { pages } = this; - const { currentIndex } = this.state; - const hasMore = currentIndex < pages.length - 1; - - const nextOrDoneBtn = hasMore ? ( - - ) : ( - - ); - - return ( -
- - {pages.map((page, i) => { - const className = classNames('onboarding-modal__page__wrapper', `onboarding-modal__page__wrapper-${i}`, { - 'onboarding-modal__page__wrapper--active': i === currentIndex, - }); - - return ( -
{page}
- ); - })} -
- -
-
- -
- -
- {pages.map((_, i) => { - const className = classNames('onboarding-modal__dot', { - active: i === currentIndex, - }); - - return ( -
- ); - })} -
- -
- {nextOrDoneBtn} -
-
-
- ); - } - -} diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 662375a76..e11235a81 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -294,6 +294,7 @@ class UI extends React.PureComponent { componentWillMount () { window.addEventListener('beforeunload', this.handleBeforeUnload, false); + document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('drop', this.handleDrop, false); @@ -304,8 +305,13 @@ class UI extends React.PureComponent { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } + if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { + window.setTimeout(() => Notification.requestPermission(), 120 * 1000); + } + this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); + setTimeout(() => this.props.dispatch(fetchFilters()), 500); } diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 2a15c052f..235fd2a07 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -102,10 +102,6 @@ export function Mutes () { return import(/* webpackChunkName: "features/mutes" */'../../mutes'); } -export function OnboardingModal () { - return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'); -} - export function MuteModal () { return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index ca5abcb05..d20ac1f88 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -228,6 +228,14 @@ "notification.reblog": "{name} boosted your status", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.filter.all": "All", + "notifications.filter.mentions": "Mentions", + "notifications.filter.favourites": "Favourites", + "notifications.filter.boosts": "Boosts", + "notifications.filter.follows": "Follows", + "notifications.column_settings.filter_bar.category": "Quick filter bar", + "notifications.column_settings.filter_bar.show": "Show", + "notifications.column_settings.filter_bar.advanced": "Display all categories", "notifications.column_settings.alert": "Desktop notifications", "notifications.column_settings.favourite": "Favourites:", "notifications.column_settings.follow": "New followers:", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 237ffd33a..356697024 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -228,6 +228,14 @@ "notification.reblog": "{name} podbił(a) Twój wpis", "notifications.clear": "Wyczyść powiadomienia", "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?", + "notifications.filter.all": "Wszystkie", + "notifications.filter.mentions": "Wspomnienia", + "notifications.filter.favourites": "Ulubione", + "notifications.filter.boosts": "Podbicia", + "notifications.filter.follows": "Śledzenia", + "notifications.column_settings.filter_bar.category": "Szybkie filtrowanie", + "notifications.column_settings.filter_bar.show": "Pokaż", + "notifications.column_settings.filter_bar.advanced": "Wyświetl wszystkie kategorie", "notifications.column_settings.alert": "Powiadomienia na pulpicie", "notifications.column_settings.favourite": "Dodanie do ulubionych:", "notifications.column_settings.follow": "Nowi śledzący:", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 67d55f66f..1622871b8 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -51,6 +51,7 @@ const initialState = ImmutableMap({ in_reply_to: null, is_composing: false, is_submitting: false, + is_changing_upload: false, is_uploading: false, progress: 0, media_attachments: ImmutableList(), @@ -79,6 +80,7 @@ function clearAll(state) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('is_submitting', false); + map.set('is_changing_upload', false); map.set('in_reply_to', null); map.set('privacy', state.get('default_privacy')); map.set('sensitive', false); @@ -248,13 +250,15 @@ export default function compose(state = initialState, action) { map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: - case COMPOSE_UPLOAD_CHANGE_REQUEST: return state.set('is_submitting', true); + case COMPOSE_UPLOAD_CHANGE_REQUEST: + return state.set('is_changing_upload', true); case COMPOSE_SUBMIT_SUCCESS: return clearAll(state); case COMPOSE_SUBMIT_FAIL: - case COMPOSE_UPLOAD_CHANGE_FAIL: return state.set('is_submitting', false); + case COMPOSE_UPLOAD_CHANGE_FAIL: + return state.set('is_changing_upload', false); case COMPOSE_UPLOAD_REQUEST: return state.set('is_uploading', true); case COMPOSE_UPLOAD_SUCCESS: @@ -300,7 +304,7 @@ export default function compose(state = initialState, action) { return insertEmoji(state, action.position, action.emoji, action.needsSpace); case COMPOSE_UPLOAD_CHANGE_SUCCESS: return state - .set('is_submitting', false) + .set('is_changing_upload', false) .update('media_attachments', list => list.map(item => { if (item.get('id') === action.media.id) { return fromJS(action.media); diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index d71ae00ae..19a02f5b1 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -3,6 +3,7 @@ import { NOTIFICATIONS_EXPAND_SUCCESS, NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_FILTER_SET, NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, } from '../actions/notifications'; @@ -98,6 +99,8 @@ export default function notifications(state = initialState, action) { return state.set('isLoading', true); case NOTIFICATIONS_EXPAND_FAIL: return state.set('isLoading', false); + case NOTIFICATIONS_FILTER_SET: + return state.set('items', ImmutableList()).set('hasMore', true); case NOTIFICATIONS_SCROLL_TOP: return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 12bcc2583..2e1878cf7 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -1,4 +1,5 @@ import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings'; +import { NOTIFICATIONS_FILTER_SET } from '../actions/notifications'; import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns'; import { STORE_HYDRATE } from '../actions/store'; import { EMOJI_USE } from '../actions/emojis'; @@ -32,6 +33,12 @@ const initialState = ImmutableMap({ mention: true, }), + quickFilter: ImmutableMap({ + active: 'all', + show: true, + advanced: false, + }), + shows: ImmutableMap({ follow: true, favourite: true, @@ -112,6 +119,7 @@ export default function settings(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: return hydrate(state, action.state.get('settings')); + case NOTIFICATIONS_FILTER_SET: case SETTING_CHANGE: return state .setIn(action.path, action.value) diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index 0990a4f25..4bce74187 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -16,6 +16,7 @@ @import 'mastodon/stream_entries'; @import 'mastodon/boost'; @import 'mastodon/components'; +@import 'mastodon/introduction'; @import 'mastodon/modal'; @import 'mastodon/emoji_picker'; @import 'mastodon/about'; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 308429573..61e330a26 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2107,6 +2107,7 @@ a.account__display-name { &__append { flex: 1 1 auto; position: relative; + min-height: 120px; } } @@ -2900,7 +2901,6 @@ a.status-card.compact:hover { transform: translateX(-50%); margin: 82px 0 0 50%; white-space: nowrap; - animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); } } @@ -2909,11 +2909,20 @@ a.status-card.compact:hover { top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 0; - height: 0; + width: 42px; + height: 42px; box-sizing: border-box; + background-color: transparent; border: 0 solid lighten($ui-base-color, 26%); + border-width: 6px; border-radius: 50%; +} + +.no-reduce-motion .loading-indicator span { + animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); +} + +.no-reduce-motion .loading-indicator__figure { animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000); } @@ -3789,25 +3798,6 @@ a.status-card.compact:hover { flex-direction: column; } -.onboarding-modal__pager { - height: 80vh; - width: 80vw; - max-width: 520px; - max-height: 470px; - - .react-swipeable-view-container > div { - width: 100%; - height: 100%; - box-sizing: border-box; - display: none; - flex-direction: column; - align-items: center; - justify-content: center; - display: flex; - user-select: text; - } -} - .error-modal__body { height: 80vh; width: 80vw; @@ -3841,22 +3831,6 @@ a.status-card.compact:hover { text-align: center; } -@media screen and (max-width: 550px) { - .onboarding-modal { - width: 100%; - height: 100%; - border-radius: 0; - } - - .onboarding-modal__pager { - width: 100%; - height: auto; - max-width: none; - max-height: none; - flex: 1 1 auto; - } -} - .onboarding-modal__paginator, .error-modal__footer { flex: 0 0 auto; @@ -3905,124 +3879,6 @@ a.status-card.compact:hover { justify-content: center; } -.onboarding-modal__dots { - flex: 1 1 auto; - display: flex; - align-items: center; - justify-content: center; -} - -.onboarding-modal__dot { - width: 14px; - height: 14px; - border-radius: 14px; - background: darken($ui-secondary-color, 16%); - margin: 0 3px; - cursor: pointer; - - &:hover { - background: darken($ui-secondary-color, 18%); - } - - &.active { - cursor: default; - background: darken($ui-secondary-color, 24%); - } -} - -.onboarding-modal__page__wrapper { - pointer-events: none; - padding: 25px; - padding-bottom: 0; - - &.onboarding-modal__page__wrapper--active { - pointer-events: auto; - } -} - -.onboarding-modal__page { - cursor: default; - line-height: 21px; - - h1 { - font-size: 18px; - font-weight: 500; - color: $inverted-text-color; - margin-bottom: 20px; - } - - a { - color: $highlight-text-color; - - &:hover, - &:focus, - &:active { - color: lighten($highlight-text-color, 4%); - } - } - - .navigation-bar a { - color: inherit; - } - - p { - font-size: 16px; - color: $lighter-text-color; - margin-top: 10px; - margin-bottom: 10px; - - &:last-child { - margin-bottom: 0; - } - - strong { - font-weight: 500; - background: $ui-base-color; - color: $secondary-text-color; - border-radius: 4px; - font-size: 14px; - padding: 3px 6px; - - @each $lang in $cjk-langs { - &:lang(#{$lang}) { - font-weight: 700; - } - } - } - } -} - -.onboarding-modal__page__wrapper-0 { - background: url('~images/elephant_ui_greeting.svg') no-repeat left bottom / auto 250px; - height: 100%; - padding: 0; -} - -.onboarding-modal__page-one { - &__lead { - padding: 65px; - padding-top: 45px; - padding-bottom: 0; - margin-bottom: 10px; - - h1 { - font-size: 26px; - line-height: 36px; - margin-bottom: 8px; - } - - p { - margin-bottom: 0; - } - } - - &__extra { - padding-right: 65px; - padding-left: 185px; - text-align: center; - } -} - .display-case { text-align: center; font-size: 15px; @@ -4045,92 +3901,6 @@ a.status-card.compact:hover { } } -.onboarding-modal__page-two, -.onboarding-modal__page-three, -.onboarding-modal__page-four, -.onboarding-modal__page-five { - p { - text-align: left; - } - - .figure { - background: darken($ui-base-color, 8%); - color: $secondary-text-color; - margin-bottom: 20px; - border-radius: 4px; - padding: 10px; - text-align: center; - font-size: 14px; - box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3); - - .onboarding-modal__image { - border-radius: 4px; - margin-bottom: 10px; - } - - &.non-interactive { - pointer-events: none; - text-align: left; - } - } -} - -.onboarding-modal__page-four__columns { - .row { - display: flex; - margin-bottom: 20px; - - & > div { - flex: 1 1 0; - margin: 0 10px; - - &:first-child { - margin-left: 0; - } - - &:last-child { - margin-right: 0; - } - - p { - text-align: center; - } - } - - &:last-child { - margin-bottom: 0; - } - } - - .column-header { - color: $primary-text-color; - } -} - -@media screen and (max-width: 320px) and (max-height: 600px) { - .onboarding-modal__page p { - font-size: 14px; - line-height: 20px; - } - - .onboarding-modal__page-two .figure, - .onboarding-modal__page-three .figure, - .onboarding-modal__page-four .figure, - .onboarding-modal__page-five .figure { - font-size: 12px; - margin-bottom: 10px; - } - - .onboarding-modal__page-four__columns .row { - margin-bottom: 10px; - } - - .onboarding-modal__page-four__columns .column-header { - padding: 5px; - font-size: 12px; - } -} - .onboard-sliders { display: inline-block; max-width: 30px; @@ -5030,12 +4800,19 @@ a.status-card.compact:hover { } } +.notification__filter-bar, .account__section-headline { background: darken($ui-base-color, 4%); border-bottom: 1px solid lighten($ui-base-color, 8%); cursor: default; display: flex; + button { + background: darken($ui-base-color, 4%); + border: 0; + } + + button, a { display: block; flex: 1 1 auto; diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index 44fc1e538..8de53ca98 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -294,6 +294,12 @@ text-decoration: underline; color: $primary-text-color; } + + @media screen and (max-width: $no-gap-breakpoint) { + &.optional { + display: none; + } + } } .nav-button { diff --git a/app/javascript/styles/mastodon/introduction.scss b/app/javascript/styles/mastodon/introduction.scss new file mode 100644 index 000000000..222d8f60e --- /dev/null +++ b/app/javascript/styles/mastodon/introduction.scss @@ -0,0 +1,153 @@ +.introduction { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + @media screen and (max-width: 920px) { + background: darken($ui-base-color, 8%); + display: block !important; + } + + &__pager { + background: darken($ui-base-color, 8%); + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + overflow: hidden; + } + + &__pager, + &__frame { + border-radius: 10px; + width: 50vw; + min-width: 920px; + + @media screen and (max-width: 920px) { + min-width: 0; + width: 100%; + border-radius: 0; + box-shadow: none; + } + } + + &__frame-wrapper { + opacity: 0; + transition: opacity 500ms linear; + + &.active { + opacity: 1; + transition: opacity 50ms linear; + } + } + + &__frame { + overflow: hidden; + } + + &__illustration { + height: 50vh; + + @media screen and (max-width: 630px) { + height: auto; + } + + img { + object-fit: cover; + display: block; + margin: 0; + width: 100%; + height: 100%; + } + } + + &__text { + border-top: 2px solid $ui-highlight-color; + + &--columnized { + display: flex; + + & > div { + flex: 1 1 33.33%; + text-align: center; + padding: 25px; + padding-bottom: 30px; + } + + @media screen and (max-width: 630px) { + display: block; + padding: 15px 0; + padding-bottom: 20px; + + & > div { + padding: 10px 25px; + } + } + } + + h3 { + font-size: 24px; + line-height: 1.5; + font-weight: 700; + margin-bottom: 10px; + } + + p { + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: $darker-text-color; + + code { + display: inline-block; + background: darken($ui-base-color, 8%); + font-size: 15px; + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 2px; + padding: 1px 3px; + } + } + + &--centered { + padding: 25px; + padding-bottom: 30px; + text-align: center; + } + } + + &__dots { + display: flex; + align-items: center; + justify-content: center; + padding: 25px; + + @media screen and (max-width: 630px) { + display: none; + } + } + + &__dot { + width: 14px; + height: 14px; + border-radius: 14px; + border: 1px solid $ui-highlight-color; + background: transparent; + margin: 0 3px; + cursor: pointer; + + &:hover { + background: lighten($ui-base-color, 8%); + } + + &.active { + cursor: default; + background: $ui-highlight-color; + } + } + + &__action { + padding: 25px; + padding-top: 0; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index c863e3b4f..87e633c70 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -229,18 +229,6 @@ margin-bottom: 10px; } -.moved-account-widget, -.memoriam-widget, -.box-widget, -.contact-widget, -.landing-page__information.contact-widget { - @media screen and (max-width: $no-gap-breakpoint) { - margin-bottom: 0; - box-shadow: none; - border-radius: 0; - } -} - .page-header { background: lighten($ui-base-color, 8%); box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); @@ -261,11 +249,20 @@ font-size: 15px; color: $darker-text-color; } + + @media screen and (max-width: $no-gap-breakpoint) { + margin-top: 0; + background: lighten($ui-base-color, 4%); + + h1 { + font-size: 24px; + } + } } .directory { background: $ui-base-color; - border-radius: 0 0 4px 4px; + border-radius: 4px; box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); &__tag { @@ -407,4 +404,24 @@ font-size: 14px; } } + + @media screen and (max-width: $no-gap-breakpoint) { + tbody td.optional { + display: none; + } + } +} + +.moved-account-widget, +.memoriam-widget, +.box-widget, +.contact-widget, +.landing-page__information.contact-widget, +.directory, +.page-header { + @media screen and (max-width: $no-gap-breakpoint) { + margin-bottom: 0; + box-shadow: none; + border-radius: 0; + } } diff --git a/app/models/account.rb b/app/models/account.rb index 71264bc9f..60ad0788e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -95,10 +95,10 @@ class Account < ApplicationRecord scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :searchable, -> { where(suspended: false).where(moved_to_account_id: nil) } - scope :discoverable, -> { where(silenced: false).where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) } + scope :discoverable, -> { searchable.where(silenced: false).where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)).by_recent_status } scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) } - scope :popular, -> { order('account_stats.followers_count desc') } scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) } + scope :popular, -> { order('account_stats.followers_count desc') } delegate :email, :unconfirmed_email, diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb index d19b20c48..b57807d1c 100644 --- a/app/models/web/push_subscription.rb +++ b/app/models/web/push_subscription.rb @@ -68,6 +68,9 @@ class Web::PushSubscription < ApplicationRecord p256dh: key_p256dh, auth: key_auth, ttl: ttl, + ssl_timeout: 10, + open_timeout: 10, + read_timeout: 10, vapid: { subject: "mailto:#{::Setting.site_contact_email}", private_key: Rails.configuration.x.vapid_private_key, diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 38c578de2..7979c312e 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -137,7 +137,8 @@ class FetchLinkCardService < BaseService detector.strip_tags = true guess = detector.detect(@html, @html_charset) - page = Nokogiri::HTML(@html, nil, guess&.fetch(:encoding, nil)) + encoding = guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil + page = Nokogiri::HTML(@html, nil, encoding) player_url = meta_property(page, 'twitter:player') if player_url && !bad_url?(Addressable::URI.parse(player_url)) diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml index f70eb964a..88706def7 100644 --- a/app/views/directories/index.html.haml +++ b/app/views/directories/index.html.haml @@ -16,10 +16,6 @@ .grid .column-0 - .account__section-headline - = active_link_to t('directories.most_recently_active'), @tag ? explore_hashtag_path(@tag) : explore_path - = active_link_to t('directories.most_popular'), @tag ? explore_hashtag_popular_path(@tag) : explore_popular_path - - if @accounts.empty? = nothing_here - else @@ -29,10 +25,10 @@ - @accounts.each do |account| %tr %td= account_link_to account - %td.accounts-table__count + %td.accounts-table__count.optional = number_to_human account.statuses_count, strip_insignificant_zeros: true %small= t('accounts.posts', count: account.statuses_count).downcase - %td.accounts-table__count + %td.accounts-table__count.optional = number_to_human account.followers_count, strip_insignificant_zeros: true %small= t('accounts.followers', count: account.followers_count).downcase %td.accounts-table__count diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml index 5545df54c..c1c0f4b87 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -7,9 +7,9 @@ = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' - if Setting.profile_directory - = link_to t('directories.directory'), explore_path, class: 'nav-link' - = link_to t('about.about_this'), about_more_path, class: 'nav-link' - = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link' + = link_to t('directories.directory'), explore_path, class: 'nav-link optional' + = link_to t('about.about_this'), about_more_path, class: 'nav-link optional' + = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional' .nav-center .nav-right - if user_signed_in? diff --git a/app/workers/web/push_notification_worker.rb b/app/workers/web/push_notification_worker.rb index 4a40e5c8b..8e8a35973 100644 --- a/app/workers/web/push_notification_worker.rb +++ b/app/workers/web/push_notification_worker.rb @@ -10,8 +10,8 @@ class Web::PushNotificationWorker notification = Notification.find(notification_id) subscription.push(notification) unless notification.activity.nil? - rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription - subscription.destroy! + rescue Webpush::ResponseError => e + subscription.destroy! if (400..499).cover?(e.response.code.to_i) rescue ActiveRecord::RecordNotFound true end diff --git a/config/locales/ar.yml b/config/locales/ar.yml index eda99e24c..4de1e4e26 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -541,7 +541,6 @@ ar: warning_title: توافر المحتوى المنشور و المبعثَر directories: explore_mastodon: استكشف %{title} - most_popular: المشهورة errors: '403': ليس لك الصلاحيات الكافية لعرض هذه الصفحة. '404': إنّ الصفحة التي تبحث عنها لا وجود لها أصلا. diff --git a/config/locales/co.yml b/config/locales/co.yml index d2dcef9a4..80d2decd3 100644 --- a/config/locales/co.yml +++ b/config/locales/co.yml @@ -531,8 +531,6 @@ co: directory: Annuariu di i prufili explanation: Scopre utilizatori à partesi di i so centri d'interessu explore_mastodon: Scopre à %{title} - most_popular: I più pupulari - most_recently_active: Attività a più fresca people: one: "%{count} persona" other: "%{count} persone" diff --git a/config/locales/cs.yml b/config/locales/cs.yml index a5a3c0184..1bba55f0f 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -536,8 +536,6 @@ cs: directory: Adresář profilů explanation: Objevujte uživatele podle jejich zájmů explore_mastodon: Prozkoumejte %{title} - most_popular: Nejpopulárnější - most_recently_active: Naposledy aktivní people: few: "%{count} lidé" one: "%{count} člověk" diff --git a/config/locales/el.yml b/config/locales/el.yml index 342cad91c..9d41f353f 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -531,8 +531,6 @@ el: directory: Κατάλογος λογαριασμών explanation: Βρες χρήστες βάσει των ενδιαφερόντων τους explore_mastodon: Εξερεύνησε %{title} - most_popular: Δημοφιλείς - most_recently_active: Πρόσφατα ενεργοί people: one: "%{count} άτομο" other: "%{count} άτομα" diff --git a/config/locales/en.yml b/config/locales/en.yml index ca139a35e..d6f071c78 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -535,8 +535,6 @@ en: directory: Profile directory explanation: Discover users based on their interests explore_mastodon: Explore %{title} - most_popular: Most popular - most_recently_active: Most recently active people: one: "%{count} person" other: "%{count} people" diff --git a/config/locales/eu.yml b/config/locales/eu.yml index 15307c76e..c96438bc3 100644 --- a/config/locales/eu.yml +++ b/config/locales/eu.yml @@ -531,8 +531,6 @@ eu: directory: Profilen direktorioa explanation: Deskubritu erabiltzaileak interesen arabera explore_mastodon: Esploratu %{title} - most_popular: Puri-purian - most_recently_active: Azkenaldian aktibo people: one: pertsona %{count} other: "%{count} pertsona" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index de3070e8a..c171d9342 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -531,8 +531,6 @@ fr: directory: Annuaire des profils explanation: Découvrir des utilisateurs en se basant sur leurs centres d'intérêt explore_mastodon: Explorer %{title} - most_popular: Les plus populaires - most_recently_active: Les actifs les plus récents people: one: "%{count} personne" other: "%{count} personne" diff --git a/config/locales/gl.yml b/config/locales/gl.yml index 29d03859d..7c4b6e5ed 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -531,8 +531,6 @@ gl: directory: Directorio de perfil explanation: Descubra usuarias según o seu interese explore_mastodon: Explorar %{title} - most_popular: Máis popular - most_recently_active: Máis activa recentemente people: one: "%{count} persoa" other: "%{count} persoas" diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 06c8d28d7..21e4236a8 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -530,8 +530,6 @@ ja: directories: directory: ディレクトリ explore_mastodon: "%{title}を探索" - most_popular: 人気順 - most_recently_active: 直近の活動順 people: one: "%{count} 人" other: "%{count} 人" diff --git a/config/locales/nl.yml b/config/locales/nl.yml index b1a34efbd..8c654034c 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -531,8 +531,6 @@ nl: directory: Gebruikersgids explanation: Ontdek gebruikers aan de hand van hun interesses explore_mastodon: "%{title} verkennen" - most_popular: Meest populair - most_recently_active: Recentelijk actief people: one: "%{count} gebruikers" other: "%{count} gebruikers" diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 9e798067f..b65e02210 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -587,8 +587,6 @@ oc: directory: Annuari de perfils explanation: Trobar d’utilizaires segon lor interèsses explore_mastodon: Explorar %{title} - most_popular: Mai populars - most_recently_active: Mai actius recentament people: one: "%{count} persona" other: "%{count} personas" diff --git a/config/locales/pl.yml b/config/locales/pl.yml index e0bf082b2..0e4498eee 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -541,8 +541,6 @@ pl: directory: Katalog profilów explanation: Poznaj profile na podstawie zainteresowań explore_mastodon: Odkrywaj %{title} - most_popular: Napopularniejsi - most_recently_active: Ostatnio aktywni people: few: "%{count} osoby" many: "%{count} osób" diff --git a/config/locales/sk.yml b/config/locales/sk.yml index 135fee51f..25d157155 100644 --- a/config/locales/sk.yml +++ b/config/locales/sk.yml @@ -536,8 +536,6 @@ sk: directory: Databáza profilov explanation: Pátraj po užívateľoch podľa ich záujmov explore_mastodon: Prebádaj %{title} - most_popular: Najpopulárnejšie - most_recently_active: Naposledy aktívni people: few: "%{count} ľudia" one: "%{count} človek" diff --git a/config/routes.rb b/config/routes.rb index c0cfbf465..918c69cc2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,9 +81,7 @@ Rails.application.routes.draw do post '/interact/:id', to: 'remote_interaction#create' get '/explore', to: 'directories#index', as: :explore - get '/explore/popular', to: 'directories#index', as: :explore_popular get '/explore/:id', to: 'directories#show', as: :explore_hashtag - get '/explore/:id/popular', to: 'directories#show', as: :explore_hashtag_popular namespace :settings do resource :profile, only: [:show, :update] diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb index 9f7870bcd..b21968223 100644 --- a/lib/mastodon/accounts_cli.rb +++ b/lib/mastodon/accounts_cli.rb @@ -309,8 +309,8 @@ module Mastodon end old_key = account.private_key - new_key = OpenSSL::PKey::RSA.new(2048).to_pem - account.update(private_key: new_key) + new_key = OpenSSL::PKey::RSA.new(2048) + account.update(private_key: new_key.to_pem, public_key: new_key.public_key.to_pem) ActivityPub::UpdateDistributionWorker.perform_in(delay, account.id, sign_with: old_key) end end diff --git a/spec/controllers/api/v1/accounts/pins_controller_spec.rb b/spec/controllers/api/v1/accounts/pins_controller_spec.rb new file mode 100644 index 000000000..c71935df2 --- /dev/null +++ b/spec/controllers/api/v1/accounts/pins_controller_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Accounts::PinsController, type: :controller do + let(:john) { Fabricate(:user, account: Fabricate(:account, username: 'john')) } + let(:kevin) { Fabricate(:user, account: Fabricate(:account, username: 'kevin')) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: john.id, scopes: 'write:accounts') } + + before do + kevin.account.followers << john.account + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'POST #create' do + subject { post :create, params: { account_id: kevin.account.id } } + + it 'returns 200' do + expect(response).to have_http_status(200) + end + + it 'creates account_pin' do + expect do + subject + end.to change { AccountPin.where(account: john.account, target_account: kevin.account).count }.by(1) + end + end + + describe 'DELETE #destroy' do + subject { delete :destroy, params: { account_id: kevin.account.id } } + + before do + Fabricate(:account_pin, account: john.account, target_account: kevin.account) + end + + it 'returns 200' do + expect(response).to have_http_status(200) + end + + it 'destroys account_pin' do + expect do + subject + end.to change { AccountPin.where(account: john.account, target_account: kevin.account).count }.by(-1) + end + end +end diff --git a/spec/controllers/api/v1/endorsements_controller_spec.rb b/spec/controllers/api/v1/endorsements_controller_spec.rb new file mode 100644 index 000000000..ad5ff400f --- /dev/null +++ b/spec/controllers/api/v1/endorsements_controller_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::EndorsementsController, type: :controller do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') } + + describe 'GET #index' do + it 'returns 200' do + allow(controller).to receive(:doorkeeper_token) { token } + get :index + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/controllers/api/v1/instances/activity_controller_spec.rb b/spec/controllers/api/v1/instances/activity_controller_spec.rb new file mode 100644 index 000000000..159792ee0 --- /dev/null +++ b/spec/controllers/api/v1/instances/activity_controller_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Instances::ActivityController, type: :controller do + describe 'GET #show' do + it 'returns 200' do + get :show + expect(response).to have_http_status(200) + end + + context '!Setting.activity_api_enabled' do + it 'returns 404' do + Setting.activity_api_enabled = false + + get :show + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/controllers/api/v1/instances/peers_controller_spec.rb b/spec/controllers/api/v1/instances/peers_controller_spec.rb new file mode 100644 index 000000000..12a214a83 --- /dev/null +++ b/spec/controllers/api/v1/instances/peers_controller_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Instances::PeersController, type: :controller do + describe 'GET #index' do + it 'returns 200' do + get :index + expect(response).to have_http_status(200) + end + + context '!Setting.peers_api_enabled' do + it 'returns 404' do + Setting.peers_api_enabled = false + + get :index + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/controllers/api/v1/timelines/direct_controller_spec.rb b/spec/controllers/api/v1/timelines/direct_controller_spec.rb new file mode 100644 index 000000000..a22c2cbea --- /dev/null +++ b/spec/controllers/api/v1/timelines/direct_controller_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Timelines::DirectController, type: :controller do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') } + + describe 'GET #show' do + it 'returns 200' do + allow(controller).to receive(:doorkeeper_token) { token } + get :show + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/controllers/concerns/accountable_concern_spec.rb b/spec/controllers/concerns/accountable_concern_spec.rb new file mode 100644 index 000000000..e3c06b494 --- /dev/null +++ b/spec/controllers/concerns/accountable_concern_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AccountableConcern do + class Hoge + include AccountableConcern + attr_reader :current_account + + def initialize(current_account) + @current_account = current_account + end + end + + let(:user) { Fabricate(:user, account: Fabricate(:account)) } + let(:target) { Fabricate(:user, account: Fabricate(:account)) } + let(:hoge) { Hoge.new(user.account) } + + describe '#log_action' do + it 'creates Admin::ActionLog' do + expect do + hoge.log_action(:create, target.account) + end.to change { Admin::ActionLog.count }.by(1) + end + end +end diff --git a/spec/fixtures/requests/windows-1251.txt b/spec/fixtures/requests/windows-1251.txt new file mode 100644 index 000000000..f573e28b2 --- /dev/null +++ b/spec/fixtures/requests/windows-1251.txt @@ -0,0 +1,17 @@ +HTTP/1.1 200 OK +server: nginx +date: Wed, 12 Dec 2018 13:14:03 GMT +content-type: text/html +content-length: 190 +accept-ranges: bytes + + + + + + + + +

+ + diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb index 88c5339db..50c60aafd 100644 --- a/spec/services/fetch_link_card_service_spec.rb +++ b/spec/services/fetch_link_card_service_spec.rb @@ -17,6 +17,8 @@ RSpec.describe FetchLinkCardService, type: :service do stub_request(:head, 'https://github.com/qbi/WannaCry').to_return(status: 404) stub_request(:head, 'http://example.com/test-').to_return(status: 200, headers: { 'Content-Type' => 'text/html' }) stub_request(:get, 'http://example.com/test-').to_return(request_fixture('idn.txt')) + stub_request(:head, 'http://example.com/windows-1251').to_return(status: 200, headers: { 'Content-Type' => 'text/html' }) + stub_request(:get, 'http://example.com/windows-1251').to_return(request_fixture('windows-1251.txt')) subject.call(status) end @@ -57,6 +59,15 @@ RSpec.describe FetchLinkCardService, type: :service do end end + context do + let(:status) { Fabricate(:status, text: 'Check out http://example.com/windows-1251') } + + it 'works with windows-1251' do + expect(a_request(:get, 'http://example.com/windows-1251')).to have_been_made.at_least_once + expect(status.preview_cards.first.title).to eq('сэмпл текст') + end + end + context do let(:status) { Fabricate(:status, text: 'テストhttp://example.com/日本語') }