diff options
author | Fire Demon <firedemon@creature.cafe> | 2020-10-22 17:34:29 -0500 |
---|---|---|
committer | Fire Demon <firedemon@creature.cafe> | 2020-10-22 17:34:29 -0500 |
commit | adf60e63806a4b8f843ce6722a9086044f0bb5cd (patch) | |
tree | 722969bdb867727a65aeb78bfdab4fa5f60248a5 /app | |
parent | c36dd229f9dcb1d77c46d8db23297fc5781b4a97 (diff) | |
parent | 36e5c9d45be0e94216b5b92ea8749a00bb68e0e3 (diff) |
Merge remote-tracking branch 'upstream/master' into merge-glitch
Diffstat (limited to 'app')
143 files changed, 1779 insertions, 344 deletions
diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb new file mode 100644 index 000000000..525031105 --- /dev/null +++ b/app/controllers/activitypub/followers_synchronizations_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController + include SignatureVerification + include AccountOwnedConcern + + before_action :require_signature! + before_action :set_items + before_action :set_cache_headers + + def show + expires_in 0, public: false + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + + private + + def uri_prefix + signed_request_account.uri[/http(s?):\/\/[^\/]+\//] + end + + def set_items + @items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri) + end + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_followers_synchronization_url(@account), + type: :ordered, + items: @items + ) + end +end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 3e67f3909..4c0e83122 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -11,6 +11,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController def create upgrade_account + process_collection_synchronization process_payload head 202 end @@ -52,6 +53,19 @@ class ActivityPub::InboxesController < ActivityPub::BaseController DeliveryFailureTracker.reset!(signed_request_account.inbox_url) end + def process_collection_synchronization + raw_params = request.headers['Collection-Synchronization'] + return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' + + # Re-using the syntax for signature parameters + tree = SignatureParamsParser.new.parse(raw_params) + params = SignatureParamsTransformer.new.apply(tree) + + ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params) + rescue Parslet::ParseFailed + Rails.logger.warn 'Error parsing Collection-Synchronization header' + end + def process_payload ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id) end diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb new file mode 100644 index 000000000..92b8b0d2b --- /dev/null +++ b/app/controllers/admin/ip_blocks_controller.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Admin + class IpBlocksController < BaseController + def index + authorize :ip_block, :index? + + @ip_blocks = IpBlock.page(params[:page]) + @form = Form::IpBlockBatch.new + end + + def new + authorize :ip_block, :create? + + @ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year) + end + + def create + authorize :ip_block, :create? + + @ip_block = IpBlock.new(resource_params) + + if @ip_block.save + log_action :create, @ip_block + redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg') + else + render :new + end + end + + def batch + @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected') + rescue Mastodon::NotPermittedError + flash[:alert] = I18n.t('admin.custom_emojis.not_permitted') + ensure + redirect_to admin_ip_blocks_path + end + + private + + def resource_params + params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in) + end + + def action_from_button + 'delete' if params[:delete] + end + + def form_ip_block_batch_params + params.require(:form_ip_block_batch).permit(ip_block_ids: []) + end + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 453929afe..6e909bbf2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController end def create - token = AppSignUpService.new.call(doorkeeper_token.application, account_params) + token = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params) response = Doorkeeper::OAuth::TokenResponse.new(token) headers.merge!(response.headers) @@ -42,7 +42,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), timelines_only: truthy_param?(:timelines_only)) + MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), timelines_only: truthy_param?(:timelines_only), duration: (params[:duration] || 0)) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index a89f3d700..fd52511d7 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -6,25 +6,16 @@ class Api::V1::MutesController < Api::BaseController after_action :insert_pagination_headers def index - @data = @accounts = load_accounts - render json: @accounts, each_serializer: REST::AccountSerializer + @accounts = load_accounts + render json: @accounts, each_serializer: REST::MutedAccountSerializer end - def details - @data = @mutes = load_mutes - render json: @mutes, each_serializer: REST::MuteSerializer - end - private def load_accounts paginated_mutes.map(&:target_account) end - def load_mutes - paginated_mutes.includes(:account, :target_account).to_a - end - def paginated_mutes @paginated_mutes ||= Mute.eager_load(:target_account) .joins(:target_account) @@ -43,34 +34,26 @@ class Api::V1::MutesController < Api::BaseController def next_path if records_continue? - url_for pagination_params(max_id: pagination_max_id) + api_v1_mutes_url pagination_params(max_id: pagination_max_id) end end def prev_path - unless @data.empty? - url_for pagination_params(since_id: pagination_since_id) + unless paginated_mutes.empty? + api_v1_mutes_url pagination_params(since_id: pagination_since_id) end end def pagination_max_id - if params[:action] == "details" - @mutes.last.id - else - paginated_mutes.last.id - end + paginated_mutes.last.id end def pagination_since_id - if params[:action] == "details" - @mutes.first.id - else - paginated_mutes.first.id - end + paginated_mutes.first.id end def records_continue? - @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + paginated_mutes.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end def pagination_params(core_params) diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 55975b274..c67757bbc 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -52,9 +52,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController def build_resource(hash = nil) super(hash) - resource.locale = I18n.locale - resource.invite_code = params[:invite_code] if resource.invite_code.blank? - resource.current_sign_in_ip = request.remote_ip + resource.locale = I18n.locale + resource.invite_code = params[:invite_code] if resource.invite_code.blank? + resource.sign_up_ip = request.remote_ip resource.build_account if resource.account.nil? end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 0a7c53f83..3d6696fc4 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -48,6 +48,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_display_media, :setting_expand_spoilers, :setting_reduce_motion, + :setting_disable_swiping, :setting_system_font_ui, :setting_system_emoji_font, :setting_noindex, diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 8e398c3b2..0f3ca36e2 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -29,6 +29,8 @@ module Admin::ActionLogsHelper link_to record.target_account.acct, admin_account_path(record.target_account_id) when 'Announcement' link_to truncate(record.text), edit_admin_announcement_path(record.id) + when 'IpBlock' + "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})" end end @@ -48,6 +50,8 @@ module Admin::ActionLogsHelper end when 'Announcement' truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text']) + when 'IpBlock' + "#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})" end end end diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb index ab7ca4698..482f4e19e 100644 --- a/app/helpers/webfinger_helper.rb +++ b/app/helpers/webfinger_helper.rb @@ -1,38 +1,7 @@ # frozen_string_literal: true -# Monkey-patch on monkey-patch. -# Because it conflicts with the request.rb patch. -class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation - def connect(socket_class, host, port, nodelay = false) - ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do - @socket = socket_class.open(host, port) - @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay - end - end -end - module WebfingerHelper def webfinger!(uri) - hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri) - - raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri - - opts = { - ssl: !hidden_service_uri, - - headers: { - 'User-Agent': Mastodon::Version.user_agent, - }, - - timeout_class: HTTP::Timeout::PerOperationOriginal, - - timeout_options: { - write_timeout: 10, - connect_timeout: 5, - read_timeout: 10, - }, - } - - Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger + Webfinger.new(uri).perform end end diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js index f2334c254..bbc7cfac7 100644 --- a/app/javascript/core/admin.js +++ b/app/javascript/core/admin.js @@ -1,5 +1,6 @@ // This file will be loaded on admin pages, regardless of theme. +import 'packs/public-path'; import { delegate } from '@rails/ujs'; import ready from '../mastodon/ready'; diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js index ca04730a3..d1d14d99e 100644 --- a/app/javascript/core/auth.js +++ b/app/javascript/core/auth.js @@ -1,2 +1,3 @@ +import 'packs/public-path'; import './settings'; import './two_factor_authentication'; diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js index 3b038ca40..1cee2f603 100644 --- a/app/javascript/core/common.js +++ b/app/javascript/core/common.js @@ -1,5 +1,6 @@ // This file will be loaded on all pages, regardless of theme. +import 'packs/public-path'; import 'font-awesome/css/font-awesome.css'; require.context('../images/', true); diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js index 6146e6592..9083eb7a3 100644 --- a/app/javascript/core/embed.js +++ b/app/javascript/core/embed.js @@ -1,5 +1,7 @@ // This file will be loaded on embed pages, regardless of theme. +import 'packs/public-path'; + window.addEventListener('message', e => { const data = e.data || {}; diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js index 8c26f1404..b67fb13e5 100644 --- a/app/javascript/core/public.js +++ b/app/javascript/core/public.js @@ -1,5 +1,6 @@ // This file will be loaded on public pages, regardless of theme. +import 'packs/public-path'; import ready from '../mastodon/ready'; const { delegate } = require('@rails/ujs'); diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js index 9403e339b..d5bb9532c 100644 --- a/app/javascript/core/settings.js +++ b/app/javascript/core/settings.js @@ -1,5 +1,6 @@ // This file will be loaded on settings pages, regardless of theme. +import 'packs/public-path'; import escapeTextContentForBrowser from 'escape-html'; const { delegate } = require('@rails/ujs'); import emojify from '../mastodon/features/emoji/emoji'; diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js index dde06be8c..f076cdf30 100644 --- a/app/javascript/core/two_factor_authentication.js +++ b/app/javascript/core/two_factor_authentication.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import axios from 'axios'; import * as WebAuthnJSON from '@github/webauthn-json'; import ready from '../mastodon/ready'; diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index 24606231c..a4eef2fd2 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -274,11 +274,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id, notifications, timelinesOnly) { +export function muteAccount(id, notifications, timelinesOnly, duration=0) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, timelinesOnly }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, timelinesOnly, duration }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js index 80bcada6e..c0e7a93af 100644 --- a/app/javascript/flavours/glitch/actions/markers.js +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) { }; }; -export function submitMarkers() { - return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); +export function submitMarkers(params = {}) { + const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + if (params.immediate === true) { + debouncedSubmitMarkers.flush(); + } + return result; }; export const fetchMarkers = () => (dispatch, getState) => { diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js index 645261627..b85cc7863 100644 --- a/app/javascript/flavours/glitch/actions/mutes.js +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; export const MUTES_TOGGLE_TIMELINES_ONLY = 'MUTES_TOGGLE_TIMELINES_ONLY'; export function fetchMutes() { @@ -106,6 +107,15 @@ export function toggleHideNotifications() { }; } +export function changeMuteDuration(duration) { + return dispatch => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; +} + export function toggleTimelinesOnly() { return dispatch => { dispatch({ type: MUTES_TOGGLE_TIMELINES_ONLY }); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 7f311153b..eb7087027 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -16,6 +16,7 @@ import { getFiltersRegex } from 'flavours/glitch/selectors'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; import compareId from 'flavours/glitch/util/compare_id'; import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; +import { requestNotificationPermission } from 'flavours/glitch/util/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -46,8 +47,12 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; + export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, }); @@ -327,3 +332,42 @@ export function markNotificationsAsRead() { type: NOTIFICATIONS_MARK_AS_READ, }; }; + +// Browser support +export function setupBrowserNotifications() { + return dispatch => { + dispatch(setBrowserSupport('Notification' in window)); + if ('Notification' in window) { + dispatch(setBrowserPermission(Notification.permission)); + } + + if ('Notification' in window && 'permissions' in navigator) { + navigator.permissions.query({ name: 'notifications' }).then((status) => { + status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); + }); + } + }; +} + +export function requestBrowserPermission(callback = noOp) { + return dispatch => { + requestNotificationPermission((permission) => { + dispatch(setBrowserPermission(permission)); + callback(permission); + }); + }; +}; + +export function setBrowserSupport (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_SUPPORT, + value, + }; +} + +export function setBrowserPermission (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_PERMISSION, + value, + }; +} diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js index f3e58dfe3..23399c630 100644 --- a/app/javascript/flavours/glitch/components/account.js +++ b/app/javascript/flavours/glitch/components/account.js @@ -8,6 +8,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from 'flavours/glitch/util/initial_state'; +import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -116,6 +117,11 @@ class Account extends ImmutablePureComponent { } } + let mute_expires_at; + if (account.get('mute_expires_at')) { + mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>; + } + return small ? ( <Permalink className='account small' @@ -138,6 +144,7 @@ class Account extends ImmutablePureComponent { <div className='account__wrapper'> <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + {mute_expires_at} <DisplayName account={account} /> </Permalink> {buttons ? diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js index c8609e48f..d04c1eb68 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js +++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; -const assetHost = process.env.CDN_HOST || ''; +import { assetHost } from 'flavours/glitch/util/config'; export default class AutosuggestEmoji extends React.PureComponent { diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js index 01bd4a246..ccd0714f1 100644 --- a/app/javascript/flavours/glitch/components/column_header.js +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent { onMove: PropTypes.func, onClick: PropTypes.func, appendContent: PropTypes.node, + collapseIssues: PropTypes.bool, }; state = { @@ -88,7 +89,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -150,7 +151,20 @@ class ColumnHeader extends React.PureComponent { } if (children || (multiColumn && this.props.onPin)) { - collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; + collapseButton = ( + <button + className={collapsibleButtonClassName} + title={formatMessage(collapsed ? messages.show : messages.hide)} + aria-label={formatMessage(collapsed ? messages.show : messages.hide)} + aria-pressed={collapsed ? 'false' : 'true'} + onClick={this.handleToggleClick} + > + <i className='icon-with-badge'> + <Icon id='sliders' /> + {collapseIssues && <i className='icon-with-badge__issue-badge' />} + </i> + </button> + ); } const hasTitle = icon && title; diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js index 219efc28c..a42ba4589 100644 --- a/app/javascript/flavours/glitch/components/icon_with_badge.js +++ b/app/javascript/flavours/glitch/components/icon_with_badge.js @@ -4,16 +4,18 @@ import Icon from 'flavours/glitch/components/icon'; const formatNumber = num => num > 40 ? '40+' : num; -const IconWithBadge = ({ id, count, className }) => ( +const IconWithBadge = ({ id, count, issueBadge, className }) => ( <i className='icon-with-badge'> <Icon id={id} fixedWidth className={className} /> {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} + {issueBadge && <i className='icon-with-badge__issue-badge' />} </i> ); IconWithBadge.propTypes = { id: PropTypes.string.isRequired, count: PropTypes.number.isRequired, + issueBadge: PropTypes.bool, className: PropTypes.string, }; diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js index 8101be87e..762280bec 100644 --- a/app/javascript/flavours/glitch/containers/mastodon.js +++ b/app/javascript/flavours/glitch/containers/mastodon.js @@ -32,13 +32,6 @@ export default class Mastodon extends React.PureComponent { componentDidMount() { this.disconnect = store.dispatch(connectUserStream()); - - // Desktop notifications - // Ask after 1 minute - if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - window.setTimeout(() => Notification.requestPermission(), 60 * 1000); - } - store.dispatch(showOnboardingOnce()); } diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js index d0d9714a8..89219d739 100644 --- a/app/javascript/flavours/glitch/features/emoji_picker/index.js +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -13,6 +13,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji'; import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; +import { assetHost } from 'flavours/glitch/util/config'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -105,7 +106,6 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ }, }); -const assetHost = process.env.CDN_HOST || ''; let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js index acaa78fe3..cd81d07de 100644 --- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js +++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js @@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker'; import AnimatedNumber from 'flavours/glitch/components/animated_number'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; +import { assetHost } from 'flavours/glitch/util/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent { } -const assetHost = process.env.CDN_HOST || ''; - class Emoji extends React.PureComponent { static propTypes = { 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 e4d5d0eda..9748219dd 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent { pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, + onRequestNotificationPermission: PropTypes.func, + alertsEnabled: PropTypes.bool, + browserSupport: PropTypes.bool, + browserPermission: PropTypes.bool, }; onPushChange = (path, checked) => { @@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent { } render () { - const { settings, pushSettings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props; const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; @@ -33,6 +37,12 @@ export default class ColumnSettings extends React.PureComponent { return ( <div> + {alertsEnabled && browserSupport && browserPermission === 'denied' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span> + </div> + )} + <div className='column-settings__row'> <ClearColumnButton onClick={onClear} /> </div> @@ -41,6 +51,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-filter-bar' className='column-settings__section'> <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> </span> + <div className='column-settings__row'> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> @@ -51,7 +62,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> @@ -62,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent { <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} /> + <SettingToggle disabled={browserPermission !== 'granted'} 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} /> @@ -73,7 +84,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> @@ -84,7 +95,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> @@ -95,7 +106,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> @@ -106,12 +117,23 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> </div> </div> + + <div role='group' aria-labelledby='notifications-status'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span> + + <div className='column-settings__row'> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> + </div> + </div> </div> ); } diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js new file mode 100644 index 000000000..8e77f5a03 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js @@ -0,0 +1,30 @@ +import React from 'react'; +import Icon from 'flavours/glitch/components/icon'; +import Button from 'flavours/glitch/components/button'; +import { requestBrowserPermission } from 'flavours/glitch/actions/notifications'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default @connect(() => {}) +class NotificationsPermissionBanner extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + }; + + handleClick = () => { + this.props.dispatch(requestBrowserPermission()); + } + + render () { + return ( + <div className='notifications-permission-banner'> + <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2> + <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p> + <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js index 0264b6815..e472f7c4f 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js +++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js @@ -13,6 +13,7 @@ export default class SettingToggle extends React.PureComponent { meta: PropTypes.node, onChange: PropTypes.func.isRequired, defaultValue: PropTypes.bool, + disabled: PropTypes.bool, } onChange = ({ target }) => { @@ -20,12 +21,12 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingPath, label, meta, defaultValue } = this.props; + const { prefix, settings, settingPath, label, meta, defaultValue, disabled } = this.props; const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> {meta && <span className='setting-meta__label'>{meta}</span>} </div> diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js index 4b863712a..c2564f44e 100644 --- a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js +++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js @@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting } from 'flavours/glitch/actions/settings'; import { setFilter } from 'flavours/glitch/actions/notifications'; -import { clearNotifications } from 'flavours/glitch/actions/notifications'; +import { clearNotifications, requestBrowserPermission } from 'flavours/glitch/actions/notifications'; import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications'; import { openModal } from 'flavours/glitch/actions/modal'; +import { showAlert } from 'flavours/glitch/actions/alerts'; const messages = defineMessages({ clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, + permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' }, }); const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), pushSettings: state.get('push_notifications'), + alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), + browserSupport: state.getIn(['notifications', 'browserSupport']), + browserPermission: state.getIn(['notifications', 'browserPermission']), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (path, checked) { if (path[0] === 'push') { - dispatch(changePushNotifications(path.slice(1), checked)); + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changePushNotifications(path.slice(1), checked)); + } } else if (path[0] === 'quickFilter') { dispatch(changeSetting(['notifications', ...path], checked)); dispatch(setFilter('all')); + } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', ...path], checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } } else { dispatch(changeSetting(['notifications', ...path], checked)); } @@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onRequestNotificationPermission () { + dispatch(requestBrowserPermission()); + }, + }); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index 475968caa..af928594d 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -27,6 +27,7 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list'; import LoadGap from 'flavours/glitch/components/load_gap'; import Icon from 'flavours/glitch/components/icon'; import compareId from 'flavours/glitch/util/compare_id'; +import NotificationsPermissionBanner from './components/notifications_permission_banner'; import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; @@ -62,6 +63,7 @@ const mapStateToProps = state => ({ notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), lastReadId: state.getIn(['notifications', 'readMarkerId']), canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), + needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default', }); /* glitch */ @@ -71,7 +73,7 @@ const mapDispatchToProps = dispatch => ({ }, onMarkAsRead() { dispatch(markNotificationsAsRead()); - dispatch(submitMarkers()); + dispatch(submitMarkers({ immediate: true })); }, onMount() { dispatch(mountNotifications()); @@ -105,6 +107,7 @@ class Notifications extends React.PureComponent { onUnmount: PropTypes.func, lastReadId: PropTypes.string, canMarkAsRead: PropTypes.bool, + needsNotificationPermission: PropTypes.bool, }; static defaultProps = { @@ -211,7 +214,7 @@ class Notifications extends React.PureComponent { } render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props; const { notifCleaning, notifCleaningActive } = this.props; const { animatingNCD } = this.state; const pinned = !!columnId; @@ -257,6 +260,8 @@ class Notifications extends React.PureComponent { showLoading={isLoading && notifications.size === 0} hasMore={hasMore} numPending={numPending} + prepend={needsNotificationPermission && <NotificationsPermissionBanner />} + alwaysPrepend emptyMessage={emptyMessage} onLoadMore={this.handleLoadOlder} onLoadPending={this.handleLoadPending} diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js index b790b29a0..5de3e26d5 100644 --- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js @@ -20,6 +20,7 @@ import GIFV from 'flavours/glitch/components/gifv'; import { me } from 'flavours/glitch/util/initial_state'; import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +import { assetHost } from 'flavours/glitch/util/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') .replace(/\n/g, ' ') .replace(/\*\*\*\*\*\*/g, '\n\n'); -const assetHost = process.env.CDN_HOST || ''; - class ImageLoader extends React.PureComponent { static propTypes = { diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js index eb4bc02d2..5970ceddb 100644 --- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js @@ -1,27 +1,34 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; import Button from 'flavours/glitch/components/button'; import { closeModal } from 'flavours/glitch/actions/modal'; import { muteAccount } from 'flavours/glitch/actions/accounts'; -import { toggleHideNotifications } from 'flavours/glitch/actions/mutes'; +import { toggleHideNotifications, changeMuteDuration } from 'flavours/glitch/actions/mutes'; import { toggleTimelinesOnly } from 'flavours/glitch/actions/mutes'; +const messages = defineMessages({ + 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}}' }, + indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' }, +}); const mapStateToProps = state => { return { account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), + muteDuration: state.getIn(['mutes', 'new', 'duration']), timelinesOnly: state.getIn(['mutes', 'new', 'timelines_only']), }; }; const mapDispatchToProps = dispatch => { return { - onConfirm(account, notifications, timelinesOnly) { - dispatch(muteAccount(account.get('id'), notifications, timelinesOnly)); + onConfirm(account, notifications, timelinesOnly, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, timelinesOnly, muteDuration)); }, onClose() { @@ -32,6 +39,10 @@ const mapDispatchToProps = dispatch => { dispatch(toggleHideNotifications()); }, + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, + onToggleTimelinesOnly() { dispatch(toggleTimelinesOnly()); }, @@ -51,6 +62,8 @@ class MuteModal extends React.PureComponent { onToggleNotifications: PropTypes.func.isRequired, onTimelinesOnly: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + muteDuration: PropTypes.number.isRequired, + onChangeMuteDuration: PropTypes.func.isRequired, }; componentDidMount() { @@ -59,7 +72,7 @@ class MuteModal extends React.PureComponent { handleClick = () => { this.props.onClose(); - this.props.onConfirm(this.props.account, this.props.notifications, this.props.timelinesOnly); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.timelinesOnly, this.props.muteDuration); } handleCancel = () => { @@ -74,12 +87,16 @@ class MuteModal extends React.PureComponent { this.props.onToggleNotifications(); } + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + } + toggleTimelinesOnly = () => { this.props.onToggleTimelinesOnly(); } render () { - const { account, notifications, timelinesOnly } = this.props; + const { account, notifications, timelinesOnly, muteDuration, intl } = this.props; return ( <div className='modal-root__modal mute-modal'> @@ -110,6 +127,21 @@ class MuteModal extends React.PureComponent { <Toggle id='mute-modal__timelines-only-checkbox' checked={timelinesOnly} onChange={this.toggleTimelinesOnly} /> </label> </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage(messages.indefinite)}</option> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> </div> <div className='mute-modal__action-bar'> diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 9b0ff2426..9b4e99905 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -361,7 +361,7 @@ class UI extends React.Component { const visibility = !document[this.visibilityHiddenProp]; this.props.dispatch(notificationsSetVisibility(visibility)); if (visibility) { - this.props.dispatch(submitMarkers()); + this.props.dispatch(submitMarkers({ immediate: true })); } } @@ -387,7 +387,7 @@ class UI extends React.Component { componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); }; if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support diff --git a/app/javascript/flavours/glitch/packs/about.js b/app/javascript/flavours/glitch/packs/about.js index bc0a4887b..2e2cce501 100644 --- a/app/javascript/flavours/glitch/packs/about.js +++ b/app/javascript/flavours/glitch/packs/about.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; function loaded() { diff --git a/app/javascript/flavours/glitch/packs/common.js b/app/javascript/flavours/glitch/packs/common.js index 1fedc890a..7dc34eba9 100644 --- a/app/javascript/flavours/glitch/packs/common.js +++ b/app/javascript/flavours/glitch/packs/common.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import { start } from '@rails/ujs'; start(); diff --git a/app/javascript/flavours/glitch/packs/error.js b/app/javascript/flavours/glitch/packs/error.js index 81c86c3ab..9f692ad37 100644 --- a/app/javascript/flavours/glitch/packs/error.js +++ b/app/javascript/flavours/glitch/packs/error.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import ready from 'flavours/glitch/util/ready'; ready(() => { diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js index b8f7b7d8e..d06688985 100644 --- a/app/javascript/flavours/glitch/packs/home.js +++ b/app/javascript/flavours/glitch/packs/home.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; loadPolyfills().then(() => { diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js index b9b588dc6..c74e5c9af 100644 --- a/app/javascript/flavours/glitch/packs/public.js +++ b/app/javascript/flavours/glitch/packs/public.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import ready from 'flavours/glitch/util/ready'; import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js index 8a9f23505..9c4d119c1 100644 --- a/app/javascript/flavours/glitch/packs/settings.js +++ b/app/javascript/flavours/glitch/packs/settings.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import ready from 'flavours/glitch/util/ready'; import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; diff --git a/app/javascript/flavours/glitch/packs/share.js b/app/javascript/flavours/glitch/packs/share.js index 9f2aa2553..f4a97e201 100644 --- a/app/javascript/flavours/glitch/packs/share.js +++ b/app/javascript/flavours/glitch/packs/share.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; function loaded() { diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js index d170c2594..f116e106a 100644 --- a/app/javascript/flavours/glitch/reducers/mutes.js +++ b/app/javascript/flavours/glitch/reducers/mutes.js @@ -3,6 +3,7 @@ import Immutable from 'immutable'; import { MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, MUTES_TOGGLE_TIMELINES_ONLY, } from 'flavours/glitch/actions/mutes'; @@ -10,6 +11,7 @@ const initialState = Immutable.Map({ new: Immutable.Map({ account: null, notifications: true, + duration: 0, timelinesOnly: false, }), }); @@ -24,6 +26,8 @@ export default function mutes(state = initialState, action) { }); case MUTES_TOGGLE_HIDE_NOTIFICATIONS: return state.updateIn(['new', 'notifications'], (old) => !old); + case MUTES_CHANGE_DURATION: + return state.setIn(['new', 'duration'], Number(action.duration)); case MUTES_TOGGLE_TIMELINES_ONLY: return state.updateIn(['new', 'timelines_only'], (old) => !old); default: diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index e136369ae..b4c5ef71a 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -17,6 +17,8 @@ import { NOTIFICATIONS_ENTER_CLEARING_MODE, NOTIFICATIONS_MARK_ALL_FOR_DELETE, NOTIFICATIONS_MARK_AS_READ, + NOTIFICATIONS_SET_BROWSER_SUPPORT, + NOTIFICATIONS_SET_BROWSER_PERMISSION, } from 'flavours/glitch/actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -44,6 +46,8 @@ const initialState = ImmutableMap({ isLoading: false, cleaningMode: false, isTabVisible: true, + browserSupport: false, + browserPermission: 'default', // notification removal mark of new notifs loaded whilst cleaningMode is true. markNewForDelete: false, }); @@ -185,7 +189,7 @@ const deleteMarkedNotifs = (state) => { const updateMounted = (state) => { state = state.update('mounted', count => count + 1); - if (!shouldCountUnreadNotifications(state)) { + if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) { state = state.set('readMarkerId', state.get('lastReadId')); state = clearUnread(state); } @@ -201,7 +205,7 @@ const updateVisibility = (state, visibility) => { return state; }; -const shouldCountUnreadNotifications = (state) => { +const shouldCountUnreadNotifications = (state, ignoreScroll = false) => { const isTabVisible = state.get('isTabVisible'); const isOnTop = state.get('top'); const isMounted = state.get('mounted') > 0; @@ -209,7 +213,7 @@ const shouldCountUnreadNotifications = (state) => { const lastItem = state.get('items').findLast(item => item !== null); const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0); - return !(isTabVisible && isOnTop && isMounted && lastItemReached); + return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached); }; const recountUnread = (state, last_read_id) => { @@ -275,6 +279,10 @@ export default function notifications(state = initialState, action) { return action.timeline === 'home' ? state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : state; + case NOTIFICATIONS_SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case NOTIFICATIONS_SET_BROWSER_PERMISSION: + return state.set('browserPermission', action.value); case NOTIFICATION_MARK_FOR_DELETE: return markForDelete(state, action.id, action.yes); diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js index 33eb5b425..49dd77ef5 100644 --- a/app/javascript/flavours/glitch/reducers/relationships.js +++ b/app/javascript/flavours/glitch/reducers/relationships.js @@ -45,7 +45,7 @@ const initialState = ImmutableMap(); export default function relationships(state = initialState, action) { switch(action.type) { case ACCOUNT_FOLLOW_REQUEST: - return state.setIn([action.id, action.locked ? 'requested' : 'following'], true); + return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true); case ACCOUNT_FOLLOW_FAIL: return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); case ACCOUNT_UNFOLLOW_REQUEST: diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index ac7d59908..64d13e18c 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -33,12 +33,13 @@ const initialState = ImmutableMap({ notifications: ImmutableMap({ alerts: ImmutableMap({ - follow: true, + follow: false, follow_request: false, - favourite: true, - reblog: true, - mention: true, - poll: true, + favourite: false, + reblog: false, + mention: false, + poll: false, + status: false, }), quickFilter: ImmutableMap({ @@ -54,6 +55,7 @@ const initialState = ImmutableMap({ reblog: true, mention: true, poll: true, + status: true, }), sounds: ImmutableMap({ @@ -63,6 +65,7 @@ const initialState = ImmutableMap({ reblog: true, mention: true, poll: true, + status: true, }), }), diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index 145219fa7..c0bbe5633 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -254,127 +254,6 @@ text-align: center; } -.column-settings__outer { - background: lighten($ui-base-color, 8%); - padding: 15px; -} - -.column-settings__section { - color: $darker-text-color; - cursor: default; - display: block; - font-weight: 500; - margin-bottom: 10px; -} - -.column-settings__hashtags { - .column-settings__row { - margin-bottom: 15px; - } - - .column-select { - &__control { - @include search-input(); - - &::placeholder { - color: lighten($darker-text-color, 4%); - } - - &::-moz-focus-inner { - border: 0; - } - - &::-moz-focus-inner, - &:focus, - &:active { - outline: 0 !important; - } - - &:focus { - background: lighten($ui-base-color, 4%); - } - - @media screen and (max-width: 600px) { - font-size: 16px; - } - } - - &__placeholder { - color: $dark-text-color; - padding-left: 2px; - font-size: 12px; - } - - &__value-container { - padding-left: 6px; - } - - &__multi-value { - background: lighten($ui-base-color, 8%); - - &__remove { - cursor: pointer; - - &:hover, - &:active, - &:focus { - background: lighten($ui-base-color, 12%); - color: lighten($darker-text-color, 4%); - } - } - } - - &__multi-value__label, - &__input { - color: $darker-text-color; - } - - &__clear-indicator, - &__dropdown-indicator { - cursor: pointer; - transition: none; - color: $dark-text-color; - - &:hover, - &:active, - &:focus { - color: lighten($dark-text-color, 4%); - } - } - - &__indicator-separator { - background-color: lighten($ui-base-color, 8%); - } - - &__menu { - @include search-popout(); - padding: 0; - background: $ui-secondary-color; - } - - &__menu-list { - padding: 6px; - } - - &__option { - color: $inverted-text-color; - border-radius: 4px; - font-size: 14px; - - &--is-focused, - &--is-selected { - background: darken($ui-secondary-color, 10%); - } - } - } -} - -.column-settings__row { - .text-btn { - margin-bottom: 15px; - } -} - .relationship-tag { color: $primary-text-color; margin-bottom: 4px; diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index 6b657660a..be32ae52e 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -463,6 +463,15 @@ flex: 1; } +.column-header__issue-btn { + color: $warning-red; + + &:hover { + color: $error-red; + text-decoration: underline; + } +} + .column-header__icon { display: inline-block; margin-right: 5px; @@ -560,3 +569,150 @@ margin: 0 5px; } } + +.column-settings__outer { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-settings__section { + color: $darker-text-color; + cursor: default; + display: block; + font-weight: 500; + margin-bottom: 10px; +} + +.column-settings__row--with-margin { + margin-bottom: 15px; +} + +.column-settings__hashtags { + .column-settings__row { + margin-bottom: 15px; + } + + .column-select { + &__control { + @include search-input(); + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } + } + + &__placeholder { + color: $dark-text-color; + padding-left: 2px; + font-size: 12px; + } + + &__value-container { + padding-left: 6px; + } + + &__multi-value { + background: lighten($ui-base-color, 8%); + + &__remove { + cursor: pointer; + + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 12%); + color: lighten($darker-text-color, 4%); + } + } + } + + &__multi-value__label, + &__input { + color: $darker-text-color; + } + + &__clear-indicator, + &__dropdown-indicator { + cursor: pointer; + transition: none; + color: $dark-text-color; + + &:hover, + &:active, + &:focus { + color: lighten($dark-text-color, 4%); + } + } + + &__indicator-separator { + background-color: lighten($ui-base-color, 8%); + } + + &__menu { + @include search-popout(); + padding: 0; + background: $ui-secondary-color; + } + + &__menu-list { + padding: 6px; + } + + &__option { + color: $inverted-text-color; + border-radius: 4px; + font-size: 14px; + + &--is-focused, + &--is-selected { + background: darken($ui-secondary-color, 10%); + } + } + } +} + +.column-settings__row { + .text-btn { + margin-bottom: 15px; + } +} + +.notifications-permission-banner { + padding: 30px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + h2 { + font-size: 16px; + font-weight: 500; + margin-bottom: 15px; + text-align: center; + } + + p { + color: $darker-text-color; + margin-bottom: 15px; + text-align: center; + } +} diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 04266c497..56d658d97 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -708,6 +708,17 @@ line-height: 14px; color: $primary-text-color; } + + &__issue-badge { + position: absolute; + left: 11px; + bottom: 1px; + display: block; + background: $error-red; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } } .column-link--transparent .icon-with-badge__badge { diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index f80045505..b9335b5b4 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -785,6 +785,22 @@ } } } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-right: 30px; + } } .confirmation-modal__container, diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index e5a5cc246..163b5220c 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -385,3 +385,8 @@ .directory__tag > div { box-shadow: none; } + +.mute-modal select { + border: 1px solid lighten($ui-base-color, 8%); + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px; +} diff --git a/app/javascript/flavours/glitch/util/config.js b/app/javascript/flavours/glitch/util/config.js new file mode 100644 index 000000000..c3e2b73ae --- /dev/null +++ b/app/javascript/flavours/glitch/util/config.js @@ -0,0 +1,10 @@ +import ready from './ready'; + +export let assetHost = ''; + +ready(() => { + const cdnHost = document.querySelector('meta[name=cdn-host]'); + if (cdnHost) { + assetHost = cdnHost.content || ''; + } +}); diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js index 233ec25e3..43ff4661c 100644 --- a/app/javascript/flavours/glitch/util/emoji/index.js +++ b/app/javascript/flavours/glitch/util/emoji/index.js @@ -1,11 +1,10 @@ import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; import unicodeMapping from './emoji_unicode_mapping_light'; +import { assetHost } from 'flavours/glitch/util/config'; import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const assetHost = process.env.CDN_HOST || ''; - // Convert to file names from emojis. (For different variation selector emojis) const emojiFilenames = (emojis) => { return emojis.map(v => unicodeMapping[v].filename); diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js index 1fdb9ff2b..6577b70c2 100644 --- a/app/javascript/flavours/glitch/util/main.js +++ b/app/javascript/flavours/glitch/util/main.js @@ -1,4 +1,5 @@ import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications'; +import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications'; import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -22,6 +23,7 @@ function main() { const props = JSON.parse(mountNode.getAttribute('data-props')); ReactDOM.render(<Mastodon {...props} />, mountNode); + store.dispatch(setupBrowserNotifications()); if (process.env.NODE_ENV === 'production') { // avoid offline in dev mode because it's harder to debug require('offline-plugin/runtime').install(); diff --git a/app/javascript/flavours/glitch/util/notifications.js b/app/javascript/flavours/glitch/util/notifications.js new file mode 100644 index 000000000..ab119c2e3 --- /dev/null +++ b/app/javascript/flavours/glitch/util/notifications.js @@ -0,0 +1,29 @@ +// Handles browser quirks, based on +// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API + +const checkNotificationPromise = () => { + try { + Notification.requestPermission().then(); + } catch(e) { + return false; + } + + return true; +}; + +const handlePermission = (permission, callback) => { + // Whatever the user answers, we make sure Chrome stores the information + if(!('permission' in Notification)) { + Notification.permission = permission; + } + + callback(Notification.permission); +}; + +export const requestNotificationPermission = (callback) => { + if (checkNotificationPromise()) { + Notification.requestPermission().then((permission) => handlePermission(permission, callback)); + } else { + Notification.requestPermission((permission) => handlePermission(permission, callback)); + } +}; diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 723c04e55..58b636602 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -257,11 +257,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id, notifications) { +export function muteAccount(id, notifications, duration=0) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js index 41a95503e..c4b61effd 100644 --- a/app/javascript/mastodon/actions/markers.js +++ b/app/javascript/mastodon/actions/markers.js @@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) { }; }; -export function submitMarkers() { - return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); +export function submitMarkers(params = {}) { + const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + if (params.immediate === true) { + debouncedSubmitMarkers.flush(); + } + return result; }; export const fetchMarkers = () => (dispatch, getState) => { diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index 9f645faee..d8874f353 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; export function fetchMutes() { return (dispatch, getState) => { @@ -104,3 +105,12 @@ export function toggleHideNotifications() { dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); }; } + +export function changeMuteDuration(duration) { + return dispatch => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; +} diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 552def63b..c4fa66428 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; +import { requestNotificationPermission } from '../utils/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -33,8 +34,12 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; + export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, @@ -234,3 +239,47 @@ export const mountNotifications = () => ({ export const unmountNotifications = () => ({ type: NOTIFICATIONS_UNMOUNT, }); + + +export const markNotificationsAsRead = () => ({ + type: NOTIFICATIONS_MARK_AS_READ, +}); + +// Browser support +export function setupBrowserNotifications() { + return dispatch => { + dispatch(setBrowserSupport('Notification' in window)); + if ('Notification' in window) { + dispatch(setBrowserPermission(Notification.permission)); + } + + if ('Notification' in window && 'permissions' in navigator) { + navigator.permissions.query({ name: 'notifications' }).then((status) => { + status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); + }); + } + }; +} + +export function requestBrowserPermission(callback = noOp) { + return dispatch => { + requestNotificationPermission((permission) => { + dispatch(setBrowserPermission(permission)); + callback(permission); + }); + }; +}; + +export function setBrowserSupport (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_SUPPORT, + value, + }; +} + +export function setBrowserPermission (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_PERMISSION, + value, + }; +} diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js index a1dd3a731..42d8ea33f 100644 --- a/app/javascript/mastodon/actions/onboarding.js +++ b/app/javascript/mastodon/actions/onboarding.js @@ -1,8 +1,21 @@ import { changeSetting, saveSettings } from './settings'; +import { requestBrowserPermission } from './notifications'; export const INTRODUCTION_VERSION = 20181216044202; export const closeOnboarding = () => dispatch => { dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); dispatch(saveSettings()); + + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', 'alerts', 'follow'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'mention'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'poll'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'status'], true)); + dispatch(saveSettings()); + } + })); }; diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 2705a6001..0e40ee1d6 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -8,6 +8,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from '../initial_state'; +import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -107,11 +108,17 @@ class Account extends ImmutablePureComponent { } } + let mute_expires_at; + if (account.get('mute_expires_at')) { + mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>; + } + 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> + {mute_expires_at} <DisplayName account={account} /> </Permalink> diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js index ce4383a60..4937e4d98 100644 --- a/app/javascript/mastodon/components/autosuggest_emoji.js +++ b/app/javascript/mastodon/components/autosuggest_emoji.js @@ -1,8 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; - -const assetHost = process.env.CDN_HOST || ''; +import { assetHost } from 'mastodon/utils/config'; export default class AutosuggestEmoji extends React.PureComponent { diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 1bb583583..236e92296 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent { onMove: PropTypes.func, onClick: PropTypes.func, appendContent: PropTypes.node, + collapseIssues: PropTypes.bool, }; state = { @@ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent { } if (children || (multiColumn && this.props.onPin)) { - collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; + collapseButton = ( + <button + className={collapsibleButtonClassName} + title={formatMessage(collapsed ? messages.show : messages.hide)} + aria-label={formatMessage(collapsed ? messages.show : messages.hide)} + aria-pressed={collapsed ? 'false' : 'true'} + onClick={this.handleToggleClick} + > + <i className='icon-with-badge'> + <Icon id='sliders' /> + {collapseIssues && <i className='icon-with-badge__issue-badge' />} + </i> + </button> + ); } const hasTitle = icon && title; diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 7f83dc1b9..7ec39198a 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -116,6 +116,7 @@ export default class IconButton extends React.PureComponent { activate, deactivate, overlayed: overlay, + 'icon-button--with-counter': typeof counter !== 'undefined', }); if (typeof counter !== 'undefined') { diff --git a/app/javascript/mastodon/components/icon_with_badge.js b/app/javascript/mastodon/components/icon_with_badge.js index 7851eb4be..4214eccfd 100644 --- a/app/javascript/mastodon/components/icon_with_badge.js +++ b/app/javascript/mastodon/components/icon_with_badge.js @@ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon'; const formatNumber = num => num > 40 ? '40+' : num; -const IconWithBadge = ({ id, count, className }) => ( +const IconWithBadge = ({ id, count, issueBadge, className }) => ( <i className='icon-with-badge'> <Icon id={id} fixedWidth className={className} /> {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} + {issueBadge && <i className='icon-with-badge__issue-badge' />} </i> ); IconWithBadge.propTypes = { id: PropTypes.string.isRequired, count: PropTypes.number.isRequired, + issueBadge: PropTypes.bool, className: PropTypes.string, }; diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index e8a36a923..bac136e5e 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -7,6 +7,7 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -25,7 +26,6 @@ const messages = defineMessages({ flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, }); -const assetHost = process.env.CDN_HOST || ''; let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index 5d9dad097..4e37f3a80 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -1,11 +1,10 @@ import { autoPlayGif } from '../../initial_state'; import unicodeMapping from './emoji_unicode_mapping_light'; +import { assetHost } from 'mastodon/utils/config'; import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const assetHost = process.env.CDN_HOST || ''; - // Convert to file names from emojis. (For different variation selector emojis) const emojiFilenames = (emojis) => { return emojis.map(v => unicodeMapping[v].filename); diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js index 1896994da..4853c3935 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.js +++ b/app/javascript/mastodon/features/getting_started/components/announcements.js @@ -6,7 +6,7 @@ import PropTypes from 'prop-types'; import IconButton from 'mastodon/components/icon_button'; import Icon from 'mastodon/components/icon'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; -import { autoPlayGif, reduceMotion } from 'mastodon/initial_state'; +import { autoPlayGif, reduceMotion, disableSwiping } from 'mastodon/initial_state'; import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; import { mascot } from 'mastodon/initial_state'; import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; @@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_pick import AnimatedNumber from 'mastodon/components/animated_number'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent { } -const assetHost = process.env.CDN_HOST || ''; - class Emoji extends React.PureComponent { static propTypes = { @@ -436,6 +435,7 @@ class Announcements extends ImmutablePureComponent { removeReaction={this.props.removeReaction} intl={intl} selected={index === idx} + disabled={disableSwiping} /> ))} </ReactSwipeableViews> diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 02e1bbba5..1b9994612 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { me, profile_directory, showTrends } from '../../initial_state'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import { List as ImmutableList } from 'immutable'; -import NavigationBar from '../compose/components/navigation_bar'; +import NavigationContainer from '../compose/containers/navigation_container'; import Icon from 'mastodon/components/icon'; import LinkFooter from 'mastodon/features/ui/components/link_footer'; import TrendsContainer from './containers/trends_container'; @@ -168,7 +168,7 @@ class GettingStarted extends ImmutablePureComponent { <div className='getting-started'> <div className='getting-started__wrapper' style={{ height }}> - {!multiColumn && <NavigationBar account={myAccount} />} + {!multiColumn && <NavigationContainer />} {navItems} </div> diff --git a/app/javascript/mastodon/features/introduction/index.js b/app/javascript/mastodon/features/introduction/index.js index 754477bb9..5820750a4 100644 --- a/app/javascript/mastodon/features/introduction/index.js +++ b/app/javascript/mastodon/features/introduction/index.js @@ -9,6 +9,7 @@ import screenHello from '../../../images/screen_hello.svg'; import screenFederation from '../../../images/screen_federation.svg'; import screenInteractions from '../../../images/screen_interactions.svg'; import logoTransparent from '../../../images/logo_transparent.svg'; +import { disableSwiping } from 'mastodon/initial_state'; const FrameWelcome = ({ domain, onNext }) => ( <div className='introduction__frame'> @@ -171,7 +172,7 @@ class Introduction extends React.PureComponent { return ( <div className='introduction'> - <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='introduction__pager'> + <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} disabled={disableSwiping} className='introduction__pager'> {pages.map((page, i) => ( <div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div> ))} diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 8bd03fbda..169e4b44d 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent { pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, + onRequestNotificationPermission: PropTypes.func, + alertsEnabled: PropTypes.bool, + browserSupport: PropTypes.bool, + browserPermission: PropTypes.bool, }; onPushChange = (path, checked) => { @@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent { } render () { - const { settings, pushSettings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props; const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; @@ -32,6 +36,12 @@ export default class ColumnSettings extends React.PureComponent { return ( <div> + {alertsEnabled && browserSupport && browserPermission === 'denied' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span> + </div> + )} + <div className='column-settings__row'> <ClearColumnButton onClick={onClear} /> </div> @@ -40,6 +50,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-filter-bar' className='column-settings__section'> <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> </span> + <div className='column-settings__row'> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> @@ -50,7 +61,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> @@ -61,7 +72,7 @@ export default class ColumnSettings extends React.PureComponent { <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} /> + <SettingToggle disabled={browserPermission !== 'granted'} 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} /> @@ -72,7 +83,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> @@ -83,7 +94,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> @@ -94,7 +105,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> @@ -105,12 +116,23 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> </div> </div> + + <div role='group' aria-labelledby='notifications-status'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span> + + <div className='column-settings__row'> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> + </div> + </div> </div> ); } diff --git a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js new file mode 100644 index 000000000..766c9bb5b --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js @@ -0,0 +1,30 @@ +import React from 'react'; +import Icon from 'mastodon/components/icon'; +import Button from 'mastodon/components/button'; +import { requestBrowserPermission } from 'mastodon/actions/notifications'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default @connect(() => {}) +class NotificationsPermissionBanner extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + }; + + handleClick = () => { + this.props.dispatch(requestBrowserPermission()); + } + + render () { + return ( + <div className='notifications-permission-banner'> + <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2> + <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p> + <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index e6f593ef8..c4c8bffbe 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -12,6 +12,7 @@ export default class SettingToggle extends React.PureComponent { label: PropTypes.node.isRequired, onChange: PropTypes.func.isRequired, defaultValue: PropTypes.bool, + disabled: PropTypes.bool, } onChange = ({ target }) => { @@ -19,12 +20,12 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingPath, label, defaultValue } = this.props; + const { prefix, settings, settingPath, label, defaultValue, disabled } = this.props; const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> </div> ); diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index a67f26295..9a70bd4f3 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting } from '../../../actions/settings'; import { setFilter } from '../../../actions/notifications'; -import { clearNotifications } from '../../../actions/notifications'; +import { clearNotifications, requestBrowserPermission } from '../../../actions/notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; +import { showAlert } from '../../../actions/alerts'; const messages = defineMessages({ clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, + permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' }, }); const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), pushSettings: state.get('push_notifications'), + alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), + browserSupport: state.getIn(['notifications', 'browserSupport']), + browserPermission: state.getIn(['notifications', 'browserPermission']), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (path, checked) { if (path[0] === 'push') { - dispatch(changePushNotifications(path.slice(1), checked)); + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changePushNotifications(path.slice(1), checked)); + } } else if (path[0] === 'quickFilter') { dispatch(changeSetting(['notifications', ...path], checked)); dispatch(setFilter('all')); + } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', ...path], checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } } else { dispatch(changeSetting(['notifications', ...path], checked)); } @@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onRequestNotificationPermission () { + dispatch(requestBrowserPermission()); + }, + }); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 41a45b2b6..73df7f49d 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -12,6 +12,7 @@ import { unmountNotifications, markNotificationsAsRead, } from '../../actions/notifications'; +import { submitMarkers } from '../../actions/markers'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import NotificationContainer from './containers/notification_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; @@ -24,6 +25,7 @@ import ScrollableList from '../../components/scrollable_list'; import LoadGap from '../../components/load_gap'; import Icon from 'mastodon/components/icon'; import compareId from 'mastodon/compare_id'; +import NotificationsPermissionBanner from './components/notifications_permission_banner'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -54,6 +56,7 @@ const mapStateToProps = state => ({ numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, lastReadId: state.getIn(['notifications', 'readMarkerId']), canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), + needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default', }); export default @connect(mapStateToProps) @@ -74,6 +77,7 @@ class Notifications extends React.PureComponent { numPending: PropTypes.number, lastReadId: PropTypes.string, canMarkAsRead: PropTypes.bool, + needsNotificationPermission: PropTypes.bool, }; static defaultProps = { @@ -162,10 +166,11 @@ class Notifications extends React.PureComponent { handleMarkAsRead = () => { this.props.dispatch(markNotificationsAsRead()); + this.props.dispatch(submitMarkers({ immediate: true })); }; render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props; const pinned = !!columnId; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; @@ -209,6 +214,8 @@ class Notifications extends React.PureComponent { showLoading={isLoading && notifications.size === 0} hasMore={hasMore} numPending={numPending} + prepend={needsNotificationPermission && <NotificationsPermissionBanner />} + alwaysPrepend emptyMessage={emptyMessage} onLoadMore={this.handleLoadOlder} onLoadPending={this.handleLoadPending} diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 9b03cf26d..ecc0b8f0b 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -8,6 +8,8 @@ import ReactSwipeableViews from 'react-swipeable-views'; import TabsBar, { links, getIndex, getLink } from './tabs_bar'; import { Link } from 'react-router-dom'; +import { disableSwiping } from 'mastodon/initial_state'; + import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; @@ -185,7 +187,7 @@ class ColumnsArea extends ImmutablePureComponent { const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>; const content = columnIndex !== -1 ? ( - <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}> + <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}> {links.map(this.renderView)} </ReactSwipeableViews> ) : ( diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 926a495d1..e19f277d8 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -20,6 +20,7 @@ import GIFV from 'mastodon/components/gifv'; import { me } from 'mastodon/initial_state'; import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') .replace(/\n/g, ' ') .replace(/\*\*\*\*\*\*/g, '\n\n'); -const assetHost = process.env.CDN_HOST || ''; - class ImageLoader extends React.PureComponent { static propTypes = { diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index a02e3bbd7..54ec51fcf 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import ImageLoader from './image_loader'; import Icon from 'mastodon/components/icon'; import GIFV from 'mastodon/components/gifv'; +import { disableSwiping } from 'mastodon/initial_state'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -212,6 +213,7 @@ class MediaModal extends ImmutablePureComponent { containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index} + disabled={disableSwiping} > {content} </ReactSwipeableViews> diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js index 852830c3c..d8d8e68c3 100644 --- a/app/javascript/mastodon/features/ui/components/mute_modal.js +++ b/app/javascript/mastodon/features/ui/components/mute_modal.js @@ -1,25 +1,32 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; import Button from '../../../components/button'; import { closeModal } from '../../../actions/modal'; import { muteAccount } from '../../../actions/accounts'; -import { toggleHideNotifications } from '../../../actions/mutes'; +import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes'; +const messages = defineMessages({ + 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}}' }, + indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' }, +}); const mapStateToProps = state => { return { account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), + muteDuration: state.getIn(['mutes', 'new', 'duration']), }; }; const mapDispatchToProps = dispatch => { return { - onConfirm(account, notifications) { - dispatch(muteAccount(account.get('id'), notifications)); + onConfirm(account, notifications, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, muteDuration)); }, onClose() { @@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => { onToggleNotifications() { dispatch(toggleHideNotifications()); }, + + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, }; }; @@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent { onConfirm: PropTypes.func.isRequired, onToggleNotifications: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + muteDuration: PropTypes.number.isRequired, + onChangeMuteDuration: PropTypes.func.isRequired, }; componentDidMount() { @@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent { handleClick = () => { this.props.onClose(); - this.props.onConfirm(this.props.account, this.props.notifications); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); } handleCancel = () => { @@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent { this.props.onToggleNotifications(); } + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + } + render () { - const { account, notifications } = this.props; + const { account, notifications, muteDuration, intl } = this.props; return ( <div className='modal-root__modal mute-modal'> @@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent { <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> </label> </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage(messages.indefinite)}</option> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> </div> <div className='mute-modal__action-bar'> diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index d05133507..c6df49a5f 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -266,7 +266,7 @@ class UI extends React.PureComponent { handleWindowFocus = () => { this.props.dispatch(focusApp()); - this.props.dispatch(submitMarkers()); + this.props.dispatch(submitMarkers({ immediate: true })); } handleWindowBlur = () => { @@ -366,10 +366,6 @@ class UI extends React.PureComponent { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } - if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - window.setTimeout(() => Notification.requestPermission(), 120 * 1000); - } - this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); @@ -379,7 +375,7 @@ class UI extends React.PureComponent { componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); }; } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 847d29dea..89b59051c 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -26,5 +26,6 @@ export const usePendingItems = getMeta('use_pending_items'); export const showTrends = getMeta('trends'); export const title = getMeta('title'); export const cropImages = getMeta('crop_images'); +export const disableSwiping = getMeta('disable_swiping'); export default initialState; diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index fcec001e9..db1158e50 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -168,10 +168,18 @@ { "descriptors": [ { + "defaultMessage": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.", + "id": "error.unexpected_crash.explanation_addons" + }, + { "defaultMessage": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.", "id": "error.unexpected_crash.explanation" }, { + "defaultMessage": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", + "id": "error.unexpected_crash.next_steps_addons" + }, + { "defaultMessage": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", "id": "error.unexpected_crash.next_steps" }, @@ -268,6 +276,15 @@ { "descriptors": [ { + "defaultMessage": "Put it back", + "id": "picture_in_picture.restore" + } + ], + "path": "app/javascript/mastodon/components/picture_in_picture_placeholder.json" + }, + { + "descriptors": [ + { "defaultMessage": "Closed", "id": "poll.closed" }, @@ -636,6 +653,15 @@ { "descriptors": [ { + "defaultMessage": "Profile unavailable", + "id": "empty_column.account_unavailable" + } + ], + "path": "app/javascript/mastodon/features/account_gallery/index.json" + }, + { + "descriptors": [ + { "defaultMessage": "Toots", "id": "account.posts" }, @@ -797,6 +823,14 @@ "id": "account.show_reblogs" }, { + "defaultMessage": "Notify me when @{name} posts", + "id": "account.enable_notifications" + }, + { + "defaultMessage": "Stop notifying me when @{name} posts", + "id": "account.disable_notifications" + }, + { "defaultMessage": "Pinned toots", "id": "navigation_bar.pins" }, @@ -2126,6 +2160,18 @@ "id": "confirmations.delete_list.confirm" }, { + "defaultMessage": "Any followed user", + "id": "lists.replies_policy.all_replies" + }, + { + "defaultMessage": "No one", + "id": "lists.replies_policy.no_replies" + }, + { + "defaultMessage": "Members of the list", + "id": "lists.replies_policy.list_replies" + }, + { "defaultMessage": "Edit list", "id": "lists.edit" }, @@ -2134,6 +2180,10 @@ "id": "lists.delete" }, { + "defaultMessage": "Show replies to:", + "id": "lists.replies_policy.title" + }, + { "defaultMessage": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.", "id": "empty_column.list" } @@ -2219,6 +2269,10 @@ "id": "notifications.column_settings.push" }, { + "defaultMessage": "Desktop notifications are unavailable due to previously denied browser permissions request", + "id": "notifications.permission_denied" + }, + { "defaultMessage": "Quick filter bar", "id": "notifications.column_settings.filter_bar.category" }, @@ -2245,6 +2299,10 @@ { "defaultMessage": "Poll results:", "id": "notifications.column_settings.poll" + }, + { + "defaultMessage": "New toots:", + "id": "notifications.column_settings.status" } ], "path": "app/javascript/mastodon/features/notifications/components/column_settings.json" @@ -2272,6 +2330,10 @@ "id": "notifications.filter.follows" }, { + "defaultMessage": "Updates from people you follow", + "id": "notifications.filter.statuses" + }, + { "defaultMessage": "All", "id": "notifications.filter.all" } @@ -2314,6 +2376,10 @@ "id": "notification.reblog" }, { + "defaultMessage": "{name} just posted", + "id": "notification.status" + }, + { "defaultMessage": "{name} has requested to follow you", "id": "notification.follow_request" } @@ -2323,12 +2389,33 @@ { "descriptors": [ { + "defaultMessage": "Never miss a thing", + "id": "notifications_permission_banner.title" + }, + { + "defaultMessage": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.", + "id": "notifications_permission_banner.how_to_control" + }, + { + "defaultMessage": "Enable desktop notifications", + "id": "notifications_permission_banner.enable" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/notifications_permission_banner.json" + }, + { + "descriptors": [ + { "defaultMessage": "Are you sure you want to permanently clear all your notifications?", "id": "notifications.clear_confirmation" }, { "defaultMessage": "Clear notifications", "id": "notifications.clear" + }, + { + "defaultMessage": "Desktop notifications can't be enabled, as browser permission has been denied before", + "id": "notifications.permission_denied_alert" } ], "path": "app/javascript/mastodon/features/notifications/containers/column_settings_container.json" @@ -2340,6 +2427,10 @@ "id": "column.notifications" }, { + "defaultMessage": "Mark every notification as read", + "id": "notifications.mark_as_read" + }, + { "defaultMessage": "You don't have any notifications yet. Interact with others to start the conversation.", "id": "empty_column.notifications" } @@ -2349,6 +2440,47 @@ { "descriptors": [ { + "defaultMessage": "Reply", + "id": "status.reply" + }, + { + "defaultMessage": "Reply to thread", + "id": "status.replyAll" + }, + { + "defaultMessage": "Boost", + "id": "status.reblog" + }, + { + "defaultMessage": "Boost with original visibility", + "id": "status.reblog_private" + }, + { + "defaultMessage": "Unboost", + "id": "status.cancel_reblog_private" + }, + { + "defaultMessage": "This post cannot be boosted", + "id": "status.cannot_reblog" + }, + { + "defaultMessage": "Favourite", + "id": "status.favourite" + }, + { + "defaultMessage": "Reply", + "id": "confirmations.reply.confirm" + }, + { + "defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", + "id": "confirmations.reply.message" + } + ], + "path": "app/javascript/mastodon/features/picture_in_picture/components/footer.json" + }, + { + "descriptors": [ + { "defaultMessage": "Pinned toot", "id": "column.pins" } @@ -2799,6 +2931,14 @@ "id": "upload_form.description" }, { + "defaultMessage": "Analyzing picture…", + "id": "upload_modal.analyzing_picture" + }, + { + "defaultMessage": "Preparing OCR…", + "id": "upload_modal.preparing_ocr" + }, + { "defaultMessage": "Edit media", "id": "upload_modal.edit_media" }, @@ -2811,10 +2951,6 @@ "id": "upload_form.thumbnail" }, { - "defaultMessage": "Analyzing picture…", - "id": "upload_modal.analyzing_picture" - }, - { "defaultMessage": "Detect text from picture", "id": "upload_modal.detect_text" }, @@ -2911,6 +3047,22 @@ { "descriptors": [ { + "defaultMessage": "{number, plural, one {# minute} other {# minutes}}", + "id": "intervals.full.minutes" + }, + { + "defaultMessage": "{number, plural, one {# hour} other {# hours}}", + "id": "intervals.full.hours" + }, + { + "defaultMessage": "{number, plural, one {# day} other {# days}}", + "id": "intervals.full.days" + }, + { + "defaultMessage": "Indefinite", + "id": "mute_modal.indefinite" + }, + { "defaultMessage": "Are you sure you want to mute {name}?", "id": "confirmations.mute.message" }, @@ -2923,6 +3075,10 @@ "id": "mute_modal.hide_notifications" }, { + "defaultMessage": "Duration", + "id": "mute_modal.duration" + }, + { "defaultMessage": "Cancel", "id": "confirmation_modal.cancel" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index bb07bbc15..e90be0a62 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -9,8 +9,10 @@ "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct message @{name}", + "account.disable_notifications": "Stop notifying me when @{name} posts", "account.domain_blocked": "Domain blocked", "account.edit_profile": "Edit profile", + "account.enable_notifications": "Notify me when @{name} posts", "account.endorse": "Feature on profile", "account.follow": "Follow", "account.followers": "Followers", @@ -170,7 +172,9 @@ "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up", "error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.", + "error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.", "error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", + "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard", "errors.unexpected_crash.report_issue": "Report issue", "follow_request.authorize": "Authorize", @@ -264,6 +268,10 @@ "lists.edit.submit": "Change title", "lists.new.create": "Add list", "lists.new.title_placeholder": "New list title", + "lists.replies_policy.all_replies": "Any followed user", + "lists.replies_policy.list_replies": "Members of the list", + "lists.replies_policy.no_replies": "No one", + "lists.replies_policy.title": "Show replies to:", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", "load_pending": "{count, plural, one {# new item} other {# new items}}", @@ -271,7 +279,9 @@ "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}", "missing_indicator.label": "Not found", "missing_indicator.sublabel": "This resource could not be found", + "mute_modal.duration": "Duration", "mute_modal.hide_notifications": "Hide notifications from this user?", + "mute_modal.indefinite": "Indefinite", "navigation_bar.apps": "Mobile apps", "navigation_bar.blocks": "Blocked users", "navigation_bar.bookmarks": "Bookmarks", @@ -303,6 +313,7 @@ "notification.own_poll": "Your poll has ended", "notification.poll": "A poll you have voted in has ended", "notification.reblog": "{name} boosted your toot", + "notification.status": "{name} just posted", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", "notifications.column_settings.alert": "Desktop notifications", @@ -318,13 +329,22 @@ "notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.show": "Show in column", "notifications.column_settings.sound": "Play sound", + "notifications.column_settings.status": "New toots:", "notifications.filter.all": "All", "notifications.filter.boosts": "Boosts", "notifications.filter.favourites": "Favourites", "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.filter.polls": "Poll results", + "notifications.filter.statuses": "Updates from people you follow", "notifications.group": "{count} notifications", + "notifications.mark_as_read": "Mark every notification as read", + "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request", + "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before", + "notifications_permission_banner.enable": "Enable desktop notifications", + "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.", + "notifications_permission_banner.title": "Never miss a thing", + "picture_in_picture.restore": "Put it back", "poll.closed": "Closed", "poll.refresh": "Refresh", "poll.total_people": "{count, plural, one {# person} other {# people}}", @@ -451,6 +471,7 @@ "upload_modal.detect_text": "Detect text from picture", "upload_modal.edit_media": "Edit media", "upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.", + "upload_modal.preparing_ocr": "Preparing OCR…", "upload_modal.preview_label": "Preview ({ratio})", "upload_progress.label": "Uploading...", "video.close": "Close video", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index ef8d8d1e6..d26871c98 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -272,6 +272,8 @@ "missing_indicator.label": "見つかりません", "missing_indicator.sublabel": "見つかりませんでした", "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?", + "mute_modal.duration": "ミュートする期間", + "mute_modal.indefinite": "無期限", "navigation_bar.apps": "アプリ", "navigation_bar.blocks": "ブロックしたユーザー", "navigation_bar.bookmarks": "ブックマーク", diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index da4884fd3..bda51f692 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -1,4 +1,5 @@ import * as registerPushNotifications from './actions/push_notifications'; +import { setupBrowserNotifications } from './actions/notifications'; import { default as Mastodon, store } from './containers/mastodon'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -22,6 +23,7 @@ function main() { const props = JSON.parse(mountNode.getAttribute('data-props')); ReactDOM.render(<Mastodon {...props} />, mountNode); + store.dispatch(setupBrowserNotifications()); if (process.env.NODE_ENV === 'production') { // avoid offline in dev mode because it's harder to debug require('offline-plugin/runtime').install(); diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js index 4672e5097..a9eb61ff8 100644 --- a/app/javascript/mastodon/reducers/mutes.js +++ b/app/javascript/mastodon/reducers/mutes.js @@ -3,12 +3,14 @@ import Immutable from 'immutable'; import { MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, } from '../actions/mutes'; const initialState = Immutable.Map({ new: Immutable.Map({ account: null, notifications: true, + duration: 0, }), }); @@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) { }); case MUTES_TOGGLE_HIDE_NOTIFICATIONS: return state.updateIn(['new', 'notifications'], (old) => !old); + case MUTES_CHANGE_DURATION: + return state.setIn(['new', 'duration'], Number(action.duration)); default: return state; } diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index b01db806f..1d4874717 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -10,6 +10,8 @@ import { NOTIFICATIONS_MOUNT, NOTIFICATIONS_UNMOUNT, NOTIFICATIONS_MARK_AS_READ, + NOTIFICATIONS_SET_BROWSER_SUPPORT, + NOTIFICATIONS_SET_BROWSER_PERMISSION, } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -40,6 +42,8 @@ const initialState = ImmutableMap({ readMarkerId: '0', isTabVisible: true, isLoading: false, + browserSupport: false, + browserPermission: 'default', }); const notificationToMap = notification => ImmutableMap({ @@ -151,7 +155,7 @@ const deleteByStatus = (state, statusId) => { const updateMounted = (state) => { state = state.update('mounted', count => count + 1); - if (!shouldCountUnreadNotifications(state)) { + if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) { state = state.set('readMarkerId', state.get('lastReadId')); state = clearUnread(state); } @@ -167,14 +171,15 @@ const updateVisibility = (state, visibility) => { return state; }; -const shouldCountUnreadNotifications = (state) => { +const shouldCountUnreadNotifications = (state, ignoreScroll = false) => { const isTabVisible = state.get('isTabVisible'); const isOnTop = state.get('top'); const isMounted = state.get('mounted') > 0; const lastReadId = state.get('lastReadId'); - const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0); + const lastItem = state.get('items').findLast(item => item !== null); + const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0); - return !(isTabVisible && isOnTop && isMounted && lastItemReached); + return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached); }; const recountUnread = (state, last_read_id) => { @@ -241,6 +246,10 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_MARK_AS_READ: const lastNotification = state.get('items').find(item => item !== null); return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; + case NOTIFICATIONS_SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case NOTIFICATIONS_SET_BROWSER_PERMISSION: + return state.set('browserPermission', action.value); default: return state; } diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js index 1d050cc63..53949258a 100644 --- a/app/javascript/mastodon/reducers/relationships.js +++ b/app/javascript/mastodon/reducers/relationships.js @@ -45,7 +45,7 @@ const initialState = ImmutableMap(); export default function relationships(state = initialState, action) { switch(action.type) { case ACCOUNT_FOLLOW_REQUEST: - return state.setIn([action.id, action.locked ? 'requested' : 'following'], true); + return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true); case ACCOUNT_FOLLOW_FAIL: return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); case ACCOUNT_UNFOLLOW_REQUEST: diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index efef2ad9a..057fa353a 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -29,12 +29,13 @@ const initialState = ImmutableMap({ notifications: ImmutableMap({ alerts: ImmutableMap({ - follow: true, + follow: false, follow_request: false, - favourite: true, - reblog: true, - mention: true, - poll: true, + favourite: false, + reblog: false, + mention: false, + poll: false, + status: false, }), quickFilter: ImmutableMap({ @@ -50,6 +51,7 @@ const initialState = ImmutableMap({ reblog: true, mention: true, poll: true, + status: true, }), sounds: ImmutableMap({ @@ -59,6 +61,7 @@ const initialState = ImmutableMap({ reblog: true, mention: true, poll: true, + status: true, }), }), diff --git a/app/javascript/mastodon/utils/config.js b/app/javascript/mastodon/utils/config.js new file mode 100644 index 000000000..932cd0cbf --- /dev/null +++ b/app/javascript/mastodon/utils/config.js @@ -0,0 +1,10 @@ +import ready from '../ready'; + +export let assetHost = ''; + +ready(() => { + const cdnHost = document.querySelector('meta[name=cdn-host]'); + if (cdnHost) { + assetHost = cdnHost.content || ''; + } +}); diff --git a/app/javascript/mastodon/utils/notifications.js b/app/javascript/mastodon/utils/notifications.js new file mode 100644 index 000000000..ab119c2e3 --- /dev/null +++ b/app/javascript/mastodon/utils/notifications.js @@ -0,0 +1,29 @@ +// Handles browser quirks, based on +// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API + +const checkNotificationPromise = () => { + try { + Notification.requestPermission().then(); + } catch(e) { + return false; + } + + return true; +}; + +const handlePermission = (permission, callback) => { + // Whatever the user answers, we make sure Chrome stores the information + if(!('permission' in Notification)) { + Notification.permission = permission; + } + + callback(Notification.permission); +}; + +export const requestNotificationPermission = (callback) => { + if (checkNotificationPromise()) { + Notification.requestPermission().then((permission) => handlePermission(permission, callback)); + } else { + Notification.requestPermission((permission) => handlePermission(permission, callback)); + } +}; diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js index 843cb2c87..892d825ec 100644 --- a/app/javascript/packs/about.js +++ b/app/javascript/packs/about.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index c65ebed74..91240aecf 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js index 5d42509c5..05dff8e49 100644 --- a/app/javascript/packs/common.js +++ b/app/javascript/packs/common.js @@ -1 +1,2 @@ +import './public-path'; import 'styles/application.scss'; diff --git a/app/javascript/packs/error.js b/app/javascript/packs/error.js index 685c89065..6376dc2f5 100644 --- a/app/javascript/packs/error.js +++ b/app/javascript/packs/error.js @@ -1,3 +1,4 @@ +import './public-path'; import ready from '../mastodon/ready'; ready(() => { diff --git a/app/javascript/packs/public-path.js b/app/javascript/packs/public-path.js new file mode 100644 index 000000000..f96109f4f --- /dev/null +++ b/app/javascript/packs/public-path.js @@ -0,0 +1,21 @@ +// Dynamically set webpack's loading path depending on a meta header, in order +// to share the same assets regardless of instance configuration. +// See https://webpack.js.org/guides/public-path/#on-the-fly + +function removeOuterSlashes(string) { + return string.replace(/^\/*/, '').replace(/\/*$/, ''); +} + +function formatPublicPath(host = '', path = '') { + let formattedHost = removeOuterSlashes(host); + if (formattedHost && !/^http/i.test(formattedHost)) { + formattedHost = `//${formattedHost}`; + } + const formattedPath = removeOuterSlashes(path); + return `${formattedHost}/${formattedPath}/`; +} + +const cdnHost = document.querySelector('meta[name=cdn-host]'); + +// eslint-disable-next-line camelcase, no-undef, no-unused-vars +__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 5ad25a9b0..3f6700195 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import ready from '../mastodon/ready'; import { start } from '../mastodon/common'; diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js index 4ef23e1b2..1225d7b52 100644 --- a/app/javascript/packs/share.js +++ b/app/javascript/packs/share.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 6b81e7623..64f4adb84 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -759,3 +759,8 @@ html { .compose-form .compose-form__warning { box-shadow: none; } + +.mute-modal select { + border: 1px solid lighten($ui-base-color, 8%); + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px; +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ff4bb3428..be554a502 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -163,8 +163,7 @@ } .icon-button { - display: inline-flex; - align-items: center; + display: inline-block; padding: 0; color: $action-button-color; border: 0; @@ -173,6 +172,7 @@ cursor: pointer; transition: all 100ms ease-in; transition-property: background-color, color; + text-decoration: none; &:hover, &:active, @@ -247,6 +247,12 @@ } } + &--with-counter { + display: inline-flex; + align-items: center; + width: auto !important; + } + &__counter { display: inline-block; width: 14px; @@ -1152,6 +1158,10 @@ .status__action-bar-button { margin-right: 18px; + + &.icon-button--with-counter { + margin-right: 14px; + } } .status__action-bar-dropdown { @@ -2408,6 +2418,17 @@ a.account__display-name { line-height: 14px; color: $primary-text-color; } + + &__issue-badge { + position: absolute; + left: 11px; + bottom: 1px; + display: block; + background: $error-red; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } } .column-link--transparent .icon-with-badge__badge { @@ -3443,6 +3464,15 @@ a.status-card.compact:hover { cursor: pointer; } +.column-header__issue-btn { + color: $warning-red; + + &:hover { + color: $error-red; + text-decoration: underline; + } +} + .column-header__icon { display: inline-block; margin-right: 5px; @@ -3702,6 +3732,10 @@ a.status-card.compact:hover { margin-bottom: 10px; } +.column-settings__row--with-margin { + margin-bottom: 15px; +} + .column-settings__hashtags { .column-settings__row { margin-bottom: 15px; @@ -5044,6 +5078,22 @@ a.status-card.compact:hover { } } } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-right: 30px; + } } .confirmation-modal__container, @@ -7122,3 +7172,25 @@ noscript { border-color: lighten($ui-base-color, 12%); } } + +.notifications-permission-banner { + padding: 30px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + h2 { + font-size: 16px; + font-weight: 500; + margin-bottom: 15px; + text-align: center; + } + + p { + color: $darker-text-color; + margin-bottom: 15px; + text-align: center; + } +} diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 7567b66d4..968dd3f67 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -74,7 +74,7 @@ class ActivityPub::Activity @object_uri ||= begin str = value_or_id(@object) - if str.start_with?('bear:') + if str&.start_with?('bear:') Addressable::URI.parse(str).query_values['u'] else str diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index c26301f7e..c89c1ebb7 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -40,6 +40,10 @@ class ActivityPub::TagManager end end + def uri_for_username(username) + account_url(username: username) + end + def generate_uri_for(_target) URI.join(root_url, 'payloads', SecureRandom.uuid) end diff --git a/app/lib/fast_ip_map.rb b/app/lib/fast_ip_map.rb new file mode 100644 index 000000000..ba30b45f3 --- /dev/null +++ b/app/lib/fast_ip_map.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class FastIpMap + MAX_IPV4_PREFIX = 32 + MAX_IPV6_PREFIX = 128 + + # @param [Enumerable<IPAddr>] addresses + def initialize(addresses) + @fast_lookup = {} + @ranges = [] + + # Hash look-up is faster but only works for exact matches, so we split + # exact addresses from non-exact ones + addresses.each do |address| + if (address.ipv4? && address.prefix == MAX_IPV4_PREFIX) || (address.ipv6? && address.prefix == MAX_IPV6_PREFIX) + @fast_lookup[address.to_s] = true + else + @ranges << address + end + end + + # We're more likely to hit wider-reaching ranges when checking for + # inclusion, so make sure they're sorted first + @ranges.sort_by!(&:prefix) + end + + # @param [IPAddr] address + # @return [Boolean] + def include?(address) + @fast_lookup[address.to_s] || @ranges.any? { |cidr| cidr.include?(address) } + end +end diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb index adbbd2168..3bc25fe9f 100644 --- a/app/lib/sanitize_config.rb +++ b/app/lib/sanitize_config.rb @@ -18,6 +18,7 @@ class Sanitize gopher xmpp magnet + gemini ).freeze CLASS_WHITELIST_TRANSFORMER = lambda do |env| diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb index 8aa826561..9889940f3 100644 --- a/app/lib/settings/scoped_settings.rb +++ b/app/lib/settings/scoped_settings.rb @@ -12,7 +12,6 @@ module Settings @object = object end - # rubocop:disable Style/MethodMissingSuper def method_missing(method, *args) method_name = method.to_s # set a value for a variable @@ -25,7 +24,6 @@ module Settings self[method_name] end end - # rubocop:enable Style/MethodMissingSuper def respond_to_missing?(*) true diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 8d1af705a..b87635dbc 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -32,6 +32,7 @@ class UserSettingsDecorator user.settings['display_media'] = display_media_preference if change?('setting_display_media') user.settings['expand_spoilers'] = expand_spoilers_preference if change?('setting_expand_spoilers') user.settings['reduce_motion'] = reduce_motion_preference if change?('setting_reduce_motion') + user.settings['disable_swiping'] = disable_swiping_preference if change?('setting_disable_swiping') user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') user.settings['system_emoji_font'] = system_emoji_font_preference if change?('setting_system_emoji_font') user.settings['noindex'] = noindex_preference if change?('setting_noindex') @@ -123,6 +124,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_reduce_motion' end + def disable_swiping_preference + boolean_cast_setting 'setting_disable_swiping' + end + def noindex_preference boolean_cast_setting 'setting_noindex' end diff --git a/app/lib/webfinger.rb b/app/lib/webfinger.rb new file mode 100644 index 000000000..b2374c494 --- /dev/null +++ b/app/lib/webfinger.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Webfinger + class Error < StandardError; end + + class Response + def initialize(body) + @json = Oj.load(body, mode: :strict) + end + + def subject + @json['subject'] + end + + def link(rel, attribute) + links.dig(rel, attribute) + end + + private + + def links + @links ||= @json['links'].map { |link| [link['rel'], link] }.to_h + end + end + + def initialize(uri) + _, @domain = uri.split('@') + + raise ArgumentError, 'Webfinger requested for local account' if @domain.nil? + + @uri = uri + end + + def perform + Response.new(body_from_webfinger) + rescue Oj::ParseError + raise Webfinger::Error, "Invalid JSON in response for #{@uri}" + rescue Addressable::URI::InvalidURIError + raise Webfinger::Error, "Invalid URI for #{@uri}" + end + + private + + def body_from_webfinger(url = standard_url, use_fallback = true) + webfinger_request(url).perform do |res| + if res.code == 200 + res.body_with_limit + elsif res.code == 404 && use_fallback + body_from_host_meta + else + raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}" + end + end + end + + def body_from_host_meta + host_meta_request.perform do |res| + if res.code == 200 + body_from_webfinger(url_from_template(res.body_with_limit), false) + else + raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}" + end + end + end + + def url_from_template(str) + link = Nokogiri::XML(str).at_xpath('//xmlns:Link[@rel="lrdd"]') + + if link.present? + link['template'].gsub('{uri}', @uri) + else + raise Webfinger::Error, "Request for #{@uri} returned host-meta without link to Webfinger" + end + rescue Nokogiri::XML::XPath::SyntaxError + raise Webfinger::Error, "Invalid XML encountered in host-meta for #{@uri}" + end + + def host_meta_request + Request.new(:get, host_meta_url).add_headers('Accept' => 'application/xrd+xml, application/xml, text/xml') + end + + def webfinger_request(url) + Request.new(:get, url).add_headers('Accept' => 'application/jrd+json, application/json') + end + + def standard_url + "https://#{@domain}/.well-known/webfinger?resource=#{@uri}" + end + + def host_meta_url + "https://#{@domain}/.well-known/host-meta" + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 228e36755..1b8afe594 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -361,6 +361,12 @@ class Account < ApplicationRecord shared_inbox_url.presence || inbox_url end + def synchronization_uri_prefix + return 'local' if local? + + @synchronization_uri_prefix ||= uri[/http(s?):\/\/[^\/]+\//] + end + def max_visibility_for_domain(domain) return 'public' if domain.blank? diff --git a/app/models/account_alias.rb b/app/models/account_alias.rb index 792e9e8d4..3d659142a 100644 --- a/app/models/account_alias.rb +++ b/app/models/account_alias.rb @@ -33,7 +33,7 @@ class AccountAlias < ApplicationRecord def set_uri target_account = ResolveAccountService.new.call(acct) self.uri = ActivityPub::TagManager.instance.uri_for(target_account) unless target_account.nil? - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error # Validation will take care of it end diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb index 681b5b2cd..4fae98ed7 100644 --- a/app/models/account_migration.rb +++ b/app/models/account_migration.rb @@ -54,7 +54,7 @@ class AccountMigration < ApplicationRecord def set_target_account self.target_account = ResolveAccountService.new.call(acct) - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error # Validation will take care of it end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 184064e94..98d06d586 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -132,14 +132,19 @@ module AccountInteractions .find_or_create_by!(target_account: other_account) end - def mute!(other_account, notifications: nil, timelines_only: nil) + def mute!(other_account, notifications: nil, timelines_only: nil, duration: 0) notifications = true if notifications.nil? timelines_only = false if timelines_only.nil? - mute = mute_relationships.create_with(hide_notifications: notifications, timelines_only: timelines_only).find_or_create_by!(target_account: other_account) + mute = mute_relationships.create_with(hide_notifications: notifications, timelines_only: timelines_only).find_or_initialize_by(target_account: other_account) + mute.expires_in = duration.zero? ? nil : duration + mute.save! + remove_potential_friendship(other_account) # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. - mute.update!(hide_notifications: notifications, timelines_only: timelines_only) if mute.hide_notifications? != notifications + if mute.hide_notifications? != notifications || mute.timelines_only? != timelines_only + mute.update!(hide_notifications: notifications, timelines_only: timelines_only) + end mute end @@ -253,6 +258,26 @@ module AccountInteractions .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago) end + def remote_followers_hash(url_prefix) + Rails.cache.fetch("followers_hash:#{id}:#{url_prefix}") do + digest = "\x00" * 32 + followers.where(Account.arel_table[:uri].matches(url_prefix + '%', false, true)).pluck_each(:uri) do |uri| + Xorcist.xor!(digest, Digest::SHA256.digest(uri)) + end + digest.unpack('H*')[0] + end + end + + def local_followers_hash + Rails.cache.fetch("followers_hash:#{id}:local") do + digest = "\x00" * 32 + followers.where(domain: nil).pluck_each(:username) do |username| + Xorcist.xor!(digest, Digest::SHA256.digest(ActivityPub::TagManager.instance.uri_for_username(username))) + end + digest.unpack('H*')[0] + end + end + private def remove_potential_friendship(other_account, mutual = false) diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb index f7d2bab49..a66a4661b 100644 --- a/app/models/concerns/expireable.rb +++ b/app/models/concerns/expireable.rb @@ -6,7 +6,15 @@ module Expireable included do scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } - attr_reader :expires_in + def expires_in + return @expires_in if defined?(@expires_in) + + if expires_at.nil? + nil + else + (expires_at - created_at).to_i + end + end def expires_in=(interval) self.expires_at = interval.to_i.seconds.from_now if interval.present? diff --git a/app/models/follow.rb b/app/models/follow.rb index 0b4ddbf3f..55a9da792 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -41,8 +41,10 @@ class Follow < ApplicationRecord before_validation :set_uri, only: :create after_create :increment_cache_counters + after_create :invalidate_hash_cache after_destroy :remove_endorsements after_destroy :decrement_cache_counters + after_destroy :invalidate_hash_cache private @@ -63,4 +65,10 @@ class Follow < ApplicationRecord account&.decrement_count!(:following_count) target_account&.decrement_count!(:followers_count) end + + def invalidate_hash_cache + return if account.local? && target_account.local? + + Rails.cache.delete("followers_hash:#{target_account_id}:#{account.synchronization_uri_prefix}") + end end diff --git a/app/models/form/ip_block_batch.rb b/app/models/form/ip_block_batch.rb new file mode 100644 index 000000000..f6fe9b593 --- /dev/null +++ b/app/models/form/ip_block_batch.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Form::IpBlockBatch + include ActiveModel::Model + include Authorization + include AccountableConcern + + attr_accessor :ip_block_ids, :action, :current_account + + def save + case action + when 'delete' + delete! + end + end + + private + + def ip_blocks + @ip_blocks ||= IpBlock.where(id: ip_block_ids) + end + + def delete! + ip_blocks.each { |ip_block| authorize(ip_block, :destroy?) } + + ip_blocks.each do |ip_block| + ip_block.destroy + log_action :destroy, ip_block + end + end +end diff --git a/app/models/form/redirect.rb b/app/models/form/redirect.rb index a7961f8e8..19ee9faed 100644 --- a/app/models/form/redirect.rb +++ b/app/models/form/redirect.rb @@ -32,7 +32,7 @@ class Form::Redirect def set_target_account @target_account = ResolveAccountService.new.call(acct) - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error # Validation will take care of it end diff --git a/app/models/ip_block.rb b/app/models/ip_block.rb new file mode 100644 index 000000000..aedd3ca0d --- /dev/null +++ b/app/models/ip_block.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: ip_blocks +# +# id :bigint(8) not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# expires_at :datetime +# ip :inet default(#<IPAddr: IPv4:0.0.0.0/255.255.255.255>), not null +# severity :integer default(NULL), not null +# comment :text default(""), not null +# + +class IpBlock < ApplicationRecord + CACHE_KEY = 'blocked_ips' + + include Expireable + + enum severity: { + sign_up_requires_approval: 5000, + no_access: 9999, + } + + validates :ip, :severity, presence: true + + after_commit :reset_cache + + class << self + def blocked?(remote_ip) + blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) } + blocked_ips_map.include?(remote_ip) + end + end + + private + + def reset_cache + Rails.cache.delete(CACHE_KEY) + end +end diff --git a/app/models/mute.rb b/app/models/mute.rb index 11f833d8e..bdd1d27d6 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -9,12 +9,15 @@ # hide_notifications :boolean default(TRUE), not null # account_id :bigint(8) not null # target_account_id :bigint(8) not null +# hide_notifications :boolean default(TRUE), not null +# expires_at :datetime # timelines_only :boolean default(FALSE), not null # class Mute < ApplicationRecord include Paginable include RelationshipCacheable + include Expireable belongs_to :account belongs_to :target_account, class_name: 'Account' diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb index 30b84f7d5..911c06713 100644 --- a/app/models/remote_follow.rb +++ b/app/models/remote_follow.rb @@ -56,7 +56,7 @@ class RemoteFollow if domain.nil? @addressable_template = Addressable::Template.new("#{authorize_interaction_url}?uri={uri}") - elsif redirect_url_link.nil? || redirect_url_link.template.nil? + elsif redirect_uri_template.nil? missing_resource_error else @addressable_template = Addressable::Template.new(redirect_uri_template) @@ -64,16 +64,12 @@ class RemoteFollow end def redirect_uri_template - redirect_url_link.template - end - - def redirect_url_link - acct_resource&.link('http://ostatus.org/schema/1.0/subscribe') + acct_resource&.link('http://ostatus.org/schema/1.0/subscribe', 'template') end def acct_resource @acct_resource ||= webfinger!("acct:#{acct}") - rescue Goldfinger::Error, HTTP::ConnectionError + rescue Webfinger::Error, HTTP::ConnectionError nil end diff --git a/app/models/user.rb b/app/models/user.rb index afe248f57..4ec5a5d8e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,6 +41,7 @@ # sign_in_token :string # sign_in_token_sent_at :datetime # webauthn_id :string +# sign_up_ip :inet # username :string # kobold :string # @@ -99,7 +100,7 @@ class User < ApplicationRecord scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) } scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) } scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) } - scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) } + scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) } scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } scope :lower_username, ->(username) { where('lower(users.username) = lower(?)', username) } @@ -118,7 +119,7 @@ class User < ApplicationRecord :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_network, :hide_followers_count, :expand_spoilers, :default_language, :show_application, :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, - :default_content_type, :system_emoji_font, + :disable_swiping, :default_content_type, :system_emoji_font, :manual_publish, :style_dashed_nest, :style_underline_a, :style_css_profile, :style_css_profile_errors, :style_css_webapp, :style_css_webapp_errors, :style_wide_media, @@ -363,6 +364,7 @@ class User < ApplicationRecord arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present? arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present? + arr << [created_at, sign_up_ip] if sign_up_ip.present? arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse! end @@ -417,7 +419,17 @@ class User < ApplicationRecord end def set_approved - self.approved = open_registrations? || valid_invitation? || external? + self.approved = begin + if sign_up_from_ip_requires_approval? + false + else + open_registrations? || valid_invitation? || external? + end + end + end + + def sign_up_from_ip_requires_approval? + !sign_up_ip.nil? && IpBlock.where(severity: :sign_up_requires_approval).where('ip >>= ?', sign_up_ip.to_s).exists? end def open_registrations? diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb new file mode 100644 index 000000000..34dbd746a --- /dev/null +++ b/app/policies/ip_block_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class IpBlockPolicy < ApplicationPolicy + def index? + admin? + end + + def create? + admin? + end + + def destroy? + admin? + end +end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index c520c9bcb..470cec8a1 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -48,6 +48,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:display_media] = object.current_account.user.setting_display_media store[:expand_spoilers] = object.current_account.user.setting_expand_spoilers store[:reduce_motion] = object.current_account.user.setting_reduce_motion + store[:disable_swiping] = object.current_account.user.setting_disable_swiping store[:advanced_layout] = object.current_account.user.setting_advanced_layout store[:use_blurhash] = object.current_account.user.setting_use_blurhash store[:use_pending_items] = object.current_account.user.setting_use_pending_items diff --git a/app/serializers/rest/muted_account_serializer.rb b/app/serializers/rest/muted_account_serializer.rb new file mode 100644 index 000000000..3ddd706dc --- /dev/null +++ b/app/serializers/rest/muted_account_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class REST::MutedAccountSerializer < REST::AccountSerializer + attribute :mute_expires_at + + def mute_expires_at + mute = current_user.account.mute_relationships.find_by(target_account_id: object.id) + mute && !mute.expired? ? mute.expires_at : nil + end +end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 83fbf6d07..e5bd0c47c 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -39,17 +39,16 @@ class ActivityPub::FetchRemoteAccountService < BaseService webfinger = webfinger!("acct:#{@username}@#{@domain}") confirmed_username, confirmed_domain = split_acct(webfinger.subject) - return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? webfinger = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}") @username, @domain = split_acct(webfinger.subject) - self_reference = webfinger.link('self') return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? - return false if self_reference&.href != @uri + return false if webfinger.link('self', 'href') != @uri true - rescue Goldfinger::Error + rescue Webfinger::Error false end diff --git a/app/services/activitypub/prepare_followers_synchronization_service.rb b/app/services/activitypub/prepare_followers_synchronization_service.rb new file mode 100644 index 000000000..2d22ed701 --- /dev/null +++ b/app/services/activitypub/prepare_followers_synchronization_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ActivityPub::PrepareFollowersSynchronizationService < BaseService + include JsonLdHelper + + def call(account, params) + @account = account + + return if params['collectionId'] != @account.followers_url || invalid_origin?(params['url']) || @account.local_followers_hash == params['digest'] + + ActivityPub::FollowersSynchronizationWorker.perform_async(@account.id, params['url']) + end +end diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb new file mode 100644 index 000000000..d83fcf55e --- /dev/null +++ b/app/services/activitypub/synchronize_followers_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class ActivityPub::SynchronizeFollowersService < BaseService + include JsonLdHelper + include Payloadable + + def call(account, partial_collection_url) + @account = account + + items = collection_items(partial_collection_url) + return if items.nil? + + # There could be unresolved accounts (hence the call to .compact) but this + # should never happen in practice, since in almost all cases we keep an + # Account record, and should we not do that, we should have sent a Delete. + # In any case there is not much we can do if that occurs. + @expected_followers = items.map { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Account) }.compact + + remove_unexpected_local_followers! + handle_unexpected_outgoing_follows! + end + + private + + def remove_unexpected_local_followers! + @account.followers.local.where.not(id: @expected_followers.map(&:id)).each do |unexpected_follower| + UnfollowService.new.call(unexpected_follower, @account) + end + end + + def handle_unexpected_outgoing_follows! + @expected_followers.each do |expected_follower| + next if expected_follower.following?(@account) + + if expected_follower.requested?(@account) + # For some reason the follow request went through but we missed it + expected_follower.follow_requests.find_by(target_account: @account)&.authorize! + else + # Since we were not aware of the follow from our side, we do not have an + # ID for it that we can include in the Undo activity. For this reason, + # the Undo may not work with software that relies exclusively on + # matching activity IDs and not the actor and target + follow = Follow.new(account: expected_follower, target_account: @account) + ActivityPub::DeliveryWorker.perform_async(build_undo_follow_json(follow), follow.account_id, follow.target_account.inbox_url) + end + end + end + + def build_undo_follow_json(follow) + Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer)) + end + + def collection_items(collection_or_uri) + collection = fetch_collection(collection_or_uri) + return unless collection.is_a?(Hash) + + collection = fetch_collection(collection['first']) if collection['first'].present? + return unless collection.is_a?(Hash) + + case collection['type'] + when 'Collection', 'CollectionPage' + collection['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + collection['orderedItems'] + end + end + + def fetch_collection(collection_or_uri) + return collection_or_uri if collection_or_uri.is_a?(Hash) + return if invalid_origin?(collection_or_uri) + + fetch_resource_without_id_validation(collection_or_uri, nil, true) + end +end diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb index c9739c77d..e00694157 100644 --- a/app/services/app_sign_up_service.rb +++ b/app/services/app_sign_up_service.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true class AppSignUpService < BaseService - def call(app, params) + def call(app, remote_ip, params) return unless allowed_registrations? user_params = params.slice(:email, :password, :agreement, :locale) account_params = params.slice(:username) invite_request_params = { text: params[:reason] } - user = User.create!(user_params.merge(created_by_application: app, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params)) + user = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params)) Doorkeeper::AccessToken.create!(application: app, resource_owner_id: user.id, diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 1a3f4981f..67df92f5c 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account, notifications: nil, timelines_only: nil) + def call(account, target_account, notifications: nil, timelines_only: nil, duration: 0) return if account.id == target_account.id - mute = account.mute!(target_account, notifications: notifications, timelines_only: timelines_only) + mute = account.mute!(target_account, notifications: notifications, timelines_only: timelines_only, duration: duration) if mute.hide_notifications? BlockWorker.perform_async(account.id, target_account.id) @@ -12,6 +12,8 @@ class MuteService < BaseService MuteWorker.perform_async(account.id, target_account.id) end + DeleteMuteWorker.perform_at(duration.seconds, mute.id) if duration != 0 + mute end end diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index ba77552c6..3f7bb7cc5 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -26,11 +26,10 @@ class ResolveAccountService < BaseService @account ||= Account.find_remote(@username, @domain) - return @account if @account&.local? || !webfinger_update_due? + return @account if @account&.local? || @domain.nil? || !webfinger_update_due? # At this point we are in need of a Webfinger query, which may # yield us a different username/domain through a redirect - process_webfinger!(@uri) # Because the username/domain pair may be different than what @@ -47,7 +46,7 @@ class ResolveAccountService < BaseService # either needs to be created, or updated from fresh data process_account! - rescue Goldfinger::Error, WebfingerRedirectError, Oj::ParseError => e + rescue Webfinger::Error, WebfingerRedirectError, Oj::ParseError => e Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}" nil end @@ -118,11 +117,11 @@ class ResolveAccountService < BaseService end def activitypub_ready? - !@webfinger.link('self').nil? && ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) + ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self', 'type')) end def actor_url - @actor_url ||= @webfinger.link('self').href + @actor_url ||= @webfinger.link('self', 'href') end def actor_json diff --git a/app/services/resolve_mentions_service.rb b/app/services/resolve_mentions_service.rb index e51e9f1ef..6478dc902 100644 --- a/app/services/resolve_mentions_service.rb +++ b/app/services/resolve_mentions_service.rb @@ -28,7 +28,7 @@ class ResolveMentionsService < BaseService if mention_undeliverable?(mentioned_account) begin mentioned_account = resolve_account_service.call(Regexp.last_match(1)) - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError + rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError mentioned_account = nil end end diff --git a/app/views/admin/ip_blocks/_ip_block.html.haml b/app/views/admin/ip_blocks/_ip_block.html.haml new file mode 100644 index 000000000..e07e2b444 --- /dev/null +++ b/app/views/admin/ip_blocks/_ip_block.html.haml @@ -0,0 +1,11 @@ +.batch-table__row + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id + .batch-table__row__content + .batch-table__row__content__text + %samp= "#{ip_block.ip}/#{ip_block.ip.prefix}" + - if ip_block.comment.present? + • + = ip_block.comment + %br/ + = t("simple_form.labels.ip_block.severities.#{ip_block.severity}") diff --git a/app/views/admin/ip_blocks/index.html.haml b/app/views/admin/ip_blocks/index.html.haml new file mode 100644 index 000000000..a282a4cfe --- /dev/null +++ b/app/views/admin/ip_blocks/index.html.haml @@ -0,0 +1,28 @@ +- content_for :page_title do + = t('admin.ip_blocks.title') + +- content_for :header_tags do + = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' + +- if can?(:create, :ip_block) + - content_for :heading_actions do + = link_to t('admin.ip_blocks.add_new'), new_admin_ip_block_path, class: 'button' + += form_for(@form, url: batch_admin_ip_blocks_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + .batch-table + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__toolbar__actions + - if can?(:destroy, :ip_block) + = f.button safe_join([fa_icon('times'), t('admin.ip_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + .batch-table__body + - if @ip_blocks.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'ip_block', collection: @ip_blocks, locals: { f: f } + += paginate @ip_blocks + diff --git a/app/views/admin/ip_blocks/new.html.haml b/app/views/admin/ip_blocks/new.html.haml new file mode 100644 index 000000000..69f6b98b9 --- /dev/null +++ b/app/views/admin/ip_blocks/new.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t('.title') + += simple_form_for @ip_block, url: admin_ip_blocks_path do |f| + = render 'shared/error_messages', object: @ip_block + + .fields-group + = f.input :ip, as: :string, wrapper: :with_block_label, input_html: { placeholder: '192.0.2.0/24' } + + .fields-group + = f.input :expires_in, wrapper: :with_block_label, collection: [1.day, 2.weeks, 1.month, 6.months, 1.year, 3.years].map(&:to_i), label_method: lambda { |i| I18n.t("admin.ip_blocks.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') + + .fields-group + = f.input :severity, as: :radio_buttons, collection: IpBlock.severities.keys, include_blank: false, wrapper: :with_block_label, label_method: lambda { |severity| safe_join([I18n.t("simple_form.labels.ip_block.severities.#{severity}"), content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint')]) } + + .fields-group + = f.input :comment, as: :string, wrapper: :with_block_label + + .actions + = f.button :button, t('admin.ip_blocks.add_new'), type: :submit diff --git a/app/views/admin/pending_accounts/_account.html.haml b/app/views/admin/pending_accounts/_account.html.haml index 7a9796a67..5b475b59a 100644 --- a/app/views/admin/pending_accounts/_account.html.haml +++ b/app/views/admin/pending_accounts/_account.html.haml @@ -7,7 +7,7 @@ %strong= account.user_email = "(@#{account.username})" %br/ - = account.user_current_sign_in_ip + %samp= account.user_current_sign_in_ip • = t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at) diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 34f742c16..e00fc60c6 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -6,6 +6,7 @@ - if cdn_host? %link{ rel: 'dns-prefetch', href: cdn_host }/ + %meta{ name: 'cdn-host', content: cdn_host }/ - if storage_host? %link{ rel: 'dns-prefetch', href: storage_host }/ diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index 75441b452..69b206f69 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -6,6 +6,7 @@ - if cdn_host? %link{ rel: 'dns-prefetch', href: cdn_host }/ + %meta{ name: 'cdn-host', content: cdn_host }/ - if storage_host? %link{ rel: 'dns-prefetch', href: storage_host }/ diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 48031a973..13f9aa668 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -27,6 +27,7 @@ .fields-group = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label, recommended: true = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label + = f.input :setting_disable_swiping, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label = f.input :setting_system_emoji_font, as: :boolean, wrapper: :with_label diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index 0f2763d27..445cf09ea 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -59,13 +59,12 @@ = t 'statuses.show_thread' .status__action-bar - .status__action-bar__counter - = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button modal-button' do - - if status.in_reply_to_id.nil? - = fa_icon 'reply fw' - - else - = fa_icon 'reply-all fw' - .status__action-bar__counter__label= obscured_counter status.replies_count + = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button icon-button--with-counter modal-button' do + - if status.in_reply_to_id.nil? + = fa_icon 'reply fw' + - else + = fa_icon 'reply-all fw' + %span.icon-button__counter= obscured_counter status.replies_count = link_to remote_interaction_path(status, type: :reblog), class: 'status__action-bar-button icon-button modal-button' do - if status.distributable? = fa_icon 'retweet fw' diff --git a/app/views/well_known/host_meta/show.xml.ruby b/app/views/well_known/host_meta/show.xml.ruby index 0a6bdc322..b4e867c5f 100644 --- a/app/views/well_known/host_meta/show.xml.ruby +++ b/app/views/well_known/host_meta/show.xml.ruby @@ -5,7 +5,6 @@ doc << Ox::Element.new('XRD').tap do |xrd| xrd << Ox::Element.new('Link').tap do |link| link['rel'] = 'lrdd' - link['type'] = 'application/xrd+xml' link['template'] = @webfinger_template end end diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb index 60775787a..6c5a576a7 100644 --- a/app/workers/activitypub/delivery_worker.rb +++ b/app/workers/activitypub/delivery_worker.rb @@ -2,6 +2,7 @@ class ActivityPub::DeliveryWorker include Sidekiq::Worker + include RoutingHelper include JsonLdHelper STOPLIGHT_FAILURE_THRESHOLD = 10 @@ -38,9 +39,18 @@ class ActivityPub::DeliveryWorker Request.new(:post, @inbox_url, body: @json, http_client: http_client).tap do |request| request.on_behalf_of(@source_account, :uri, sign_with: @options[:sign_with]) request.add_headers(HEADERS) + request.add_headers({ 'Collection-Synchronization' => synchronization_header }) if ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] != 'true' && @options[:synchronize_followers] end end + def synchronization_header + "collectionId=\"#{account_followers_url(@source_account)}\", digest=\"#{@source_account.remote_followers_hash(inbox_url_prefix)}\", url=\"#{account_followers_synchronization_url(@source_account)}\"" + end + + def inbox_url_prefix + @inbox_url[/http(s?):\/\/[^\/]+\//] + end + def perform_request light = Stoplight(@inbox_url) do request_pool.with(@host) do |http_client| diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index ff4bac018..1602c3e24 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -15,7 +15,7 @@ class ActivityPub::DistributionWorker return if skip_distribution? ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [payload(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url] + [payload(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url, { synchronize_followers: !@status.distributable? }] end relay! if relayable? diff --git a/app/workers/activitypub/followers_synchronization_worker.rb b/app/workers/activitypub/followers_synchronization_worker.rb new file mode 100644 index 000000000..35a3ef0b9 --- /dev/null +++ b/app/workers/activitypub/followers_synchronization_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ActivityPub::FollowersSynchronizationWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push', lock: :until_executed + + def perform(account_id, url) + @account = Account.find_by(id: account_id) + return true if @account.nil? + + ActivityPub::SynchronizeFollowersService.new.call(@account, url) + end +end diff --git a/app/workers/delete_mute_worker.rb b/app/workers/delete_mute_worker.rb new file mode 100644 index 000000000..eb031020e --- /dev/null +++ b/app/workers/delete_mute_worker.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class DeleteMuteWorker + include Sidekiq::Worker + + def perform(mute_id) + mute = Mute.find_by(id: mute_id) + UnmuteService.new.call(mute.account, mute.target_account) if mute&.expired? + end +end diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb index 6d38b52a2..853f20e25 100644 --- a/app/workers/scheduler/ip_cleanup_scheduler.rb +++ b/app/workers/scheduler/ip_cleanup_scheduler.rb @@ -3,13 +3,23 @@ class Scheduler::IpCleanupScheduler include Sidekiq::Worker - RETENTION_PERIOD = 1.year + IP_RETENTION_PERIOD = 1.year.freeze sidekiq_options lock: :until_executed, retry: 0 def perform - time_ago = RETENTION_PERIOD.ago - SessionActivation.where('updated_at < ?', time_ago).in_batches.destroy_all - User.where('last_sign_in_at < ?', time_ago).where.not(last_sign_in_ip: nil).in_batches.update_all(last_sign_in_ip: nil) + clean_ip_columns! + clean_expired_ip_blocks! + end + + private + + def clean_ip_columns! + SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all + User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_sign_in_ip: nil, current_sign_in_ip: nil, sign_up_ip: nil) + end + + def clean_expired_ip_blocks! + IpBlock.expired.in_batches.destroy_all end end |