diff options
author | ThibG <thib@sitedethib.com> | 2019-12-08 21:27:36 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-12-08 21:27:36 +0100 |
commit | 246addd5b33a172600342af3fb6fb5e4c80ad95e (patch) | |
tree | 98b87efd3f5f3d41d75482707924fde708c074ec /app | |
parent | 7d59e25fbd7a0209c11cbe6aac4400bbca4de82b (diff) | |
parent | 49027ee948bc76cc89214f8f340d353763796f1d (diff) |
Merge pull request #1251 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
Diffstat (limited to 'app')
58 files changed, 681 insertions, 79 deletions
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 33df75b37..144fdd6ac 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 1b658f870..1cbc92b93 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -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 diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index d8153e082..f388b17e5 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -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 diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index e07276f0c..7fcc4e816 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -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? diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 7effb07d1..940f3c3d4 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -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(); }; diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js index 2d2a7cbe0..62965df94 100644 --- a/app/javascript/flavours/glitch/components/poll.js +++ b/app/javascript/flavours/glitch/components/poll.js @@ -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)}% diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js index e65a68b4d..6b4aff616 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.js +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -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'> diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js index e29bd61f5..e4d5d0eda 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -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> diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js new file mode 100644 index 000000000..d73dac434 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js @@ -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> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js index 5c5bbf604..62fc28386 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.js +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -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 diff --git a/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js new file mode 100644 index 000000000..82357adfb --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js @@ -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); diff --git a/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js index 189f403bd..c30427896 100644 --- a/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js +++ b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js @@ -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()); - } + dispatch(fetchFollowRequests()); } render () { - const { locked, count } = this.props; + const { count } = this.props; - if (!locked || count === 0) { + if (count === 0) { return null; } diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js index ef69f60f4..e7309021e 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -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 { diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 8d5c6785c..3623e90da 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -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: diff --git a/app/javascript/flavours/glitch/reducers/push_notifications.js b/app/javascript/flavours/glitch/reducers/push_notifications.js index e87e8fc1a..117fb5167 100644 --- a/app/javascript/flavours/glitch/reducers/push_notifications.js +++ b/app/javascript/flavours/glitch/reducers/push_notifications.js @@ -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, diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index 9be27a02f..ef99ad552 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -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, diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js index b4e1d1eae..c8a6f524e 100644 --- a/app/javascript/flavours/glitch/reducers/user_lists.js +++ b/app/javascript/flavours/glitch/reducers/user_lists.js @@ -1,4 +1,7 @@ import { + NOTIFICATIONS_UPDATE, +} from '../actions/notifications'; +import { FOLLOWERS_FETCH_SUCCESS, FOLLOWERS_EXPAND_SUCCESS, FOLLOWING_FETCH_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: diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 77d67576b..00f947cdc 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -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 diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss index 95d8e510c..49d0e7f71 100644 --- a/app/javascript/flavours/glitch/styles/polls.scss +++ b/app/javascript/flavours/glitch/styles/polls.scss @@ -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; + } } } diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js index c4642344f..50f90d44c 100644 --- a/app/javascript/flavours/glitch/util/stream.js +++ b/app/javascript/flavours/glitch/util/stream.js @@ -1,4 +1,4 @@ -import WebSocketClient from 'websocket.js'; +import WebSocketClient from '@gamestdio/websocket'; const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 3a92e0224..798f9b37e 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -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(); }; diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js index 0edd064e0..3a17e80e7 100644 --- a/app/javascript/mastodon/components/poll.js +++ b/app/javascript/mastodon/components/poll.js @@ -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)}% diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 3176bda89..e120278a0 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -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(); diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index bd3bb16bb..4b3c79d0d 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -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); } } diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index dbb567e85..8bd7f2db5 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -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'> diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index 7dac950b1..791a4b1ad 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.js +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -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 diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 60a86312a..8bd03fbda 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -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> diff --git a/app/javascript/mastodon/features/notifications/components/follow_request.js b/app/javascript/mastodon/features/notifications/components/follow_request.js new file mode 100644 index 000000000..a80cfb2fa --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/follow_request.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 2dea8afa7..74065e5e2 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -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': diff --git a/app/javascript/mastodon/features/notifications/containers/follow_request_container.js b/app/javascript/mastodon/features/notifications/containers/follow_request_container.js new file mode 100644 index 000000000..f9f6c577e --- /dev/null +++ b/app/javascript/mastodon/features/notifications/containers/follow_request_container.js @@ -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); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 76334de69..bf6469f2f 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -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); } } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index ab468b5e8..6b18f34d1 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -282,7 +282,7 @@ class Status extends ImmutablePureComponent { } handleHotkeyOpenMedia = e => { - const { status } = this.props; + const status = this._properStatus(); e.preventDefault(); diff --git a/app/javascript/mastodon/features/ui/components/follow_requests_nav_link.js b/app/javascript/mastodon/features/ui/components/follow_requests_nav_link.js index 90c953893..950ed7b27 100644 --- a/app/javascript/mastodon/features/ui/components/follow_requests_nav_link.js +++ b/app/javascript/mastodon/features/ui/components/follow_requests_nav_link.js @@ -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()); - } + dispatch(fetchFollowRequests()); } render () { - const { locked, count } = this.props; + const { count } = this.props; - if (!locked || count === 0) { + if (count === 0) { return null; } diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index a65481998..16e3e402a 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -399,6 +399,14 @@ "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" @@ -800,6 +832,19 @@ { "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": [ + { "defaultMessage": "Media only", "id": "community.column_settings.media_only" } @@ -1529,6 +1574,10 @@ "id": "navigation_bar.direct" }, { + "defaultMessage": "Bookmarks", + "id": "navigation_bar.bookmarks" + }, + { "defaultMessage": "Preferences", "id": "navigation_bar.preferences" }, @@ -1779,6 +1828,10 @@ "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" }, @@ -2029,6 +2082,10 @@ "id": "notifications.column_settings.follow" }, { + "defaultMessage": "New follow requests:", + "id": "notifications.column_settings.follow_request" + }, + { "defaultMessage": "Favourites:", "id": "notifications.column_settings.favourite" }, @@ -2079,6 +2136,19 @@ { "descriptors": [ { + "defaultMessage": "Authorize", + "id": "follow_request.authorize" + }, + { + "defaultMessage": "Reject", + "id": "follow_request.reject" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/follow_request.json" + }, + { + "descriptors": [ + { "defaultMessage": "{name} favourited your status", "id": "notification.favourite" }, @@ -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" @@ -2205,6 +2279,10 @@ "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" @@ -2473,18 +2575,26 @@ "id": "upload_modal.description_placeholder" }, { - "defaultMessage": "Edit media", - "id": "upload_modal.edit_media" + "defaultMessage": "Describe for people with hearing loss", + "id": "upload_form.audio_description" }, { - "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 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" + }, + { + "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": "Analyzing picture…", "id": "upload_modal.analyzing_picture" }, @@ -2634,6 +2744,10 @@ "id": "navigation_bar.favourites" }, { + "defaultMessage": "Bookmarks", + "id": "navigation_bar.bookmarks" + }, + { "defaultMessage": "Lists", "id": "navigation_bar.lists" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 38f190791..84e40a9fc 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 6ba80bd6a..60e901e39 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -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: diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js index 317352b79..c48cfb705 100644 --- a/app/javascript/mastodon/reducers/push_notifications.js +++ b/app/javascript/mastodon/reducers/push_notifications.js @@ -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, diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 793a99f8f..efef2ad9a 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -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, diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 08e94022f..a7853452f 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -1,4 +1,7 @@ import { + NOTIFICATIONS_UPDATE, +} from '../actions/notifications'; +import { FOLLOWERS_FETCH_SUCCESS, FOLLOWERS_EXPAND_SUCCESS, FOLLOWING_FETCH_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: diff --git a/app/javascript/mastodon/service_worker/web_push_locales.js b/app/javascript/mastodon/service_worker/web_push_locales.js index 5ce8c7b50..1265f3cfa 100644 --- a/app/javascript/mastodon/service_worker/web_push_locales.js +++ b/app/javascript/mastodon/service_worker/web_push_locales.js @@ -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'] || '', diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js index c4642344f..50f90d44c 100644 --- a/app/javascript/mastodon/stream.js +++ b/app/javascript/mastodon/stream.js @@ -1,4 +1,4 @@ -import WebSocketClient from 'websocket.js'; +import WebSocketClient from '@gamestdio/websocket'; const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss index f59a9d693..d7d850a1e 100644 --- a/app/javascript/styles/mastodon/polls.scss +++ b/app/javascript/styles/mastodon/polls.scss @@ -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; + } } } diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index cdd406043..0ca6b92a4 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -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 diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 2a8f72333..78138fb73 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -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] diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 512272dbe..ed680d762 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -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 diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb index 6f9511a54..302072bcc 100644 --- a/app/lib/language_detector.rb +++ b/app/lib/language_detector.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index 1f06926fd..25cde6d6c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/concerns/ldap_authenticable.rb b/app/models/concerns/ldap_authenticable.rb index 2d2e1edbb..e3f94bb6c 100644 --- a/app/models/concerns/ldap_authenticable.rb +++ b/app/models/concerns/ldap_authenticable.rb @@ -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 diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 9bba89e57..e05879188 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -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 diff --git a/app/models/notification.rb b/app/models/notification.rb index 498673ff1..ad7528f50 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -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) diff --git a/app/models/poll.rb b/app/models/poll.rb index 5427368fd..b5deafcc2 100644 --- a/app/models/poll.rb +++ b/app/models/poll.rb @@ -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 diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 17df85de3..aa64936a7 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -49,6 +49,8 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer 'Application' elsif object.bot? 'Service' + elsif object.group? + 'Group' else 'Person' end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 63b84a0b9..7bdb5d7ff 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -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 diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index 7e74cc893..d217dabb3 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -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 diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index b5c721589..9364a6ae8 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -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 diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 3a498dcf4..090fd409b 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -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? diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml index 294c9495d..49a666a5a 100644 --- a/app/views/admin/instances/show.html.haml +++ b/app/views/admin/instances/show.html.haml @@ -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 diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index f71b930e7..a496be21b 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -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| |