Merge pull request #1251 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
remotes/1727458204337373841/tmp_refs/heads/signup-info-prompt
ThibG 2019-12-08 21:27:36 +01:00 committed by GitHub
commit 246addd5b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 1134 additions and 488 deletions

View File

View File

@ -27,10 +27,10 @@ plugins:
enabled: true
eslint:
enabled: true
channel: eslint-5
channel: eslint-6
rubocop:
enabled: true
channel: rubocop-0-71
channel: rubocop-0-76
sass-lint:
enabled: true
exclude_patterns:

View File

@ -183,6 +183,8 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io
# LDAP_BIND_DN=
# LDAP_PASSWORD=
# LDAP_UID=cn
# LDAP_MAIL=mail
# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email}))
# LDAP_UID_CONVERSION_ENABLED=true
# LDAP_UID_CONVERSION_SEARCH=., -
# LDAP_UID_CONVERSION_REPLACE=_

View File

@ -203,7 +203,8 @@ STREAMING_CLUSTER_NUM=1
# LDAP_BIND_DN=
# LDAP_PASSWORD=
# LDAP_UID=cn
# LDAP_SEARCH_FILTER=%{uid}=%{email}
# LDAP_MAIL=mail
# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email}))
# LDAP_UID_CONVERSION_ENABLED=true
# LDAP_UID_CONVERSION_SEARCH=., -
# LDAP_UID_CONVERSION_REPLACE=_

View File

@ -71,6 +71,9 @@ Naming/MemoizedInstanceVariableName:
Rails:
Enabled: true
Rails/EnumHash:
Enabled: false
Rails/HasAndBelongsToMany:
Enabled: false
@ -102,6 +105,9 @@ Style/Documentation:
Style/DoubleNegation:
Enabled: true
Style/FormatStringToken:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: true

12
Gemfile
View File

@ -5,7 +5,7 @@ ruby '>= 2.4.0', '< 2.7.0'
gem 'pkg-config', '~> 1.4'
gem 'puma', '~> 4.2'
gem 'puma', '~> 4.3'
gem 'rails', '~> 5.2.3'
gem 'thor', '~> 0.20'
@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.4'
gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.55', require: false
gem 'aws-sdk-s3', '~> 1.57', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@ -91,7 +91,7 @@ gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.2.0'
gem 'strong_migrations', '~> 0.4'
gem 'tty-command', '~> 0.9', require: false
gem 'tty-prompt', '~> 0.19', require: false
gem 'tty-prompt', '~> 0.20', require: false
gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2019'
gem 'webpacker', '~> 4.2'
@ -104,7 +104,7 @@ gem 'rdf-normalize', '~> 0.3'
gem 'redcarpet', '~> 3.4'
group :development, :test do
gem 'fabrication', '~> 2.20'
gem 'fabrication', '~> 2.21'
gem 'fuubar', '~> 2.5'
gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.7'
@ -119,7 +119,7 @@ end
group :test do
gem 'capybara', '~> 3.29'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.7'
gem 'faker', '~> 2.8'
gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
@ -138,7 +138,7 @@ group :development do
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', '~> 0.76', require: false
gem 'rubocop-rails', '~> 2.3', require: false
gem 'rubocop-rails', '~> 2.4', require: false
gem 'brakeman', '~> 4.7', require: false
gem 'bundler-audit', '~> 0.6', require: false

View File

@ -105,16 +105,16 @@ GEM
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.3)
aws-partitions (1.240.0)
aws-sdk-core (3.78.0)
aws-partitions (1.246.0)
aws-sdk-core (3.82.0)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.25.0)
aws-sdk-kms (1.26.0)
aws-sdk-core (~> 3, >= 3.71.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.55.0)
aws-sdk-s3 (1.57.0)
aws-sdk-core (~> 3, >= 3.77.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
@ -132,7 +132,7 @@ GEM
ffi (~> 1.10.0)
bootsnap (1.4.5)
msgpack (~> 1.0)
brakeman (4.7.1)
brakeman (4.7.2)
browser (2.7.1)
builder (3.2.3)
bullet (6.0.2)
@ -239,8 +239,8 @@ GEM
et-orbi (1.1.6)
tzinfo
excon (0.62.0)
fabrication (2.20.2)
faker (2.7.0)
fabrication (2.21.0)
faker (2.8.0)
i18n (>= 1.6, < 1.8)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
@ -384,12 +384,12 @@ GEM
msgpack (1.3.1)
multi_json (1.13.1)
multipart-post (2.1.1)
necromancer (0.5.0)
necromancer (0.5.1)
net-ldap (0.16.2)
net-scp (2.0.0)
net-ssh (>= 2.6.5, < 6.0.0)
net-ssh (5.2.0)
nio4r (2.5.1)
nio4r (2.5.2)
nokogiri (1.10.5)
mini_portile2 (~> 2.4.0)
nokogumbo (2.0.1)
@ -455,7 +455,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.1)
puma (4.2.0)
puma (4.3.1)
nio4r (~> 2.0)
pundit (2.1.0)
activesupport (>= 3.0.0)
@ -568,7 +568,7 @@ GEM
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7)
rubocop-rails (2.3.2)
rubocop-rails (2.4.0)
rack (>= 1.1)
rubocop (>= 0.72.0)
ruby-progressbar (1.10.1)
@ -637,11 +637,11 @@ GEM
tty-command (0.9.0)
pastel (~> 0.7.0)
tty-cursor (0.7.0)
tty-prompt (0.19.0)
tty-prompt (0.20.0)
necromancer (~> 0.5.0)
pastel (~> 0.7.0)
tty-reader (~> 0.6.0)
tty-reader (0.6.0)
tty-reader (~> 0.7.0)
tty-reader (0.7.0)
tty-cursor (~> 0.7)
tty-screen (~> 0.7)
wisper (~> 2.0.0)
@ -673,7 +673,7 @@ GEM
websocket-driver (0.7.0)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.3)
wisper (2.0.0)
wisper (2.0.1)
xpath (3.2.0)
nokogiri (~> 1.8)
@ -685,7 +685,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.7)
addressable (~> 2.7)
annotate (~> 3.0)
aws-sdk-s3 (~> 1.55)
aws-sdk-s3 (~> 1.57)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
@ -712,8 +712,8 @@ DEPENDENCIES
discard (~> 1.1)
doorkeeper (~> 5.2)
dotenv-rails (~> 2.7)
fabrication (~> 2.20)
faker (~> 2.7)
fabrication (~> 2.21)
faker (~> 2.8)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@ -767,7 +767,7 @@ DEPENDENCIES
private_address_check (~> 0.5)
pry-byebug (~> 3.7)
pry-rails (~> 0.3)
puma (~> 4.2)
puma (~> 4.3)
pundit (~> 2.1)
rack-attack (~> 6.2)
rack-cors (~> 1.1)
@ -784,7 +784,7 @@ DEPENDENCIES
rspec-rails (~> 3.9)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.76)
rubocop-rails (~> 2.3)
rubocop-rails (~> 2.4)
ruby-progressbar (~> 1.10)
sanitize (~> 5.1)
sidekiq (~> 5.2)
@ -801,7 +801,7 @@ DEPENDENCIES
strong_migrations (~> 0.4)
thor (~> 0.20)
tty-command (~> 0.9)
tty-prompt (~> 0.19)
tty-prompt (~> 0.20)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2019)
webmock (~> 3.7)

View File

@ -20,6 +20,10 @@ class Api::BaseController < ApplicationController
render json: { error: e.to_s }, status: 422
end
rescue_from ActiveRecord::RecordNotUnique do
render json: { error: 'Duplicate record' }, status: 422
end
rescue_from ActiveRecord::RecordNotFound do
render json: { error: 'Record not found' }, status: 404
end

View File

@ -51,6 +51,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def data_params
return {} if params[:data].blank?
params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll])
params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll])
end
end

View File

@ -19,6 +19,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
data = {
alerts: {
follow: alerts_enabled,
follow_request: false,
favourite: alerts_enabled,
reblog: alerts_enabled,
mention: alerts_enabled,
@ -58,6 +59,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end
def data_params
@data_params ||= params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll])
@data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll])
end
end

View File

@ -62,6 +62,8 @@ module AccountsHelper
def account_badge(account, all: false)
if account.bot?
content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')
elsif account.group?
content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles')
elsif (Setting.show_staff_badge && account.user_staff?) || all
content_tag(:div, class: 'roles') do
if all && !account.user_staff?

View File

@ -121,7 +121,7 @@ const excludeTypesFromSettings = state => state.getIn(['settings', 'notification
const excludeTypesFromFilter = filter => {
const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
return allTypes.filterNot(item => item === filter).toJS();
};

View File

@ -67,9 +67,7 @@ class Poll extends ImmutablePureComponent {
}
}
handleOptionChange = e => {
const { target: { value } } = e;
_toggleOption = value => {
if (this.props.poll.get('multiple')) {
const tmp = { ...this.state.selected };
if (tmp[value]) {
@ -83,8 +81,20 @@ class Poll extends ImmutablePureComponent {
tmp[value] = true;
this.setState({ selected: tmp });
}
}
handleOptionChange = ({ target: { value } }) => {
this._toggleOption(value);
};
handleOptionKeyPress = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
this._toggleOption(e.target.getAttribute('data-index'));
e.stopPropagation();
e.preventDefault();
}
}
handleVote = () => {
if (this.props.disabled) {
return;
@ -135,7 +145,17 @@ class Poll extends ImmutablePureComponent {
disabled={disabled}
/>
{!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />}
{!showResults && (
<span
className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
tabIndex='0'
role={poll.get('multiple') ? 'checkbox' : 'radio'}
onKeyPress={this.handleOptionKeyPress}
aria-checked={active}
aria-label={option.get('title')}
data-index={optionIndex}
/>
)}
{showResults && <span className='poll__number'>
{!!voted && <Icon id='check' className='poll__vote__mark' title={intl.formatMessage(messages.voted)} />}
{Math.round(percent)}%

View File

@ -232,9 +232,18 @@ class Header extends ImmutablePureComponent {
const content = { __html: account.get('note_emojified') };
const displayNameHtml = { __html: account.get('display_name_html') };
const fields = account.get('fields');
const badge = account.get('bot') ? (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>) : null;
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
let badge;
if (account.get('bot')) {
badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>);
} else if (account.get('group')) {
badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>);
} else {
badge = null;
}
return (
<div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
<div className='account__header__image'>

View File

@ -58,6 +58,17 @@ export default class ColumnSettings extends React.PureComponent {
</div>
</div>
<div role='group' aria-labelledby='notifications-follow-request'>
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-favourite'>
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>

View File

@ -0,0 +1,130 @@
import React, { Fragment } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import Permalink from 'flavours/glitch/components/permalink';
import IconButton from 'flavours/glitch/components/icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import NotificationOverlayContainer from '../containers/overlay_container';
import { HotKeys } from 'react-hotkeys';
import Icon from 'flavours/glitch/components/icon';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
});
export default @injectIntl
class FollowRequest extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
notification: ImmutablePropTypes.map.isRequired,
};
handleMoveUp = () => {
const { notification, onMoveUp } = this.props;
onMoveUp(notification.get('id'));
}
handleMoveDown = () => {
const { notification, onMoveDown } = this.props;
onMoveDown(notification.get('id'));
}
handleOpen = () => {
this.handleOpenProfile();
}
handleOpenProfile = () => {
const { notification } = this.props;
this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
}
handleMention = e => {
e.preventDefault();
const { notification, onMention } = this.props;
onMention(notification.get('account'), this.context.router.history);
}
getHandlers () {
return {
moveUp: this.handleMoveUp,
moveDown: this.handleMoveDown,
open: this.handleOpen,
openProfile: this.handleOpenProfile,
mention: this.handleMention,
reply: this.handleMention,
};
}
render () {
const { intl, hidden, account, onAuthorize, onReject, notification } = this.props;
if (!account) {
return <div />;
}
if (hidden) {
return (
<Fragment>
{account.get('display_name')}
{account.get('username')}
</Fragment>
);
}
// Links to the display name.
const displayName = account.get('display_name_html') || account.get('username');
const link = (
<bdi><Permalink
className='notification__display-name'
href={account.get('url')}
title={account.get('acct')}
to={`/accounts/${account.get('id')}`}
dangerouslySetInnerHTML={{ __html: displayName }}
/></bdi>
);
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow-request focusable' tabIndex='0'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='user' fixedWidth />
</div>
<FormattedMessage
id='notification.follow_request'
defaultMessage='{name} has requested to follow you'
values={{ name: link }}
/>
</div>
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Permalink>
<div className='account__relationship'>
<IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} />
<IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} />
</div>
</div>
</div>
<NotificationOverlayContainer notification={notification} />
</div>
</HotKeys>
);
}
}

View File

@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports,
import StatusContainer from 'flavours/glitch/containers/status_container';
import NotificationFollow from './follow';
import NotificationFollowRequestContainer from '../containers/follow_request_container';
export default class Notification extends ImmutablePureComponent {
@ -47,6 +48,18 @@ export default class Notification extends ImmutablePureComponent {
onMention={onMention}
/>
);
case 'follow_request':
return (
<NotificationFollowRequestContainer
hidden={hidden}
id={notification.get('id')}
account={notification.get('account')}
notification={notification}
onMoveDown={onMoveDown}
onMoveUp={onMoveUp}
onMention={onMention}
/>
);
case 'mention':
return (
<StatusContainer

View File

@ -0,0 +1,16 @@
import { connect } from 'react-redux';
import { makeGetAccount } from 'flavours/glitch/selectors';
import FollowRequest from '../components/follow_request';
import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts';
const mapDispatchToProps = (dispatch, { account }) => ({
onAuthorize () {
dispatch(authorizeFollowRequest(account.get('id')));
},
onReject () {
dispatch(rejectFollowRequest(account.get('id')));
},
});
export default connect(null, mapDispatchToProps)(FollowRequest);

View File

@ -4,12 +4,10 @@ import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
import { connect } from 'react-redux';
import { NavLink, withRouter } from 'react-router-dom';
import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
import { me } from 'flavours/glitch/util/initial_state';
import { List as ImmutableList } from 'immutable';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({
locked: state.getIn(['accounts', me, 'locked']),
count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
});
@ -19,22 +17,19 @@ class FollowRequestsNavLink extends React.Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
locked: PropTypes.bool,
count: PropTypes.number.isRequired,
};
componentDidMount () {
const { dispatch, locked } = this.props;
const { dispatch } = this.props;
if (locked) {
dispatch(fetchFollowRequests());
}
}
render () {
const { locked, count } = this.props;
const { count } = this.props;
if (!locked || count === 0) {
if (count === 0) {
return null;
}

View File

@ -5,7 +5,7 @@ import Video from 'flavours/glitch/features/video';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import Icon from 'flavours/glitch/components/icon';
export default class VideoModal extends ImmutablePureComponent {

View File

@ -20,6 +20,8 @@ import {
import {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS,
} from 'flavours/glitch/actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
@ -113,8 +115,8 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
});
};
const filterNotifications = (state, accountIds) => {
const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')));
const filterNotifications = (state, accountIds, type) => {
const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')) && (type === undefined || type === item.get('type')));
return state.update('items', helper).update('pendingItems', helper);
};
@ -227,6 +229,11 @@ export default function notifications(state = initialState, action) {
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
case DOMAIN_BLOCK_SUCCESS:
return filterNotifications(state, action.accounts);
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
case FOLLOW_REQUEST_REJECT_SUCCESS:
return filterNotifications(state, [action.id], 'follow_request');
case ACCOUNT_MUTE_SUCCESS:
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
case NOTIFICATIONS_CLEAR:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
case TIMELINE_DELETE:

View File

@ -6,6 +6,7 @@ const initialState = Immutable.Map({
subscription: null,
alerts: new Immutable.Map({
follow: false,
follow_request: false,
favourite: false,
reblog: false,
mention: false,

View File

@ -34,6 +34,7 @@ const initialState = ImmutableMap({
notifications: ImmutableMap({
alerts: ImmutableMap({
follow: true,
follow_request: false,
favourite: true,
reblog: true,
mention: true,
@ -48,6 +49,7 @@ const initialState = ImmutableMap({
shows: ImmutableMap({
follow: true,
follow_request: false,
favourite: true,
reblog: true,
mention: true,
@ -56,6 +58,7 @@ const initialState = ImmutableMap({
sounds: ImmutableMap({
follow: true,
follow_request: false,
favourite: true,
reblog: true,
mention: true,

View File

@ -1,3 +1,6 @@
import {
NOTIFICATIONS_UPDATE,
} from '../actions/notifications';
import {
FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_EXPAND_SUCCESS,
@ -53,6 +56,12 @@ const appendToList = (state, type, id, accounts, next) => {
});
};
const normalizeFollowRequest = (state, notification) => {
return state.updateIn(['follow_requests', 'items'], list => {
return list.filterNot(item => item === notification.account.id).unshift(notification.account.id);
});
};
export default function userLists(state = initialState, action) {
switch(action.type) {
case FOLLOWERS_FETCH_SUCCESS:
@ -67,6 +76,8 @@ export default function userLists(state = initialState, action) {
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
case FOLLOW_REQUESTS_EXPAND_SUCCESS:

View File

@ -232,7 +232,9 @@
}
.notif-cleaning {
.status, .notification-follow {
.status,
.notification-follow,
.notification-follow-request {
padding-right: ($dismiss-overlay-width + 0.5rem);
}
}
@ -256,7 +258,8 @@
position: absolute;
}
.notification-follow {
.notification-follow,
.notification-follow-request {
position: relative;
// same like Status

View File

@ -98,6 +98,23 @@
border-color: $valid-value-color;
background: $valid-value-color;
}
&:active,
&:focus,
&:hover {
border-width: 4px;
background: none;
}
&::-moz-focus-inner {
outline: 0 !important;
border: 0;
}
&:focus,
&:active {
outline: 0 !important;
}
}
&__number {
@ -168,6 +185,10 @@
select {
width: 100%;
flex: 1 1 50%;
&:focus {
border-color: $highlight-text-color;
}
}
}

View File

@ -1,4 +1,4 @@
import WebSocketClient from 'websocket.js';
import WebSocketClient from '@gamestdio/websocket';
const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));

View File

@ -110,7 +110,7 @@ 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', 'poll']);
const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
return allTypes.filterNot(item => item === filter).toJS();
};

View File

@ -67,9 +67,7 @@ class Poll extends ImmutablePureComponent {
}
}
handleOptionChange = e => {
const { target: { value } } = e;
_toggleOption = value => {
if (this.props.poll.get('multiple')) {
const tmp = { ...this.state.selected };
if (tmp[value]) {
@ -83,8 +81,20 @@ class Poll extends ImmutablePureComponent {
tmp[value] = true;
this.setState({ selected: tmp });
}
}
handleOptionChange = ({ target: { value } }) => {
this._toggleOption(value);
};
handleOptionKeyPress = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
this._toggleOption(e.target.getAttribute('data-index'));
e.stopPropagation();
e.preventDefault();
}
}
handleVote = () => {
if (this.props.disabled) {
return;
@ -135,7 +145,17 @@ class Poll extends ImmutablePureComponent {
disabled={disabled}
/>
{!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />}
{!showResults && (
<span
className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
tabIndex='0'
role={poll.get('multiple') ? 'checkbox' : 'radio'}
onKeyPress={this.handleOptionKeyPress}
aria-checked={active}
aria-label={option.get('title')}
data-index={optionIndex}
/>
)}
{showResults && <span className='poll__number'>
{!!voted && <Icon id='check' className='poll__vote__mark' title={intl.formatMessage(messages.voted)} />}
{Math.round(percent)}%

View File

@ -215,7 +215,8 @@ class Status extends ImmutablePureComponent {
}
handleHotkeyOpenMedia = e => {
const { status, onOpenMedia, onOpenVideo } = this.props;
const { onOpenMedia, onOpenVideo } = this.props;
const status = this._properStatus();
e.preventDefault();

View File

@ -173,9 +173,9 @@ class StatusActionBar extends ImmutablePureComponent {
const account = status.get('account');
if (relationship && relationship.get('blocking')) {
onBlock(status);
} else {
onUnblock(account);
} else {
onBlock(status);
}
}

View File

@ -238,9 +238,18 @@ class Header extends ImmutablePureComponent {
const content = { __html: account.get('note_emojified') };
const displayNameHtml = { __html: account.get('display_name_html') };
const fields = account.get('fields');
const badge = account.get('bot') ? (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>) : null;
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
let badge;
if (account.get('bot')) {
badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>);
} else if (account.get('group')) {
badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>);
} else {
badge = null;
}
return (
<div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
<div className='account__header__image'>

View File

@ -13,6 +13,8 @@ const messages = defineMessages({
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
@ -50,6 +52,12 @@ class Option extends React.PureComponent {
e.stopPropagation();
};
handleCheckboxKeypress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleToggleMultiple(e);
}
}
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
}
@ -71,8 +79,11 @@ class Option extends React.PureComponent {
<span
className={classNames('poll__input', { checkbox: isPollMultiple })}
onClick={this.handleToggleMultiple}
onKeyPress={this.handleCheckboxKeypress}
role='button'
tabIndex='0'
title={intl.formatMessage(isPollMultiple ? messages.switchToMultiple : messages.switchToSingle)}
aria-label={intl.formatMessage(isPollMultiple ? messages.switchToMultiple : messages.switchToSingle)}
/>
<AutosuggestInput

View File

@ -57,6 +57,17 @@ export default class ColumnSettings extends React.PureComponent {
</div>
</div>
<div role='group' aria-labelledby='notifications-follow-request'>
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-favourite'>
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>

View File

@ -0,0 +1,59 @@
import React, { Fragment } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import Permalink from 'mastodon/components/permalink';
import IconButton from 'mastodon/components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
});
export default @injectIntl
class FollowRequest extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { intl, hidden, account, onAuthorize, onReject } = this.props;
if (!account) {
return <div />;
}
if (hidden) {
return (
<Fragment>
{account.get('display_name')}
{account.get('username')}
</Fragment>
);
}
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Permalink>
<div className='account__relationship'>
<IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} />
<IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} />
</div>
</div>
</div>
);
}
}

View File

@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from 'mastodon/initial_state';
import StatusContainer from 'mastodon/containers/status_container';
import AccountContainer from 'mastodon/containers/account_container';
import FollowRequestContainer from '../containers/follow_request_container';
import Icon from 'mastodon/components/icon';
import Permalink from 'mastodon/components/permalink';
@ -127,7 +128,29 @@ class Notification extends ImmutablePureComponent {
</span>
</div>
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
<AccountContainer id={account.get('id')} hidden={this.props.hidden} />
</div>
</HotKeys>
);
}
renderFollowRequest (notification, account, link) {
const { intl } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow-request focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='user' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.follow_request' defaultMessage='{name} has requested to follow you' values={{ name: link }} />
</span>
</div>
<FollowRequestContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
</div>
</HotKeys>
);
@ -261,6 +284,8 @@ class Notification extends ImmutablePureComponent {
switch(notification.get('type')) {
case 'follow':
return this.renderFollow(notification, account, link);
case 'follow_request':
return this.renderFollowRequest(notification, account, link);
case 'mention':
return this.renderMention(notification);
case 'favourite':

View File

@ -0,0 +1,26 @@
import { connect } from 'react-redux';
import { makeGetAccount } from 'mastodon/selectors';
import FollowRequest from '../components/follow_request';
import { authorizeFollowRequest, rejectFollowRequest } from 'mastodon/actions/accounts';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { id }) => ({
onAuthorize () {
dispatch(authorizeFollowRequest(id));
},
onReject () {
dispatch(rejectFollowRequest(id));
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(FollowRequest);

View File

@ -120,9 +120,9 @@ class ActionBar extends React.PureComponent {
const account = status.get('account');
if (relationship && relationship.get('blocking')) {
onBlock(status);
} else {
onUnblock(account);
} else {
onBlock(status);
}
}

View File

@ -282,7 +282,7 @@ class Status extends ImmutablePureComponent {
}
handleHotkeyOpenMedia = e => {
const { status } = this.props;
const status = this._properStatus();
e.preventDefault();

View File

@ -4,12 +4,10 @@ import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { connect } from 'react-redux';
import { NavLink, withRouter } from 'react-router-dom';
import IconWithBadge from 'mastodon/components/icon_with_badge';
import { me } from 'mastodon/initial_state';
import { List as ImmutableList } from 'immutable';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => ({
locked: state.getIn(['accounts', me, 'locked']),
count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
});
@ -19,22 +17,19 @@ class FollowRequestsNavLink extends React.Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
locked: PropTypes.bool,
count: PropTypes.number.isRequired,
};
componentDidMount () {
const { dispatch, locked } = this.props;
const { dispatch } = this.props;
if (locked) {
dispatch(fetchFollowRequests());
}
}
render () {
const { locked, count } = this.props;
const { count } = this.props;
if (!locked || count === 0) {
if (count === 0) {
return null;
}

View File

@ -398,6 +398,14 @@
"defaultMessage": "Favourite",
"id": "status.favourite"
},
{
"defaultMessage": "Bookmark",
"id": "status.bookmark"
},
{
"defaultMessage": "Remove bookmark",
"id": "status.remove_bookmark"
},
{
"defaultMessage": "Expand this status",
"id": "status.open"
@ -437,6 +445,22 @@
{
"defaultMessage": "Copy link to status",
"id": "status.copy"
},
{
"defaultMessage": "Hide everything from {domain}",
"id": "account.block_domain"
},
{
"defaultMessage": "Unhide {domain}",
"id": "account.unblock_domain"
},
{
"defaultMessage": "Unmute @{name}",
"id": "account.unmute"
},
{
"defaultMessage": "Unblock @{name}",
"id": "account.unblock"
}
],
"path": "app/javascript/mastodon/components/status_action_bar.json"
@ -530,6 +554,14 @@
{
"defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"id": "confirmations.reply.message"
},
{
"defaultMessage": "Hide entire domain",
"id": "confirmations.domain_block.confirm"
},
{
"defaultMessage": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
"id": "confirmations.domain_block.message"
}
],
"path": "app/javascript/mastodon/containers/status_container.json"
@ -797,6 +829,19 @@
],
"path": "app/javascript/mastodon/features/blocks/index.json"
},
{
"descriptors": [
{
"defaultMessage": "Bookmarks",
"id": "column.bookmarks"
},
{
"defaultMessage": "You don't have any bookmarked toots yet. When you bookmark one, it will show up here.",
"id": "empty_column.bookmarked_statuses"
}
],
"path": "app/javascript/mastodon/features/bookmarked_statuses/index.json"
},
{
"descriptors": [
{
@ -1528,6 +1573,10 @@
"defaultMessage": "Direct messages",
"id": "navigation_bar.direct"
},
{
"defaultMessage": "Bookmarks",
"id": "navigation_bar.bookmarks"
},
{
"defaultMessage": "Preferences",
"id": "navigation_bar.preferences"
@ -1778,6 +1827,10 @@
"defaultMessage": "to open status",
"id": "keyboard_shortcuts.enter"
},
{
"defaultMessage": "to open media",
"id": "keyboard_shortcuts.open_media"
},
{
"defaultMessage": "to show/hide text behind CW",
"id": "keyboard_shortcuts.toggle_hidden"
@ -2028,6 +2081,10 @@
"defaultMessage": "New followers:",
"id": "notifications.column_settings.follow"
},
{
"defaultMessage": "New follow requests:",
"id": "notifications.column_settings.follow_request"
},
{
"defaultMessage": "Favourites:",
"id": "notifications.column_settings.favourite"
@ -2076,6 +2133,19 @@
],
"path": "app/javascript/mastodon/features/notifications/components/filter_bar.json"
},
{
"descriptors": [
{
"defaultMessage": "Authorize",
"id": "follow_request.authorize"
},
{
"defaultMessage": "Reject",
"id": "follow_request.reject"
}
],
"path": "app/javascript/mastodon/features/notifications/components/follow_request.json"
},
{
"descriptors": [
{
@ -2097,6 +2167,10 @@
{
"defaultMessage": "{name} boosted your status",
"id": "notification.reblog"
},
{
"defaultMessage": "{name} has requested to follow you",
"id": "notification.follow_request"
}
],
"path": "app/javascript/mastodon/features/notifications/components/notification.json"
@ -2204,6 +2278,10 @@
"defaultMessage": "Favourite",
"id": "status.favourite"
},
{
"defaultMessage": "Bookmark",
"id": "status.bookmark"
},
{
"defaultMessage": "Mute @{name}",
"id": "status.mute"
@ -2251,6 +2329,22 @@
{
"defaultMessage": "Copy link to status",
"id": "status.copy"
},
{
"defaultMessage": "Hide everything from {domain}",
"id": "account.block_domain"
},
{
"defaultMessage": "Unhide {domain}",
"id": "account.unblock_domain"
},
{
"defaultMessage": "Unmute @{name}",
"id": "account.unmute"
},
{
"defaultMessage": "Unblock @{name}",
"id": "account.unblock"
}
],
"path": "app/javascript/mastodon/features/status/components/action_bar.json"
@ -2321,6 +2415,14 @@
{
"defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"id": "confirmations.reply.message"
},
{
"defaultMessage": "Hide entire domain",
"id": "confirmations.domain_block.confirm"
},
{
"defaultMessage": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
"id": "confirmations.domain_block.message"
}
],
"path": "app/javascript/mastodon/features/status/index.json"
@ -2472,6 +2574,18 @@
"defaultMessage": "A quick brown fox jumps over the lazy dog",
"id": "upload_modal.description_placeholder"
},
{
"defaultMessage": "Describe for people with hearing loss",
"id": "upload_form.audio_description"
},
{
"defaultMessage": "Describe for people with hearing loss or visual impairment",
"id": "upload_form.video_description"
},
{
"defaultMessage": "Describe for the visually impaired",
"id": "upload_form.description"
},
{
"defaultMessage": "Edit media",
"id": "upload_modal.edit_media"
@ -2480,10 +2594,6 @@
"defaultMessage": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
"id": "upload_modal.hint"
},
{
"defaultMessage": "Describe for the visually impaired",
"id": "upload_form.description"
},
{
"defaultMessage": "Analyzing picture…",
"id": "upload_modal.analyzing_picture"
@ -2633,6 +2743,10 @@
"defaultMessage": "Favourites",
"id": "navigation_bar.favourites"
},
{
"defaultMessage": "Bookmarks",
"id": "navigation_bar.bookmarks"
},
{
"defaultMessage": "Lists",
"id": "navigation_bar.lists"

View File

@ -51,6 +51,7 @@
"bundle_modal_error.message": "Something went wrong while loading this component.",
"bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users",
"column.bookmarks": "Bookmarks",
"column.community": "Local timeline",
"column.direct": "Direct messages",
"column.directory": "Browse profiles",
@ -142,6 +143,7 @@
"empty_column.account_timeline": "No toots here!",
"empty_column.account_unavailable": "Profile unavailable",
"empty_column.blocks": "You haven't blocked any users yet.",
"empty_column.bookmarked_statuses": "You don't have any bookmarked toots yet. When you bookmark one, it will show up here.",
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.domain_blocks": "There are no hidden domains yet.",
@ -223,6 +225,7 @@
"keyboard_shortcuts.muted": "to open muted users list",
"keyboard_shortcuts.my_profile": "to open your profile",
"keyboard_shortcuts.notifications": "to open notifications column",
"keyboard_shortcuts.open_media": "to open media",
"keyboard_shortcuts.pinned": "to open pinned toots list",
"keyboard_shortcuts.profile": "to open author's profile",
"keyboard_shortcuts.reply": "to reply",
@ -255,6 +258,7 @@
"mute_modal.hide_notifications": "Hide notifications from this user?",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.bookmarks": "Bookmarks",
"navigation_bar.community_timeline": "Local timeline",
"navigation_bar.compose": "Compose new toot",
"navigation_bar.direct": "Direct messages",
@ -278,6 +282,7 @@
"navigation_bar.security": "Security",
"notification.favourite": "{name} favourited your status",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned you",
"notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended",
@ -290,6 +295,7 @@
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.filter_bar.show": "Show",
"notifications.column_settings.follow": "New followers:",
"notifications.column_settings.follow_request": "New follow requests:",
"notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.poll": "Poll results:",
"notifications.column_settings.push": "Push notifications",
@ -350,6 +356,7 @@
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this status in the moderation interface",
"status.block": "Block @{name}",
"status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost",
"status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to status",
@ -374,6 +381,7 @@
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",
"status.remove_bookmark": "Remove bookmark",
"status.reply": "Reply",
"status.replyAll": "Reply to thread",
"status.report": "Report @{name}",
@ -406,9 +414,11 @@
"upload_button.label": "Add media ({formats})",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",
"upload_form.description": "Describe for the visually impaired",
"upload_form.edit": "Edit",
"upload_form.undo": "Delete",
"upload_form.video_description": "Describe for people with hearing loss or visual impairment",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",

View File

@ -13,6 +13,8 @@ import {
import {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS,
} from '../actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
@ -89,8 +91,8 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
});
};
const filterNotifications = (state, accountIds) => {
const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')));
const filterNotifications = (state, accountIds, type) => {
const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')) && (type === undefined || type === item.get('type')));
return state.update('items', helper).update('pendingItems', helper);
};
@ -129,6 +131,11 @@ export default function notifications(state = initialState, action) {
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
case DOMAIN_BLOCK_SUCCESS:
return filterNotifications(state, action.accounts);
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
case FOLLOW_REQUEST_REJECT_SUCCESS:
return filterNotifications(state, [action.id], 'follow_request');
case ACCOUNT_MUTE_SUCCESS:
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
case NOTIFICATIONS_CLEAR:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
case TIMELINE_DELETE:

View File

@ -6,6 +6,7 @@ const initialState = Immutable.Map({
subscription: null,
alerts: new Immutable.Map({
follow: false,
follow_request: false,
favourite: false,
reblog: false,
mention: false,

View File

@ -30,6 +30,7 @@ const initialState = ImmutableMap({
notifications: ImmutableMap({
alerts: ImmutableMap({
follow: true,
follow_request: false,
favourite: true,
reblog: true,
mention: true,
@ -44,6 +45,7 @@ const initialState = ImmutableMap({
shows: ImmutableMap({
follow: true,
follow_request: false,
favourite: true,
reblog: true,
mention: true,
@ -52,6 +54,7 @@ const initialState = ImmutableMap({
sounds: ImmutableMap({
follow: true,
follow_request: false,
favourite: true,
reblog: true,
mention: true,

View File

@ -1,3 +1,6 @@
import {
NOTIFICATIONS_UPDATE,
} from '../actions/notifications';
import {
FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_EXPAND_SUCCESS,
@ -53,6 +56,12 @@ const appendToList = (state, type, id, accounts, next) => {
});
};
const normalizeFollowRequest = (state, notification) => {
return state.updateIn(['follow_requests', 'items'], list => {
return list.filterNot(item => item === notification.account.id).unshift(notification.account.id);
});
};
export default function userLists(state = initialState, action) {
switch(action.type) {
case FOLLOWERS_FETCH_SUCCESS:
@ -67,6 +76,8 @@ export default function userLists(state = initialState, action) {
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return state.setIn(['follow_requests', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
case FOLLOW_REQUESTS_EXPAND_SUCCESS:

View File

@ -16,6 +16,7 @@ filenames.forEach(filename => {
filtered[locale] = {
'notification.favourite': full['notification.favourite'] || '',
'notification.follow': full['notification.follow'] || '',
'notification.follow_request': full['notification.follow_request'] || '',
'notification.mention': full['notification.mention'] || '',
'notification.reblog': full['notification.reblog'] || '',
'notification.poll': full['notification.poll'] || '',

View File

@ -1,4 +1,4 @@
import WebSocketClient from 'websocket.js';
import WebSocketClient from '@gamestdio/websocket';
const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));

View File

@ -91,6 +91,23 @@
border-color: $valid-value-color;
background: $valid-value-color;
}
&:active,
&:focus,
&:hover {
border-width: 4px;
background: none;
}
&::-moz-focus-inner {
outline: 0 !important;
border: 0;
}
&:focus,
&:active {
outline: 0 !important;
}
}
&__number {
@ -160,6 +177,10 @@
button,
select {
flex: 1 1 50%;
&:focus {
border-color: $highlight-text-color;
}
}
}

View File

@ -89,7 +89,7 @@ class ActivityPub::Activity
def distribute(status)
crawl_links(status)
notify_about_reblog(status) if reblog_of_local_account?(status)
notify_about_reblog(status) if reblog_of_local_account?(status) && !reblog_by_following_group_account?(status)
notify_about_mentions(status)
# Only continue if the status is supposed to have arrived in real-time.
@ -105,6 +105,10 @@ class ActivityPub::Activity
status.reblog? && status.reblog.account.local?
end
def reblog_by_following_group_account?(status)
status.reblog? && status.account.group? && status.reblog.account.following?(status.account)
end
def notify_about_reblog(status)
NotifyService.new.call(status.reblog.account, status)
end

View File

@ -35,6 +35,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
def serializable_hash(options = nil)
named_contexts = {}
context_extensions = {}
options = serialization_options(options)
serialized_hash = serializer.serializable_hash(options.merge(named_contexts: named_contexts, context_extensions: context_extensions))
serialized_hash = serialized_hash.select { |k, _| options[:fields].include?(k) } if options[:fields]

View File

@ -68,10 +68,19 @@ class ActivityPub::TagManager
if status.account.silenced?
# Only notify followers if the account is locally silenced
account_ids = status.active_mentions.pluck(:account_id)
to = status.account.followers.where(id: account_ids).map { |account| uri_for(account) }
to.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).map { |request| uri_for(request.account) })
to = status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
result << uri_for(account)
result << account.followers_url if account.group?
end
to.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result|
result << uri_for(request.account)
result << request.account.followers_url if request.account.group?
end)
else
status.active_mentions.map { |mention| uri_for(mention.account) }
status.active_mentions.each_with_object([]) do |mention, result|
result << uri_for(mention.account)
result << mention.account.followers_url if mention.account.group?
end
end
end
end
@ -97,10 +106,19 @@ class ActivityPub::TagManager
if status.account.silenced?
# Only notify followers if the account is locally silenced
account_ids = status.active_mentions.pluck(:account_id)
cc.concat(status.account.followers.where(id: account_ids).map { |account| uri_for(account) })
cc.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).map { |request| uri_for(request.account) })
cc.concat(status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
result << uri_for(account)
result << account.followers_url if account.group?
end)
cc.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result|
result << uri_for(request.account)
result << request.account.followers_url if request.account.group?
end)
else
cc.concat(status.active_mentions.map { |mention| uri_for(mention.account) })
cc.concat(status.active_mentions.each_with_object([]) do |mention, result|
result << uri_for(mention.account)
result << mention.account.followers_url if mention.account.group?
end)
end
end

View File

@ -44,7 +44,7 @@ class LanguageDetector
words = text.scan(RELIABLE_CHARACTERS_RE)
if words.present?
words.reduce(0) { |acc, elem| acc + elem.size }.to_f / text.size.to_f > 0.3
words.reduce(0) { |acc, elem| acc + elem.size }.to_f / text.size > 0.3
else
false
end

View File

@ -97,6 +97,7 @@ class Account < ApplicationRecord
scope :without_silenced, -> { where(silenced_at: nil) }
scope :recent, -> { reorder(id: :desc) }
scope :bots, -> { where(actor_type: %w(Application Service)) }
scope :groups, -> { where(actor_type: 'Group') }
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
@ -157,6 +158,12 @@ class Account < ApplicationRecord
self.actor_type = ActiveModel::Type::Boolean.new.cast(val) ? 'Service' : 'Person'
end
def group?
actor_type == 'Group'
end
alias group group?
def acct
local? ? username : "#{username}@#{domain}"
end

View File

@ -6,7 +6,7 @@ module LdapAuthenticable
class_methods do
def authenticate_with_ldap(params = {})
ldap = Net::LDAP.new(ldap_options)
filter = format(Devise.ldap_search_filter, uid: Devise.ldap_uid, email: params[:email])
filter = format(Devise.ldap_search_filter, uid: Devise.ldap_uid, mail: Devise.ldap_mail, email: params[:email])
if (user_info = ldap.bind_as(base: Devise.ldap_base, filter: filter, password: params[:password]))
ldap_get_user(user_info.first)
@ -25,7 +25,7 @@ module LdapAuthenticable
resource = joins(:account).find_by(accounts: { username: safe_username })
if resource.blank?
resource = new(email: attributes[:mail].first, agreement: true, account_attributes: { username: safe_username }, admin: false, external: true, confirmed_at: Time.now.utc)
resource = new(email: attributes[Devise.ldap_mail.to_sym].first, agreement: true, account_attributes: { username: safe_username }, admin: false, external: true, confirmed_at: Time.now.utc)
resource.save!
end

View File

@ -287,7 +287,7 @@ class MediaAttachment < ApplicationRecord
width: width,
height: height,
size: "#{width}x#{height}",
aspect: width.to_f / height.to_f,
aspect: width.to_f / height,
}
end

View File

@ -42,7 +42,7 @@ class Notification < ApplicationRecord
validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values }
scope :browserable, ->(exclude_types = [], account_id = nil) {
types = TYPE_CLASS_MAP.values - activity_types_from_types(exclude_types + [:follow_request])
types = TYPE_CLASS_MAP.values - activity_types_from_types(exclude_types)
if account_id.nil?
where(activity_type: types)
else
@ -50,7 +50,7 @@ class Notification < ApplicationRecord
end
}
cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account, poll: [status: STATUS_INCLUDES]
cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account, follow_request: :account, poll: [status: STATUS_INCLUDES]
def type
@type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym
@ -69,10 +69,6 @@ class Notification < ApplicationRecord
end
end
def browserable?
type != :follow_request
end
class << self
def cache_ids
select(:id, :updated_at, :activity_type, :activity_id)

View File

@ -36,7 +36,7 @@ class Poll < ApplicationRecord
scope :attached, -> { where.not(status_id: nil) }
scope :unattached, -> { where(status_id: nil) }
before_validation :prepare_options
before_validation :prepare_options, if: :local?
before_validation :prepare_votes_count
after_initialize :prepare_cached_tallies

View File

@ -49,6 +49,8 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
'Application'
elsif object.bot?
'Service'
elsif object.group?
'Group'
else
'Person'
end

View File

@ -3,7 +3,7 @@
class REST::AccountSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at,
:note, :url, :avatar, :avatar_static, :header, :header_static,
:followers_count, :following_count, :statuses_count, :last_status_at

View File

@ -4,8 +4,8 @@ class AccountSearchService < BaseService
attr_reader :query, :limit, :offset, :options, :account
def call(query, account = nil, options = {})
@acct_hint = query.start_with?('@')
@query = query.strip.gsub(/\A@/, '')
@acct_hint = query&.start_with?('@')
@query = query&.strip&.gsub(/\A@/, '')
@limit = options[:limit].to_i
@offset = options[:offset].to_i
@options = options

View File

@ -9,7 +9,7 @@ class NotifyService < BaseService
return if recipient.user.nil? || blocked?
create_notification!
push_notification! if @notification.browserable?
push_notification!
push_to_conversation! if direct_message?
send_email! if email_enabled?
rescue ActiveRecord::RecordInvalid

View File

@ -2,7 +2,7 @@
class SearchService < BaseService
def call(query, account, limit, options = {})
@query = query.strip
@query = query&.strip
@account = account
@options = options
@limit = limit.to_i
@ -10,6 +10,8 @@ class SearchService < BaseService
@resolve = options[:resolve] || false
default_results.tap do |results|
next if @query.blank?
if url_query?
results.merge!(url_resource_results) unless url_resource.nil? || (@options[:type].present? && url_resource_symbol != @options[:type].to_sym)
elsif @query.present?

View File

@ -19,7 +19,7 @@
.dashboard__counters__num= number_with_delimiter @blocks_count
.dashboard__counters__label= t 'admin.instances.total_blocked_by_us'
%div
%div
= link_to admin_reports_path(by_target_domain: @instance.domain) do
.dashboard__counters__num= number_with_delimiter @reports_count
.dashboard__counters__label= t 'admin.instances.total_reported'
%div

View File

@ -4,9 +4,9 @@
= simple_form_for current_user, url: settings_preferences_notifications_path, html: { method: :put } do |f|
= render 'shared/error_messages', object: current_user
%h4= t('notifications.email_events')
%h4= t 'notifications.email_events'
%p.hint = t('notifications.email_events_hint')
%p.hint= t 'notifications.email_events_hint'
.fields-group
= f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
@ -25,7 +25,7 @@
= f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
= ff.input :digest, as: :boolean, wrapper: :with_label
%h4 = t('notifications.other_settings')
%h4= t 'notifications.other_settings'
.fields-group
= f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|

View File

@ -1,36 +1,39 @@
module.exports = (api) => {
const env = api.env();
const reactOptions = {
development: false,
};
const envOptions = {
debug: false,
loose: true,
modules: false,
debug: false,
};
const config = {
presets: [
'@babel/react',
['@babel/react', reactOptions],
['@babel/env', envOptions],
],
plugins: [
'@babel/syntax-dynamic-import',
['@babel/proposal-object-rest-spread', { useBuiltIns: true }],
['@babel/proposal-decorators', { legacy: true }],
'@babel/proposal-class-properties',
['react-intl', { messagesDir: './build/messages' }],
'preval',
],
overrides: [{
overrides: [
{
test: /tesseract\.js/,
presets: [
['@babel/env', { ...envOptions, modules: 'commonjs' }],
],
}],
},
],
};
switch (env) {
case 'production':
envOptions.debug = false;
config.plugins.push(...[
'lodash',
[
@ -55,11 +58,8 @@ module.exports = (api) => {
]);
break;
case 'development':
reactOptions.development = true;
envOptions.debug = true;
config.plugins.push(...[
'@babel/transform-react-jsx-source',
'@babel/transform-react-jsx-self',
]);
break;
case 'test':
envOptions.modules = 'commonjs';

View File

@ -53,6 +53,8 @@ module Devise
@@ldap_base = nil
mattr_accessor :ldap_uid
@@ldap_uid = nil
mattr_accessor :ldap_mail
@@ldap_mail = nil
mattr_accessor :ldap_bind_dn
@@ldap_bind_dn = nil
mattr_accessor :ldap_password
@ -369,8 +371,9 @@ Devise.setup do |config|
config.ldap_bind_dn = ENV.fetch('LDAP_BIND_DN')
config.ldap_password = ENV.fetch('LDAP_PASSWORD')
config.ldap_uid = ENV.fetch('LDAP_UID', 'cn')
config.ldap_mail = ENV.fetch('LDAP_MAIL', 'mail')
config.ldap_tls_no_verify = ENV['LDAP_TLS_NO_VERIFY'] == 'true'
config.ldap_search_filter = ENV.fetch('LDAP_SEARCH_FILTER', '%{uid}=%{email}')
config.ldap_search_filter = ENV.fetch('LDAP_SEARCH_FILTER', '(|(%{uid}=%{email})(%{mail}=%{email}))')
config.ldap_uid_conversion_enabled = ENV['LDAP_UID_CONVERSION_ENABLED'] == 'true'
config.ldap_uid_conversion_search = ENV.fetch('LDAP_UID_CONVERSION_SEARCH', '.,- ')
config.ldap_uid_conversion_replace = ENV.fetch('LDAP_UID_CONVERSION_REPLACE', '_')

View File

@ -42,7 +42,7 @@ if ENV['S3_ENABLED'] == 'true'
s3_options: {
signature_version: ENV.fetch('S3_SIGNATURE_VERSION') { 'v4' },
http_open_timeout: 5,
http_open_timeout: ENV.fetch('S3_OPEN_TIMEOUT'){ '5' }.to_i,
http_read_timeout: 5,
http_idle_timeout: 5,
retry_limit: 0,

View File

@ -78,6 +78,7 @@ en:
roles:
admin: Admin
bot: Bot
group: Group
moderator: Mod
unavailable: Profile unavailable
unfollow: Unfollow

View File

@ -1,6 +1,66 @@
class MigrateAccountConversations < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
class Mention < ApplicationRecord
belongs_to :account, inverse_of: :mentions
belongs_to :status, -> { unscope(where: :deleted_at) }
delegate(
:username,
:acct,
to: :account,
prefix: true
)
end
class Notification < ApplicationRecord
belongs_to :account, optional: true
belongs_to :activity, polymorphic: true, optional: true
belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id', optional: true
belongs_to :mention, foreign_type: 'Mention', foreign_key: 'activity_id', optional: true
def target_status
mention&.status
end
end
class AccountConversation < ApplicationRecord
belongs_to :account
belongs_to :conversation
belongs_to :last_status, -> { unscope(where: :deleted_at) }, class_name: 'Status'
before_validation :set_last_status
class << self
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))
return conversation if conversation.status_ids.include?(status.id)
conversation.status_ids << status.id
conversation.unread = status.account_id != recipient.id
conversation.save
conversation
rescue ActiveRecord::StaleObjectError
retry
end
private
def participants_from_status(recipient, status)
((status.active_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
end
def up
say ''
say 'WARNING: This migration may take a *long* time for large instances'

View File

@ -9,8 +9,8 @@ module Paperclip
min_side = [@current_geometry.width, @current_geometry.height].min.to_i
options[:geometry] = "#{min_side}x#{min_side}#" if @target_geometry.square? && min_side < @target_geometry.width
elsif options[:pixels]
width = Math.sqrt(options[:pixels] * (@current_geometry.width.to_f / @current_geometry.height.to_f)).round.to_i
height = Math.sqrt(options[:pixels] * (@current_geometry.height.to_f / @current_geometry.width.to_f)).round.to_i
width = Math.sqrt(options[:pixels] * (@current_geometry.width.to_f / @current_geometry.height)).round.to_i
height = Math.sqrt(options[:pixels] * (@current_geometry.height.to_f / @current_geometry.width)).round.to_i
options[:geometry] = "#{width}x#{height}>"
end

View File

@ -60,23 +60,20 @@
},
"private": true,
"dependencies": {
"@babel/core": "^7.7.2",
"@babel/plugin-proposal-class-properties": "^7.7.0",
"@babel/plugin-proposal-decorators": "^7.7.0",
"@babel/plugin-proposal-object-rest-spread": "^7.6.2",
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
"@babel/core": "^7.7.4",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-proposal-decorators": "^7.7.4",
"@babel/plugin-transform-react-inline-elements": "^7.7.4",
"@babel/plugin-transform-react-jsx-self": "^7.7.4",
"@babel/plugin-transform-react-jsx-source": "^7.5.0",
"@babel/plugin-transform-runtime": "^7.7.4",
"@babel/preset-env": "^7.7.4",
"@babel/preset-react": "^7.7.0",
"@babel/preset-react": "^7.7.4",
"@babel/runtime": "^7.7.4",
"@gamestdio/websocket": "^0.3.2",
"@clusterws/cws": "^0.16.0",
"array-includes": "^3.0.3",
"atrament": "^0.2.3",
"arrow-key-navigation": "^1.0.2",
"autoprefixer": "^9.6.1",
"arrow-key-navigation": "^1.1.0",
"autoprefixer": "^9.7.3",
"axios": "^0.19.0",
"babel-loader": "^8.0.6",
"babel-plugin-lodash": "^3.3.4",
@ -84,7 +81,7 @@
"babel-plugin-react-intl": "^3.4.1",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"babel-runtime": "^6.26.0",
"blurhash": "^1.0.0",
"blurhash": "^1.1.3",
"classnames": "^2.2.5",
"compression-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.0.5",
@ -128,7 +125,7 @@
"postcss-object-fit-images": "^1.1.2",
"prop-types": "^15.5.10",
"punycode": "^2.1.0",
"rails-ujs": "^5.2.3",
"rails-ujs": "^5.2.4",
"react": "^16.10.2",
"react-dom": "^16.12.0",
"react-hotkeys": "^1.1.4",
@ -171,7 +168,6 @@
"webpack-bundle-analyzer": "^3.6.0",
"webpack-cli": "^3.3.10",
"webpack-merge": "^4.2.1",
"websocket.js": "^0.1.12",
"wicg-inert": "^3.0.0"
},
"devDependencies": {
@ -179,11 +175,11 @@
"babel-jest": "^24.9.0",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.15.1",
"eslint": "^6.5.1",
"eslint": "^6.7.2",
"eslint-plugin-import": "~2.18.2",
"eslint-plugin-jsx-a11y": "~6.2.3",
"eslint-plugin-promise": "~4.2.1",
"eslint-plugin-react": "~7.16.0",
"eslint-plugin-react": "~7.17.0",
"jest": "^24.9.0",
"raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3",

View File

@ -34,32 +34,6 @@ RSpec.describe Notification, type: :model do
end
end
describe '#browserable?' do
let(:notification) { Fabricate(:notification) }
subject { notification.browserable? }
context 'type is :follow_request' do
before do
allow(notification).to receive(:type).and_return(:follow_request)
end
it 'returns false' do
is_expected.to be false
end
end
context 'type is not :follow_request' do
before do
allow(notification).to receive(:type).and_return(:else)
end
it 'returns true' do
is_expected.to be true
end
end
end
describe '#type' do
it 'returns :reblog for a Status' do
notification = Notification.new(activity: Status.new)

View File

@ -12,7 +12,7 @@ end
gc_counter = -1
RSpec.configure do |config|
config.example_status_persistence_file_path = ".cache/rspec"
config.example_status_persistence_file_path = "tmp/rspec/examples.txt"
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end

637
yarn.lock

File diff suppressed because it is too large Load Diff