diff options
61 files changed, 1255 insertions, 279 deletions
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 5cce5bce4..cbd7abe95 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -22,6 +22,14 @@ module Admin end end + def update + if @custom_emoji.update(resource_params) + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg') + else + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg') + end + end + def destroy @custom_emoji.destroy redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') @@ -36,7 +44,7 @@ module Admin flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg') end - redirect_to admin_custom_emojis_path(params[:page]) + redirect_to admin_custom_emojis_path(page: params[:page]) end def enable @@ -56,7 +64,7 @@ module Admin end def resource_params - params.require(:custom_emoji).permit(:shortcode, :image) + params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) end def filtered_custom_emojis diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb new file mode 100644 index 000000000..d455227eb --- /dev/null +++ b/app/controllers/api/v1/timelines/direct_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::DirectController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:show] + before_action :require_user!, only: [:show] + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + respond_to :json + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_direct_statuses + end + + def cached_direct_statuses + cache_collection direct_statuses, Status + end + + def direct_statuses + direct_timeline_statuses.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def direct_timeline_statuses + Status.as_direct_timeline(current_account) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:local, :limit).merge(core_params) + end + + def next_path + api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end +end diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb new file mode 100644 index 000000000..f79e1b320 --- /dev/null +++ b/app/controllers/settings/keyword_mutes_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class Settings::KeywordMutesController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :load_keyword_mute, only: [:edit, :update, :destroy] + + def index + @keyword_mutes = paginated_keyword_mutes_for_account + end + + def new + @keyword_mute = keyword_mutes_for_account.build + end + + def create + @keyword_mute = keyword_mutes_for_account.create(keyword_mute_params) + + if @keyword_mute.persisted? + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + else + render :new + end + end + + def update + if @keyword_mute.update(keyword_mute_params) + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + else + render :edit + end + end + + def destroy + @keyword_mute.destroy! + + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + end + + def destroy_all + keyword_mutes_for_account.delete_all + + redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg') + end + + private + + def keyword_mutes_for_account + Glitch::KeywordMute.where(account: current_account) + end + + def load_keyword_mute + @keyword_mute = keyword_mutes_for_account.find(params[:id]) + end + + def keyword_mute_params + params.require(:keyword_mute).permit(:keyword, :whole_word) + end + + def paginated_keyword_mutes_for_account + keyword_mutes_for_account.order(:keyword).page params[:page] + end +end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index c23a2e095..a3441e6f9 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -9,6 +9,10 @@ module JsonLdHelper value.is_a?(Array) ? value.first : value end + def as_array(value) + value.is_a?(Array) ? value : [value] + end + def value_or_id(value) value.is_a?(String) || value.nil? ? value : value['id'] end diff --git a/app/helpers/settings/keyword_mutes_helper.rb b/app/helpers/settings/keyword_mutes_helper.rb new file mode 100644 index 000000000..7b98cd59e --- /dev/null +++ b/app/helpers/settings/keyword_mutes_helper.rb @@ -0,0 +1,2 @@ +module Settings::KeywordMutesHelper +end diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 24e64e06c..3ee9e1e7b 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -8,6 +8,7 @@ import { refreshHomeTimeline, refreshCommunityTimeline, refreshPublicTimeline, + refreshDirectTimeline, } from './timelines'; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; @@ -133,6 +134,8 @@ export function submitCompose() { if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { insertOrRefresh('community', refreshCommunityTimeline); insertOrRefresh('public', refreshPublicTimeline); + } else if (response.data.visibility === 'direct') { + insertOrRefresh('direct', refreshDirectTimeline); } }).catch(function (error) { dispatch(submitComposeFail(error)); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 7802694a3..a2e25c930 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -92,3 +92,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', ' export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); export const connectPublicStream = () => connectTimelineStream('public', 'public'); export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); +export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 09abe2702..935bbb6f0 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -115,6 +115,7 @@ export function refreshTimeline(timelineId, path, params = {}) { export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); +export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct'); export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); @@ -155,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) { export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); +export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct'); export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index c47296a51..71530ffdd 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -175,7 +175,9 @@ export default class ColumnHeader extends React.PureComponent { <div className={wrapperClassName}> <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> <i className={`fa fa-fw fa-${icon} column-header__icon`} /> - {title} + <span className='column-header__title'> + {title} + </span> <div className='column-header__buttons'> {backButton} { notifCleaning ? ( diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js index 71944128c..699687c69 100644 --- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js @@ -46,7 +46,7 @@ const getFrequentlyUsedEmojis = createSelector([ const getCustomEmojis = createSelector([ state => state.get('custom_emojis'), -], emojis => emojis.sort((a, b) => { +], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { const aShort = a.get('shortcode').toLowerCase(); const bShort = b.get('shortcode').toLowerCase(); diff --git a/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..1833f69e5 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../../community_timeline/components/column_settings'; +import { changeSetting } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'direct']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['direct', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js new file mode 100644 index 000000000..05e092ee0 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import { + refreshDirectTimeline, + expandDirectTimeline, +} from '../../actions/timelines'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectDirectStream } from '../../actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.direct', defaultMessage: 'Direct messages' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class DirectTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECT', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshDirectTimeline()); + this.disconnect = dispatch(connectDirectStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandDirectTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + <Column ref={this.setRef}> + <ColumnHeader + icon='envelope' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`direct_timeline-${columnId}`} + timelineId='direct' + loadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />} + /> + </Column> + ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 68267c54f..9b94b9830 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -17,6 +17,7 @@ const messages = defineMessages({ navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' }, settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, @@ -78,18 +79,22 @@ export default class GettingStarted extends ImmutablePureComponent { } } + if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) { + navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />); + } + navItems = navItems.concat([ - <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, - <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, + <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, + <ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, ]); if (me.get('locked')) { - navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); + navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); } navItems = navItems.concat([ - <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, - <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, + <ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, + <ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, ]); return ( diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 5610095b9..ee1064229 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -11,7 +11,7 @@ import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; import BundleColumnError from './bundle_column_error'; -import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from '../../ui/util/async-components'; import detectPassiveEvents from 'detect-passive-events'; import { scrollRight } from '../../../scroll'; @@ -23,6 +23,7 @@ const componentMap = { 'PUBLIC': PublicTimeline, 'COMMUNITY': CommunityTimeline, 'HASHTAG': HashtagTimeline, + 'DIRECT': DirectTimeline, 'FAVOURITES': FavouritedStatuses, }; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 883bfe055..9f77ab5aa 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -29,6 +29,7 @@ import { Following, Reblogs, Favourites, + DirectTimeline, HashtagTimeline, Notifications, FollowRequests, @@ -71,6 +72,7 @@ const keyMap = { goToNotifications: 'g n', goToLocal: 'g l', goToFederated: 'g t', + goToDirect: 'g d', goToStart: 'g s', goToFavourites: 'g f', goToPinned: 'g p', @@ -302,6 +304,10 @@ export default class UI extends React.Component { this.context.router.history.push('/timelines/public'); } + handleHotkeyGoToDirect = () => { + this.context.router.history.push('/timelines/direct'); + } + handleHotkeyGoToStart = () => { this.context.router.history.push('/getting-started'); } @@ -357,6 +363,7 @@ export default class UI extends React.Component { goToNotifications: this.handleHotkeyGoToNotifications, goToLocal: this.handleHotkeyGoToLocal, goToFederated: this.handleHotkeyGoToFederated, + goToDirect: this.handleHotkeyGoToDirect, goToStart: this.handleHotkeyGoToStart, goToFavourites: this.handleHotkeyGoToFavourites, goToPinned: this.handleHotkeyGoToPinned, @@ -377,6 +384,7 @@ export default class UI extends React.Component { <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} /> + <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} /> <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/notifications' component={Notifications} content={children} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 7f2b303a7..dc8e9dfb9 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -26,6 +26,10 @@ export function HashtagTimeline () { return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); } +export function DirectTimeline() { + return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline'); +} + export function Status () { return import(/* webpackChunkName: "features/status" */'../../status'); } diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index f400b283f..ebb514e69 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -758,6 +758,19 @@ { "descriptors": [ { + "defaultMessage": "Direct messages", + "id": "column.direct" + }, + { + "defaultMessage": "You don't have any direct messages yet. When you send or receive one, it will show up here.", + "id": "empty_column.direct" + } + ], + "path": "app/javascript/mastodon/features/direct_timeline/index.json" + }, + { + "descriptors": [ + { "defaultMessage": "Favourites", "id": "column.favourites" } @@ -817,6 +830,10 @@ "id": "navigation_bar.community_timeline" }, { + "defaultMessage": "Direct messages", + "id": "navigation_bar.direct" + }, + { "defaultMessage": "Preferences", "id": "navigation_bar.preferences" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 1d0bbcee5..efe0e1de9 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -28,6 +28,7 @@ "bundle_modal_error.retry": "Try again", "column.blocks": "Blocked users", "column.community": "Local timeline", + "column.direct": "Direct messages", "column.favourites": "Favourites", "column.follow_requests": "Follow requests", "column.home": "Home", @@ -80,6 +81,7 @@ "emoji_button.symbols": "Symbols", "emoji_button.travel": "Travel & Places", "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.", "empty_column.home.public_timeline": "the public timeline", @@ -106,6 +108,7 @@ "missing_indicator.label": "Not found", "navigation_bar.blocks": "Blocked users", "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.direct": "Direct messages", "navigation_bar.edit_profile": "Edit profile", "navigation_bar.favourites": "Favourites", "navigation_bar.follow_requests": "Follow requests", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 22639f6f9..3f67a8fff 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -1,221 +1,221 @@ { "account.block": "Bloki @{name}", - "account.block_domain": "Hide everything from {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.block_domain": "Kaŝi ĉion el {domain}", + "account.disclaimer_full": "La ĉi-subaj informoj povas ne plene reflekti la profilon de la uzanto.", "account.edit_profile": "Redakti la profilon", "account.follow": "Sekvi", "account.followers": "Sekvantoj", "account.follows": "Sekvatoj", "account.follows_you": "Sekvas vin", - "account.media": "Media", + "account.media": "Sonbildaĵoj", "account.mention": "Mencii @{name}", - "account.mute": "Mute @{name}", + "account.mute": "Silentigi @{name}", "account.posts": "Mesaĝoj", - "account.report": "Report @{name}", + "account.report": "Signali @{name}", "account.requested": "Atendas aprobon", - "account.share": "Share @{name}'s profile", + "account.share": "Diskonigi la profilon de @{name}", "account.unblock": "Malbloki @{name}", - "account.unblock_domain": "Unhide {domain}", - "account.unfollow": "Malsekvi", - "account.unmute": "Unmute @{name}", - "account.view_full_profile": "View full profile", - "boost_modal.combo": "You can press {combo} to skip this next time", - "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", - "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", - "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", - "column.blocks": "Blocked users", + "account.unblock_domain": "Malkaŝi {domain}", + "account.unfollow": "Ne plus sekvi", + "account.unmute": "Malsilentigi @{name}", + "account.view_full_profile": "Vidi plenan profilon", + "boost_modal.combo": "La proksiman fojon, premu {combo} por pasigi", + "bundle_column_error.body": "Io malfunkciis ŝargante tiun ĉi komponanton.", + "bundle_column_error.retry": "Bonvolu reprovi", + "bundle_column_error.title": "Reta eraro", + "bundle_modal_error.close": "Fermi", + "bundle_modal_error.message": "Io malfunkciis ŝargante tiun ĉi komponanton.", + "bundle_modal_error.retry": "Bonvolu reprovi", + "column.blocks": "Blokitaj uzantoj", "column.community": "Loka tempolinio", - "column.favourites": "Favourites", - "column.follow_requests": "Follow requests", + "column.favourites": "Favoritoj", + "column.follow_requests": "Abonpetoj", "column.home": "Hejmo", - "column.mutes": "Muted users", + "column.mutes": "Silentigitaj uzantoj", "column.notifications": "Sciigoj", - "column.pins": "Pinned toot", + "column.pins": "Alpinglitaj pepoj", "column.public": "Fratara tempolinio", "column_back_button.label": "Reveni", - "column_header.hide_settings": "Hide settings", - "column_header.moveLeft_settings": "Move column to the left", - "column_header.moveRight_settings": "Move column to the right", - "column_header.pin": "Pin", - "column_header.show_settings": "Show settings", - "column_header.unpin": "Unpin", - "column_subheading.navigation": "Navigation", - "column_subheading.settings": "Settings", - "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", - "compose_form.lock_disclaimer.lock": "locked", + "column_header.hide_settings": "Kaŝi agordojn", + "column_header.moveLeft_settings": "Movi kolumnon maldekstren", + "column_header.moveRight_settings": "Movi kolumnon dekstren", + "column_header.pin": "Alpingli", + "column_header.show_settings": "Malkaŝi agordojn", + "column_header.unpin": "Depingli", + "column_subheading.navigation": "Navigado", + "column_subheading.settings": "Agordoj", + "compose_form.lock_disclaimer": "Via konta ne estas ŝlosita. Iu ajn povas sekvi vin por vidi viajn privatajn pepojn.", + "compose_form.lock_disclaimer.lock": "ŝlosita", "compose_form.placeholder": "Pri kio vi pensas?", "compose_form.publish": "Hup", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive": "Marki ke la enhavo estas tikla", "compose_form.spoiler": "Kaŝi la tekston malantaŭ averto", - "compose_form.spoiler_placeholder": "Content warning", - "confirmation_modal.cancel": "Cancel", - "confirmations.block.confirm": "Block", - "confirmations.block.message": "Are you sure you want to block {name}?", - "confirmations.delete.confirm": "Delete", - "confirmations.delete.message": "Are you sure you want to delete this status?", - "confirmations.domain_block.confirm": "Hide entire domain", - "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", - "confirmations.mute.confirm": "Mute", - "confirmations.mute.message": "Are you sure you want to mute {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", - "embed.instructions": "Embed this status on your website by copying the code below.", - "embed.preview": "Here is what it will look like:", - "emoji_button.activity": "Activity", - "emoji_button.custom": "Custom", - "emoji_button.flags": "Flags", - "emoji_button.food": "Food & Drink", - "emoji_button.label": "Insert emoji", - "emoji_button.nature": "Nature", - "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", - "emoji_button.objects": "Objects", - "emoji_button.people": "People", - "emoji_button.recent": "Frequently used", - "emoji_button.search": "Search...", - "emoji_button.search_results": "Search results", - "emoji_button.symbols": "Symbols", - "emoji_button.travel": "Travel & Places", - "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", - "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.", - "empty_column.home.public_timeline": "the public timeline", - "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 instances to fill it up", - "follow_request.authorize": "Authorize", - "follow_request.reject": "Reject", - "getting_started.appsshort": "Apps", - "getting_started.faq": "FAQ", + "compose_form.spoiler_placeholder": "Skribu tie vian averton", + "confirmation_modal.cancel": "Malfari", + "confirmations.block.confirm": "Bloki", + "confirmations.block.message": "Ĉu vi konfirmas la blokadon de {name}?", + "confirmations.delete.confirm": "Malaperigi", + "confirmations.delete.message": "Ĉu vi konfirmas la malaperigon de tiun pepon?", + "confirmations.domain_block.confirm": "Kaŝi la tutan reton", + "confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas bloki {domain} tute? Plej ofte, kelkaj celitaj blokadoj aŭ silentigoj estas sufiĉaj kaj preferindaj.", + "confirmations.mute.confirm": "Silentigi", + "confirmations.mute.message": "Ĉu vi konfirmas la silentigon de {name}?", + "confirmations.unfollow.confirm": "Ne plu sekvi", + "confirmations.unfollow.message": "Ĉu vi volas ĉesi sekvi {name}?", + "embed.instructions": "Enmetu tiun statkonigon ĉe vian retejon kopiante la ĉi-suban kodon.", + "embed.preview": "Ĝi aperos tiel:", + "emoji_button.activity": "Aktivecoj", + "emoji_button.custom": "Personaj", + "emoji_button.flags": "Flagoj", + "emoji_button.food": "Manĝi kaj trinki", + "emoji_button.label": "Enmeti mieneton", + "emoji_button.nature": "Naturo", + "emoji_button.not_found": "Neniuj mienetoj!! (╯°□°)╯︵ ┻━┻", + "emoji_button.objects": "Objektoj", + "emoji_button.people": "Homoj", + "emoji_button.recent": "Ofte uzataj", + "emoji_button.search": "Serĉo…", + "emoji_button.search_results": "Rezultatoj de serĉo", + "emoji_button.symbols": "Simboloj", + "emoji_button.travel": "Vojaĝoj & lokoj", + "empty_column.community": "La loka tempolinio estas malplena. Skribu ion por plenigi ĝin!", + "empty_column.hashtag": "Ĝise, neniu enhavo estas asociita kun tiu kradvorto.", + "empty_column.home": "Via hejma tempolinio estas malplena! Vizitu {public} aŭ uzu la serĉilon por renkonti aliajn uzantojn.", + "empty_column.home.public_timeline": "la publika tempolinio", + "empty_column.notifications": "Vi dume ne havas sciigojn. Interagi kun aliajn uzantojn por komenci la konversacion.", + "empty_column.public": "Estas nenio ĉi tie! Publike skribu ion, aŭ mane sekvu uzantojn de aliaj instancoj por plenigi la publikan tempolinion.", + "follow_request.authorize": "Akcepti", + "follow_request.reject": "Rifuzi", + "getting_started.appsshort": "Aplikaĵoj", + "getting_started.faq": "Oftaj demandoj", "getting_started.heading": "Por komenci", - "getting_started.open_source_notice": "Mastodon estas malfermitkoda programo. Vi povas kontribui aŭ raporti problemojn en github je {github}.", - "getting_started.userguide": "User Guide", - "home.column_settings.advanced": "Advanced", - "home.column_settings.basic": "Basic", - "home.column_settings.filter_regex": "Filter out by regular expressions", - "home.column_settings.show_reblogs": "Show boosts", - "home.column_settings.show_replies": "Show replies", - "home.settings": "Column settings", + "getting_started.open_source_notice": "Mastodono estas malfermkoda programo. Vi povas kontribui aŭ raporti problemojn en GitHub je {github}.", + "getting_started.userguide": "Gvidilo de uzo", + "home.column_settings.advanced": "Precizaj agordoj", + "home.column_settings.basic": "Bazaj agordoj", + "home.column_settings.filter_regex": "Forfiltri per regulesprimo", + "home.column_settings.show_reblogs": "Montri diskonigojn", + "home.column_settings.show_replies": "Montri respondojn", + "home.settings": "Agordoj de la kolumno", "lightbox.close": "Fermi", - "lightbox.next": "Next", - "lightbox.previous": "Previous", - "loading_indicator.label": "Ŝarĝanta...", - "media_gallery.toggle_visible": "Toggle visibility", - "missing_indicator.label": "Not found", - "navigation_bar.blocks": "Blocked users", + "lightbox.next": "Malantaŭa", + "lightbox.previous": "Antaŭa", + "loading_indicator.label": "Ŝarganta…", + "media_gallery.toggle_visible": "Baskuli videblecon", + "missing_indicator.label": "Ne trovita", + "navigation_bar.blocks": "Blokitaj uzantoj", "navigation_bar.community_timeline": "Loka tempolinio", "navigation_bar.edit_profile": "Redakti la profilon", - "navigation_bar.favourites": "Favourites", - "navigation_bar.follow_requests": "Follow requests", - "navigation_bar.info": "Extended information", + "navigation_bar.favourites": "Favoritaj", + "navigation_bar.follow_requests": "Abonpetoj", + "navigation_bar.info": "Plia informo", "navigation_bar.logout": "Elsaluti", - "navigation_bar.mutes": "Muted users", - "navigation_bar.pins": "Pinned toots", + "navigation_bar.mutes": "Silentigitaj uzantoj", + "navigation_bar.pins": "Alpinglitaj pepoj", "navigation_bar.preferences": "Preferoj", "navigation_bar.public_timeline": "Fratara tempolinio", "notification.favourite": "{name} favoris vian mesaĝon", "notification.follow": "{name} sekvis vin", "notification.mention": "{name} menciis vin", "notification.reblog": "{name} diskonigis vian mesaĝon", - "notifications.clear": "Clear notifications", - "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.clear": "Forviŝi la sciigojn", + "notifications.clear_confirmation": "Ĉu vi certe volas malaperigi ĉiujn viajn sciigojn?", "notifications.column_settings.alert": "Retumilaj atentigoj", - "notifications.column_settings.favourite": "Favoroj:", + "notifications.column_settings.favourite": "Favoritoj:", "notifications.column_settings.follow": "Novaj sekvantoj:", "notifications.column_settings.mention": "Mencioj:", - "notifications.column_settings.push": "Push notifications", - "notifications.column_settings.push_meta": "This device", + "notifications.column_settings.push": "Puŝsciigoj", + "notifications.column_settings.push_meta": "Tiu ĉi aparato", "notifications.column_settings.reblog": "Diskonigoj:", "notifications.column_settings.show": "Montri en kolono", - "notifications.column_settings.sound": "Play sound", - "onboarding.done": "Done", - "onboarding.next": "Next", - "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", - "onboarding.page_four.home": "The home timeline shows posts from people you follow.", - "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", - "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", - "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", - "onboarding.page_one.welcome": "Welcome to Mastodon!", - "onboarding.page_six.admin": "Your instance's admin is {admin}.", - "onboarding.page_six.almost_done": "Almost done...", - "onboarding.page_six.appetoot": "Bon Appetoot!", - "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", - "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", - "onboarding.page_six.guidelines": "community guidelines", - "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", - "onboarding.page_six.various_app": "mobile apps", - "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", - "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", - "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", - "onboarding.skip": "Skip", - "privacy.change": "Adjust status privacy", - "privacy.direct.long": "Post to mentioned users only", - "privacy.direct.short": "Direct", - "privacy.private.long": "Post to followers only", - "privacy.private.short": "Followers-only", - "privacy.public.long": "Post to public timelines", - "privacy.public.short": "Public", - "privacy.unlisted.long": "Do not show in public timelines", - "privacy.unlisted.short": "Unlisted", - "relative_time.days": "{number}d", + "notifications.column_settings.sound": "Eligi sonon", + "onboarding.done": "Farita", + "onboarding.next": "Malantaŭa", + "onboarding.page_five.public_timelines": "La loka tempolinio enhavas mesaĝojn de ĉiuj ĉe {domain}. La federacia tempolinio enhavas ĉiujn mesaĝojn de uzantoj, kiujn iu ĉe {domain} sekvas. Ambaŭ tre utilas por trovi novajn kunparolantojn.", + "onboarding.page_four.home": "La hejma tempolinio enhavas la mesaĝojn de ĉiuj uzantoj, kiuj vi sekvas.", + "onboarding.page_four.notifications": "La sciiga kolumno informas vin kiam iu interagas kun vi.", + "onboarding.page_one.federation": "Mastodono estas reto de nedependaj serviloj, unuiĝintaj por krei pligrandan socian retejon. Ni nomas tiujn servilojn instancoj.", + "onboarding.page_one.handle": "Vi estas ĉe {domain}, unu el la multaj instancoj de Mastodono. Via kompleta uznomo do estas {handle}", + "onboarding.page_one.welcome": "Bonvenon al Mastodono!", + "onboarding.page_six.admin": "Via instancestro estas {admin}.", + "onboarding.page_six.almost_done": "Estas preskaŭ finita…", + "onboarding.page_six.appetoot": "Bonan a‘pepi’ton!", + "onboarding.page_six.apps_available": "{apps} estas elŝuteblaj por iOS, Androido kaj alioj. Kaj nun… bonan a‘pepi’ton!", + "onboarding.page_six.github": "Mastodono estas libera, senpaga kaj malfermkoda programaro. Vi povas signali cimojn, proponi funkciojn aŭ kontribui al gîa kreskado ĉe {github}.", + "onboarding.page_six.guidelines": "komunreguloj", + "onboarding.page_six.read_guidelines": "Ni petas vin: ne forgesu legi la {guidelines}n de {domain}!", + "onboarding.page_six.various_app": "telefon-aplikaĵoj", + "onboarding.page_three.profile": "Redaktu vian profilon por ŝanĝi vian avataron, priskribon kaj vian nomon. Vi tie trovos ankoraŭ aliajn agordojn.", + "onboarding.page_three.search": "Uzu la serĉokampo por trovi uzantojn kaj esplori kradvortojn tiel ke {illustration} kaj {introductions}. Por trovi iun, kiu ne estas ĉe ĉi tiu instanco, uzu ĝian kompletan uznomon.", + "onboarding.page_two.compose": "Skribu pepojn en la verkkolumno. Vi povas aldoni bildojn, ŝanĝi la agordojn de privateco kaj aldoni tiklavertojn (« content warning ») dank' al la piktogramoj malsupre.", + "onboarding.skip": "Pasigi", + "privacy.change": "Alĝustigi la privateco de la mesaĝo", + "privacy.direct.long": "Vidigi nur al la menciitaj personoj", + "privacy.direct.short": "Rekta", + "privacy.private.long": "Vidigi nur al viaj sekvantoj", + "privacy.private.short": "Nursekvanta", + "privacy.public.long": "Vidigi en publikaj tempolinioj", + "privacy.public.short": "Publika", + "privacy.unlisted.long": "Ne vidigi en publikaj tempolinioj", + "privacy.unlisted.short": "Nelistigita", + "relative_time.days": "{number}t", "relative_time.hours": "{number}h", - "relative_time.just_now": "now", + "relative_time.just_now": "nun", "relative_time.minutes": "{number}m", "relative_time.seconds": "{number}s", - "reply_indicator.cancel": "Rezigni", - "report.placeholder": "Additional comments", - "report.submit": "Submit", - "report.target": "Reporting", + "reply_indicator.cancel": "Malfari", + "report.placeholder": "Pliaj komentoj", + "report.submit": "Sendi", + "report.target": "Signalaĵo", "search.placeholder": "Serĉi", - "search_popout.search_format": "Advanced search format", - "search_popout.tips.hashtag": "hashtag", - "search_popout.tips.status": "status", - "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", - "search_popout.tips.user": "user", - "search_results.total": "{count, number} {count, plural, one {result} other {results}}", - "standalone.public_title": "A look inside...", - "status.cannot_reblog": "This post cannot be boosted", + "search_popout.search_format": "Detala serĉo", + "search_popout.tips.hashtag": "kradvorto", + "search_popout.tips.status": "statkonigo", + "search_popout.tips.text": "Simpla teksto eligas la kongruajn afiŝnomojn, uznomojn kaj kradvortojn.", + "search_popout.tips.user": "uzanto", + "search_results.total": "{count, number} {count, plural, one {rezultato} other {rezultatoj}}", + "standalone.public_title": "Rigardeti…", + "status.cannot_reblog": "Tiun publikaĵon oni ne povas diskonigi", "status.delete": "Forigi", - "status.embed": "Embed", + "status.embed": "Enmeti", "status.favourite": "Favori", - "status.load_more": "Load more", - "status.media_hidden": "Media hidden", + "status.load_more": "Ŝargi plie", + "status.media_hidden": "Sonbildaĵo kaŝita", "status.mention": "Mencii @{name}", - "status.more": "More", - "status.mute_conversation": "Mute conversation", - "status.open": "Expand this status", - "status.pin": "Pin on profile", + "status.more": "Pli", + "status.mute_conversation": "Silentigi konversacion", + "status.open": "Disfaldi statkonigon", + "status.pin": "Pingli al la profilo", "status.reblog": "Diskonigi", - "status.reblogged_by": "{name} diskonigita", + "status.reblogged_by": "{name} diskonigis", "status.reply": "Respondi", - "status.replyAll": "Reply to thread", - "status.report": "Report @{name}", + "status.replyAll": "Respondi al la fadeno", + "status.report": "Signali @{name}", "status.sensitive_toggle": "Alklaki por vidi", "status.sensitive_warning": "Tikla enhavo", - "status.share": "Share", - "status.show_less": "Show less", - "status.show_more": "Show more", - "status.unmute_conversation": "Unmute conversation", - "status.unpin": "Unpin from profile", + "status.share": "Diskonigi", + "status.show_less": "Refaldi", + "status.show_more": "Disfaldi", + "status.unmute_conversation": "Malsilentigi konversacion", + "status.unpin": "Depingli de profilo", "tabs_bar.compose": "Ekskribi", - "tabs_bar.federated_timeline": "Federated", + "tabs_bar.federated_timeline": "Federacia tempolinio", "tabs_bar.home": "Hejmo", - "tabs_bar.local_timeline": "Local", + "tabs_bar.local_timeline": "Loka tempolinio", "tabs_bar.notifications": "Sciigoj", - "upload_area.title": "Drag & drop to upload", - "upload_button.label": "Aldoni enhavaĵon", - "upload_form.description": "Describe for the visually impaired", + "upload_area.title": "Algliti por alŝuti", + "upload_button.label": "Aldoni sonbildaĵon", + "upload_form.description": "Priskribi por la misvidantaj", "upload_form.undo": "Malfari", - "upload_progress.label": "Uploading...", - "video.close": "Close video", - "video.exit_fullscreen": "Exit full screen", - "video.expand": "Expand video", - "video.fullscreen": "Full screen", - "video.hide": "Hide video", - "video.mute": "Mute sound", - "video.pause": "Pause", - "video.play": "Play", - "video.unmute": "Unmute sound" + "upload_progress.label": "Alŝutanta…", + "video.close": "Fermi videon", + "video.exit_fullscreen": "Eliri el plenekrano", + "video.expand": "Vastigi videon", + "video.fullscreen": "Igi plenekrane", + "video.hide": "Kaŝi videon", + "video.mute": "Silentigi", + "video.pause": "Paŭzi", + "video.play": "Legi", + "video.unmute": "Malsilentigi" } diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index 104b063f5..65bc5b374 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -63,20 +63,20 @@ "confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?", "confirmations.unfollow.confirm": "Отписаться", "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?", - "embed.instructions": "Embed this status on your website by copying the code below.", - "embed.preview": "Here is what it will look like:", + "embed.instructions": "Встройте этот статус на Вашем сайте, скопировав код внизу.", + "embed.preview": "Так это будет выглядеть:", "emoji_button.activity": "Занятия", - "emoji_button.custom": "Custom", + "emoji_button.custom": "Собственные", "emoji_button.flags": "Флаги", "emoji_button.food": "Еда и напитки", "emoji_button.label": "Вставить эмодзи", "emoji_button.nature": "Природа", - "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", + "emoji_button.not_found": "Нет эмодзи!! (╯°□°)╯︵ ┻━┻", "emoji_button.objects": "Предметы", "emoji_button.people": "Люди", - "emoji_button.recent": "Frequently used", + "emoji_button.recent": "Последние", "emoji_button.search": "Найти...", - "emoji_button.search_results": "Search results", + "emoji_button.search_results": "Результаты поиска", "emoji_button.symbols": "Символы", "emoji_button.travel": "Путешествия", "empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!", @@ -159,34 +159,34 @@ "privacy.public.short": "Публичный", "privacy.unlisted.long": "Не показывать в лентах", "privacy.unlisted.short": "Скрытый", - "relative_time.days": "{number}d", - "relative_time.hours": "{number}h", - "relative_time.just_now": "now", - "relative_time.minutes": "{number}m", - "relative_time.seconds": "{number}s", + "relative_time.days": "{number}д", + "relative_time.hours": "{number}ч", + "relative_time.just_now": "только что", + "relative_time.minutes": "{number}м", + "relative_time.seconds": "{number}с", "reply_indicator.cancel": "Отмена", "report.placeholder": "Комментарий", "report.submit": "Отправить", "report.target": "Жалуемся на", "search.placeholder": "Поиск", - "search_popout.search_format": "Advanced search format", - "search_popout.tips.hashtag": "hashtag", - "search_popout.tips.status": "status", - "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", - "search_popout.tips.user": "user", + "search_popout.search_format": "Продвинутый формат поиска", + "search_popout.tips.hashtag": "хэштег", + "search_popout.tips.status": "статус", + "search_popout.tips.text": "Простой ввод текста покажет совпадающие имена пользователей, отображаемые имена и хэштеги", + "search_popout.tips.user": "пользователь", "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}", - "standalone.public_title": "A look inside...", + "standalone.public_title": "Прямо сейчас", "status.cannot_reblog": "Этот статус не может быть продвинут", "status.delete": "Удалить", - "status.embed": "Embed", + "status.embed": "Встроить", "status.favourite": "Нравится", "status.load_more": "Показать еще", "status.media_hidden": "Медиаконтент скрыт", "status.mention": "Упомянуть @{name}", - "status.more": "More", + "status.more": "Больше", "status.mute_conversation": "Заглушить тред", "status.open": "Развернуть статус", - "status.pin": "Pin on profile", + "status.pin": "Закрепить в профиле", "status.reblog": "Продвинуть", "status.reblogged_by": "{name} продвинул(а)", "status.reply": "Ответить", @@ -194,11 +194,11 @@ "status.report": "Пожаловаться", "status.sensitive_toggle": "Нажмите для просмотра", "status.sensitive_warning": "Чувствительный контент", - "status.share": "Share", + "status.share": "Поделиться", "status.show_less": "Свернуть", "status.show_more": "Развернуть", "status.unmute_conversation": "Снять глушение с треда", - "status.unpin": "Unpin from profile", + "status.unpin": "Открепить от профиля", "tabs_bar.compose": "Написать", "tabs_bar.federated_timeline": "Глобальная", "tabs_bar.home": "Главная", @@ -206,16 +206,16 @@ "tabs_bar.notifications": "Уведомления", "upload_area.title": "Перетащите сюда, чтобы загрузить", "upload_button.label": "Добавить медиаконтент", - "upload_form.description": "Describe for the visually impaired", + "upload_form.description": "Описать для людей с нарушениями зрения", "upload_form.undo": "Отменить", "upload_progress.label": "Загрузка...", - "video.close": "Close video", - "video.exit_fullscreen": "Exit full screen", - "video.expand": "Expand video", - "video.fullscreen": "Full screen", - "video.hide": "Hide video", - "video.mute": "Mute sound", - "video.pause": "Pause", - "video.play": "Play", - "video.unmute": "Unmute sound" + "video.close": "Закрыть видео", + "video.exit_fullscreen": "Покинуть полноэкранный режим", + "video.expand": "Развернуть видео", + "video.fullscreen": "Полноэкранный режим", + "video.hide": "Скрыть видео", + "video.mute": "Заглушить звук", + "video.pause": "Пауза", + "video.play": "Пуск", + "video.unmute": "Включить звук" } diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 0c0dae388..4b8a652d1 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -58,6 +58,12 @@ const initialState = ImmutableMap({ body: '', }), }), + + direct: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), }); const defaultColumns = fromJS([ diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 076aa9576..2506bbe62 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2503,6 +2503,7 @@ button.icon-button.active i.fa-retweet { } .column-header { + display: flex; padding: 15px; font-size: 16px; background: lighten($ui-base-color, 4%); @@ -2528,12 +2529,10 @@ button.icon-button.active i.fa-retweet { } .column-header__buttons { - position: absolute; - right: 0; - top: 0; - height: 100%; - display: flex; height: 48px; + display: flex; + margin: -15px; + margin-left: 0; } .column-header__button { @@ -2692,6 +2691,14 @@ button.icon-button.active i.fa-retweet { } } +.column-header__title { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; +} + .text-btn { display: inline-block; padding: 0; @@ -3465,7 +3472,6 @@ button.icon-button.active i.fa-retweet { right: 0; bottom: 0; background: rgba($base-overlay-background, 0.7); - transform: translateZ(0); } .modal-root__container { diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index d6e9bc1de..376684c00 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -53,9 +53,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_tags(status) - return unless @object['tag'].is_a?(Array) + return if @object['tag'].nil? - @object['tag'].each do |tag| + as_array(@object['tag']).each do |tag| case tag['type'] when 'Hashtag' process_hashtag tag, status @@ -103,9 +103,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_attachments(status) - return unless @object['attachment'].is_a?(Array) + return if @object['attachment'].nil? - @object['attachment'].each do |attachment| + as_array(@object['attachment']).each do |attachment| next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank? href = Addressable::URI.parse(attachment['url']).normalize.to_s diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index ca15745cb..2ddfac336 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -141,6 +141,8 @@ class FeedManager return false if receiver_id == status.account_id return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) + return true if keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id)) + check_for_mutes = [status.account_id] check_for_mutes.concat(status.mentions.pluck(:account_id)) check_for_mutes.concat([status.reblog.account_id]) if status.reblog? @@ -166,6 +168,18 @@ class FeedManager false end + def keyword_filter?(status, matcher) + should_filter = matcher =~ status.text + should_filter ||= matcher =~ status.spoiler_text + + if status.reblog? + should_filter ||= matcher =~ status.reblog.text + should_filter ||= matcher =~ status.reblog.spoiler_text + end + + !!should_filter + end + def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id @@ -175,6 +189,7 @@ class FeedManager should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them + should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id)) # or if the mention contains a muted keyword should_filter end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 65d9840d5..28b6a2b0b 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -15,6 +15,7 @@ # disabled :boolean default(FALSE), not null # uri :string # image_remote_url :string +# visible_in_picker :boolean default(TRUE), not null # class CustomEmoji < ApplicationRecord diff --git a/app/models/glitch.rb b/app/models/glitch.rb new file mode 100644 index 000000000..0e497babc --- /dev/null +++ b/app/models/glitch.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Glitch + def self.table_name_prefix + 'glitch_' + end +end diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb new file mode 100644 index 000000000..73de4d4b7 --- /dev/null +++ b/app/models/glitch/keyword_mute.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: glitch_keyword_mutes +# +# id :integer not null, primary key +# account_id :integer not null +# keyword :string not null +# whole_word :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Glitch::KeywordMute < ApplicationRecord + belongs_to :account, required: true + + validates_presence_of :keyword + + after_commit :invalidate_cached_matcher + + def self.matcher_for(account_id) + Matcher.new(account_id) + end + + private + + def invalidate_cached_matcher + Rails.cache.delete("keyword_mutes:regex:#{account_id}") + end + + class Matcher + attr_reader :account_id + attr_reader :regex + + def initialize(account_id) + @account_id = account_id + regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account } + @regex = /#{regex_text}/i + end + + def =~(str) + regex =~ str + end + + private + + def keywords + Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word) + end + + def regex_text_for_account + kws = keywords.find_each.with_object([]) do |kw, a| + a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword) + end + + Regexp.union(kws).source + end + + def boundary_regex_for_keyword(keyword) + sb = keyword =~ /\A[[:word:]]/ ? '\b' : '' + eb = keyword =~ /[[:word:]]\Z/ ? '\b' : '' + + /#{sb}#{Regexp.escape(keyword)}#{eb}/ + end + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 30d53f298..d78a921b5 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -154,6 +154,14 @@ class Status < ApplicationRecord where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private]) end + def as_direct_timeline(account) + query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}") + .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}") + .where(visibility: [:direct]) + + apply_timeline_filters(query, account, false) + end + def as_public_timeline(account = nil, local_only = false) query = timeline_scope(local_only).without_replies diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb index b958e6a5d..65686a866 100644 --- a/app/serializers/rest/custom_emoji_serializer.rb +++ b/app/serializers/rest/custom_emoji_serializer.rb @@ -3,7 +3,7 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer include RoutingHelper - attributes :shortcode, :url, :static_url + attributes :shortcode, :url, :static_url, :visible_in_picker def url full_asset_url(object.image.url) diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 5d83771c9..aa2229f13 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -40,6 +40,7 @@ class BatchedRemoveStatusService < BaseService # Cannot be batched statuses.each do |status| unpush_from_public_timelines(status) + unpush_from_direct_timelines(status) if status.direct_visibility? batch_salmon_slaps(status) if status.local? end @@ -100,6 +101,16 @@ class BatchedRemoveStatusService < BaseService end end + def unpush_from_direct_timelines(status) + payload = @json_payloads[status.id] + redis.pipelined do + @mentions[status.id].each do |mention| + redis.publish("timeline:direct:#{mention.account.id}", payload) if mention.account.local? + end + redis.publish("timeline:direct:#{status.account.id}", payload) if status.account.local? + end + end + def batch_salmon_slaps(status) return if @mentions[status.id].empty? diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 47a47a735..2214d73dd 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -10,15 +10,17 @@ class FanOutOnWriteService < BaseService deliver_to_self(status) if status.account.local? + render_anonymous_payload(status) + if status.direct_visibility? deliver_to_mentioned_followers(status) + deliver_to_direct_timelines(status) else deliver_to_followers(status) end return if status.account.silenced? || !status.public_visibility? || status.reblog? - render_anonymous_payload(status) deliver_to_hashtags(status) return if status.reply? && status.in_reply_to_account_id != status.account_id @@ -73,4 +75,13 @@ class FanOutOnWriteService < BaseService Redis.current.publish('timeline:public', @payload) Redis.current.publish('timeline:public:local', @payload) if status.local? end + + def deliver_to_direct_timelines(status) + Rails.logger.debug "Delivering status #{status.id} to direct timelines" + + status.mentions.includes(:account).each do |mention| + Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? + end + Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local? + end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 96d9208cc..8eef3e57e 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -18,6 +18,7 @@ class RemoveStatusService < BaseService remove_reblogs remove_from_hashtags remove_from_public + remove_from_direct if status.direct_visibility? @status.destroy! @@ -121,6 +122,13 @@ class RemoveStatusService < BaseService Redis.current.publish('timeline:public:local', @payload) if @status.local? end + def remove_from_direct + @mentions.each do |mention| + Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? + end + Redis.current.publish("timeline:direct:#{@account.id}", @payload) if @account.local? + end + def redis Redis.current end diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml index 1fa64908c..399d13bbd 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml @@ -9,7 +9,12 @@ - else = custom_emoji.domain %td - - unless custom_emoji.local? + - if custom_emoji.local? + - if custom_emoji.visible_in_picker + = table_link_to 'eye', t('admin.custom_emojis.listed'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: false }), method: :patch + - else + = table_link_to 'eye-slash', t('admin.custom_emojis.unlisted'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: true }), method: :patch + - else = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji, page: params[:page]), method: :post %td - if custom_emoji.disabled? diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index c3f5cb842..cba2bbbd4 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -5,6 +5,7 @@ %link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ %link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ %link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ + %link{ href: asset_pack_path('features/direct_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key} %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) diff --git a/app/views/settings/keyword_mutes/_fields.html.haml b/app/views/settings/keyword_mutes/_fields.html.haml new file mode 100644 index 000000000..892676f18 --- /dev/null +++ b/app/views/settings/keyword_mutes/_fields.html.haml @@ -0,0 +1,11 @@ +.fields-group + = f.input :keyword + = f.check_box :whole_word + = f.label :whole_word, t('keyword_mutes.match_whole_word') + +.actions + - if f.object.persisted? + = f.button :button, t('generic.save_changes'), type: :submit + = link_to t('keyword_mutes.remove'), settings_keyword_mute_path(f.object), class: 'negative button', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } + - else + = f.button :button, t('keyword_mutes.add_keyword'), type: :submit diff --git a/app/views/settings/keyword_mutes/_keyword_mute.html.haml b/app/views/settings/keyword_mutes/_keyword_mute.html.haml new file mode 100644 index 000000000..c45cc64fb --- /dev/null +++ b/app/views/settings/keyword_mutes/_keyword_mute.html.haml @@ -0,0 +1,10 @@ +%tr + %td + = keyword_mute.keyword + %td + - if keyword_mute.whole_word + %i.fa.fa-check + %td + = table_link_to 'edit', t('keyword_mutes.edit'), edit_settings_keyword_mute_path(keyword_mute) + %td + = table_link_to 'times', t('keyword_mutes.remove'), settings_keyword_mute_path(keyword_mute), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/settings/keyword_mutes/edit.html.haml b/app/views/settings/keyword_mutes/edit.html.haml new file mode 100644 index 000000000..af3949be2 --- /dev/null +++ b/app/views/settings/keyword_mutes/edit.html.haml @@ -0,0 +1,6 @@ +- content_for :page_title do + = t('keyword_mutes.edit_keyword') + += simple_form_for @keyword_mute, url: settings_keyword_mute_path(@keyword_mute), as: :keyword_mute do |f| + = render 'shared/error_messages', object: @keyword_mute + = render 'fields', f: f diff --git a/app/views/settings/keyword_mutes/index.html.haml b/app/views/settings/keyword_mutes/index.html.haml new file mode 100644 index 000000000..9ef8d55bc --- /dev/null +++ b/app/views/settings/keyword_mutes/index.html.haml @@ -0,0 +1,18 @@ +- content_for :page_title do + = t('settings.keyword_mutes') + +.table-wrapper + %table.table + %thead + %tr + %th= t('keyword_mutes.keyword') + %th= t('keyword_mutes.match_whole_word') + %th + %th + %tbody + = render partial: 'keyword_mute', collection: @keyword_mutes, as: :keyword_mute + += paginate @keyword_mutes +.simple_form + = link_to t('keyword_mutes.add_keyword'), new_settings_keyword_mute_path, class: 'button' + = link_to t('keyword_mutes.remove_all'), destroy_all_settings_keyword_mutes_path, class: 'button negative', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/settings/keyword_mutes/new.html.haml b/app/views/settings/keyword_mutes/new.html.haml new file mode 100644 index 000000000..5c999c8d2 --- /dev/null +++ b/app/views/settings/keyword_mutes/new.html.haml @@ -0,0 +1,6 @@ +- content_for :page_title do + = t('keyword_mutes.add_keyword') + += simple_form_for @keyword_mute, url: settings_keyword_mutes_path, as: :keyword_mute do |f| + = render 'shared/error_messages', object: @keyword_mute + = render 'fields', f: f diff --git a/config/environments/production.rb b/config/environments/production.rb index e0ee393c1..f7cb4b08a 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -97,6 +97,8 @@ Rails.application.configure do 'X-XSS-Protection' => '1; mode=block', 'Content-Security-Policy' => "frame-ancestors 'none'; object-src 'none'; script-src 'self' https://dev-static.glitch.social 'unsafe-inline'; base-uri 'none';" , 'Referrer-Policy' => 'no-referrer, strict-origin-when-cross-origin', - 'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload' + 'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload', + 'X-Clacks-Overhead' => 'GNU Natalie Nguyen' + } end diff --git a/config/locales/en.yml b/config/locales/en.yml index 45929e97d..d5c46470c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -130,11 +130,15 @@ en: enable: Enable enabled_msg: Successfully enabled that emoji image_hint: PNG up to 50KB + listed: Listed new: title: Add new custom emoji shortcode: Shortcode shortcode_hint: At least 2 characters, only alphanumeric characters and underscores title: Custom emojis + unlisted: Unlisted + update_failed_msg: Could not update that emoji + updated_msg: Emoji successfully updated! upload: Upload domain_blocks: add_new: Add new @@ -373,6 +377,14 @@ en: following: Following list muting: Muting list upload: Upload + keyword_mutes: + add_keyword: Add keyword + edit: Edit + edit_keyword: Edit keyword + keyword: Keyword + match_whole_word: Match whole word + remove: Remove + remove_all: Remove all landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse." landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>. media_attachments: @@ -491,6 +503,7 @@ en: export: Data export followers: Authorized followers import: Import + keyword_mutes: Muted keywords notifications: Notifications preferences: Preferences settings: Settings diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 9ca08831e..7c9caec14 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -1,39 +1,77 @@ --- ru: about: + about_hashtag_html: Это публичные статусы, отмеченные хэштегом <strong>#%{hashtag}</strong>. Вы можете взаимодействовать с ними при наличии у Вас аккаунта в глобальной сети Mastodon. about_mastodon_html: Mastodon - это <em>свободная</em> социальная сеть с <em>открытым исходным кодом</em>. Как <em>децентрализованная</em> альтернатива коммерческим платформам, Mastodon предотвращает риск монополизации Вашего общения одной компанией. Выберите сервер, которому Вы доверяете — что бы Вы ни выбрали, Вы сможете общаться со всеми остальными. Любой может запустить свой собственный узел Mastodon и участвовать в <em>социальной сети</em> совершенно бесшовно. about_this: Об этом узле closed_registrations: В данный момент регистрация на этом узле закрыта. contact: Связаться + contact_missing: Не установлено + contact_unavailable: Недоступен description_headline: Что такое %{domain}? domain_count_after: другими узлами domain_count_before: Связан с + extended_description_html: | + <h3>Хорошее место для правил</h3> + <p>Расширенное описание еще не настроено.</p> + features: + humane_approach_body: Наученный ошибками других проектов, Mastodon направлен на выбор этичных решений в борьбе со злоупотреблениями возможностями социальных сетей. + humane_approach_title: Человечный подход + not_a_product_body: Mastodon - не коммерческая сеть. Здесь нет рекламы, сбора данных, отгороженных мест. Здесь нет централизованного управления. + not_a_product_title: Вы - человек, а не продукт + real_conversation_body: С 500 символами в Вашем распоряжении и поддержкой предупреждений о содержании статусов Вы сможете выражать свои мысли так, как Вы этого хотите. + real_conversation_title: Создан для настоящего общения + within_reach_body: Различные приложения для iOS, Android и других платформ, написанные благодаря дружественной к разработчикам экосистеме API, позволят Вам держать связь с Вашими друзьями где угодно. + within_reach_title: Всегда под рукой + find_another_instance: Найти другой узел + generic_description: "%{domain} - один из серверов сети" + hosted_on: Mastodon размещен на %{domain} + learn_more: Узнать больше other_instances: Другие узлы source_code: Исходный код status_count_after: статусов status_count_before: Опубликовано user_count_after: пользователей user_count_before: Здесь живет + what_is_mastodon: Что такое Mastodon? accounts: follow: Подписаться followers: Подписчики following: Подписан(а) + media: Медиаконтент nothing_here: Здесь ничего нет! people_followed_by: Люди, на которых подписан(а) %{name} people_who_follow: Подписчики %{name} posts: Посты + posts_with_replies: Посты с ответами remote_follow: Подписаться на удаленном узле + reserved_username: Имя пользователя зарезервировано + roles: + admin: Администратор unfollow: Отписаться admin: + account_moderation_notes: + account: Модератор + create: Создать + created_at: Дата + created_msg: Заметка модератора успешно создана! + delete: Удалить + destroyed_msg: Заметка модератора успешно удалена! accounts: are_you_sure: Вы уверены? + confirm: Подтвердить + confirmed: Подтверждено + disable_two_factor_authentication: Отключить 2FA display_name: Отображаемое имя domain: Домен edit: Изменить email: E-mail feed_url: URL фида followers: Подписчики + followers_url: URL подписчиков follows: Подписки + inbox_url: URL входящих + ip: IP location: all: Все local: Локальные @@ -45,6 +83,7 @@ ru: silenced: Заглушенные suspended: Заблокированные title: Модерация + moderation_notes: Заметки модератора most_recent_activity: Последняя активность most_recent_ip: Последний IP not_subscribed: Не подписаны @@ -52,19 +91,51 @@ ru: alphabetic: По алфавиту most_recent: По дате title: Порядок + outbox_url: URL исходящих perform_full_suspension: Полная блокировка profile_url: URL профиля + protocol: Протокол public: Публичный push_subscription_expires: Подписка PuSH истекает + redownload: Обновить аватар + reset: Сбросить reset_password: Сбросить пароль + resubscribe: Переподписаться salmon_url: Salmon URL + search: Поиск + shared_inbox_url: URL общих входящих + show: + created_reports: Жалобы, отправленные этим аккаунтом + report: жалоба + targeted_reports: Жалобы на этот аккаунт silence: Глушение statuses: Статусы + subscribe: Подписаться title: Аккаунты undo_silenced: Снять глушение undo_suspension: Снять блокировку + unsubscribe: Отписаться username: Имя пользователя web: WWW + custom_emojis: + copied_msg: Локальная копия эмодзи успешно создана + copy: Скопироват + copy_failed_msg: Не удалось создать локальную копию эмодзи + created_msg: Эмодзи успешно создано! + delete: Удалить + destroyed_msg: Эмодзи успешно удалено! + disable: Отключить + disabled_msg: Эмодзи успешно отключено + emoji: Эмодзи + enable: Включить + enabled_msg: Эмодзи успешно включено + image_hint: PNG до 50KB + new: + title: Добавить новое эмодзи + shortcode: Шорткод + shortcode_hint: Как минимум 2 символа, только алфавитно-цифровые символы и подчеркивания + title: Собственные эмодзи + upload: Загрузить domain_blocks: add_new: Добавить новую created_msg: Блокировка домена обрабатывается @@ -74,13 +145,15 @@ ru: create: Создать блокировку hint: Блокировка домена не предотвратит создание новых аккаунтов в базе данных, но ретроактивно и автоматически применит указанные методы модерации для этих аккаунтов. severity: - desc_html: "<strong>Глушение</strong> сделает статусы аккаунта невидимыми для всех, кроме их подписчиков. <strong>Блокировка</strong> удалит весь контент аккаунта, включая мультимедийные вложения и данные профиля." + desc_html: "<strong>Глушение</strong> сделает статусы аккаунта невидимыми для всех, кроме их подписчиков. <strong>Блокировка</strong> удалит весь контент аккаунта, включая мультимедийные вложения и данные профиля. Используйте <strong>Ничего</strong>, если хотите только запретить медиаконтент." + noop: Ничего silence: Глушение suspend: Блокировка title: Новая доменная блокировка reject_media: Запретить медиаконтент reject_media_hint: Удаляет локально хранимый медиаконтент и запрещает его загрузку в будущем. Не имеет значения в случае блокировки. severities: + noop: Ничего silence: Глушение suspend: Блокировка severity: Строгость @@ -97,13 +170,34 @@ ru: undo: Отменить title: Доменные блокировки undo: Отемнить + email_domain_blocks: + add_new: Добавить новую + created_msg: Доменная блокировка еmail успешно создана + delete: Удалить + destroyed_msg: Доменная блокировка еmail успешно удалена + domain: Домен + new: + create: Создать блокировку + title: Новая доменная блокировка еmail + title: Доменная блокировка email + instances: + account_count: Известных аккаунтов + domain_name: Домен + reset: Сбросить + search: Поиск + title: Известные узлы reports: + action_taken_by: 'Действие предпринято:' + are_you_sure: Вы уверены? comment: label: Комментарий none: Нет delete: Удалить id: ID mark_as_resolved: Отметить как разрешенную + nsfw: + 'false': Показать мультимедийные вложения + 'true': Скрыть мультимедийные вложения report: 'Жалоба #%{id}' reported_account: Аккаунт нарушителя reported_by: Отправитель жалобы @@ -116,6 +210,9 @@ ru: unresolved: Неразрешенные view: Просмотреть settings: + bootstrap_timeline_accounts: + desc_html: Разделяйте имена пользователей запятыми. Сработает только для локальных незакрытых аккаунтов. По умолчанию включены все локальные администраторы. + title: Подписки по умолчанию для новых пользователей contact_information: email: Введите публичный e-mail username: Введите имя пользователя @@ -123,7 +220,11 @@ ru: closed_message: desc_html: Отображается на титульной странице, когда закрыта регистрация<br>Можно использовать HTML-теги title: Сообщение о закрытой регистрации + deletion: + desc_html: Позволяет всем удалять собственные аккаунты + title: Разрешить удаление аккаунтов open: + desc_html: Позволяет любому создавать аккаунт title: Открыть регистрацию site_description: desc_html: Отображается в качестве параграфа на титульной странице и используется в качестве мета-тега.<br>Можно использовать HTML-теги, в особенности <code><a></code> и <code><em></code>. @@ -131,8 +232,32 @@ ru: site_description_extended: desc_html: Отображается на странице дополнительной информации<br>Можно использовать HTML-теги title: Расширенное описание сайта + site_terms: + desc_html: Вы можете добавить сюда собственную политику конфиденциальности, пользовательское соглашение и другие документы. Можно использовать теги HTML. + title: Условия использования site_title: Название сайта + thumbnail: + desc_html: Используется для предпросмотра с помощью OpenGraph и API. Рекомендуется разрешение 1200x630px + title: Картинка узла + timeline_preview: + desc_html: Показывать публичную ленту на целевой странице + title: Предпросмотр ленты title: Настройки сайта + statuses: + back_to_account: Назад к странице аккаунта + batch: + delete: Удалить + nsfw_off: Выключить NSFW + nsfw_on: Включить NSFW + execute: Выполнить + failed_to_execute: Не удалось выполнить + media: + hide: Скрыть медиаконтент + show: Показать медиаконтент + title: Медиаконтент + no_media: Без медиаконтента + title: Статусы аккаунта + with_media: С медиаконтентом subscriptions: callback_url: Callback URL confirmed: Подтверждено @@ -141,18 +266,31 @@ ru: title: WebSub topic: Тема title: Администрирование + admin_mailer: + new_report: + body: "%{reporter} подал(а) жалобу на %{target}" + subject: Новая жалоба, узел %{instance} (#%{id}) application_mailer: + salutation: "%{name}," settings: 'Изменить настройки e-mail: %{link}' signature: Уведомления Mastodon от %{instance} view: 'Просмотр:' applications: + created: Приложение успешно создано + destroyed: Приложение успешно удалено invalid_url: Введенный URL неверен + regenerate_token: Повторно сгенерировать токен доступа + token_regenerated: Токен доступа успешно сгенерирован + warning: Будьте очень внимательны с этими данными. Не делитесь ими ни с кем! + your_token: Ваш токен доступа auth: + agreement_html: Создавая аккаунт, вы соглашаетесь с <a href="%{rules_path}">нашими правилами поведения</a> и <a href="%{terms_path}">политикой конфиденциальности</a>. change_password: Изменить пароль delete_account: Удалить аккаунт delete_account_html: Если Вы хотите удалить свой аккаунт, вы можете <a href="%{path}">перейти сюда</a>. У Вас будет запрошено подтверждение. didnt_get_confirmation: Не получили инструкцию для подтверждения? forgot_password: Забыли пароль? + invalid_reset_password_token: Токен сброса пароля неверен или устарел. Пожалуйста, запросите новый. login: Войти logout: Выйти register: Зарегистрироваться @@ -162,6 +300,12 @@ ru: authorize_follow: error: К сожалению, при поиске удаленного аккаунта возникла ошибка follow: Подписаться + follow_request: 'Вы отправили запрос на подписку:' + following: 'Ура! Теперь Вы подписаны на:' + post_follow: + close: Или просто закрыть это окно. + return: Вернуться к профилю пользователя + web: Перейти к WWW title: Подписаться на %{acct} datetime: distance_in_words: @@ -193,7 +337,10 @@ ru: content: Проверка безопасности не удалась. Возможно, Вы блокируете cookies? title: Проверка безопасности не удалась. '429': Слишком много запросов - noscript_html: Для работы с Mastodon, пожалуйста, включите JavaScript. + '500': + content: Приносим извинения, но на нашей стороне что-то пошло не так. + title: Страница неверна + noscript_html: Для работы с Mastodon, пожалуйста, включите JavaScript. Кроме того, вы можете использовать одно из <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">приложений</a> Mastodon для Вашей платформы. exports: blocks: Список блокировки csv: CSV @@ -265,23 +412,30 @@ ru: number: human: decimal_units: - format: "%n%u" + format: "%n %u" units: - billion: B - million: M + billion: млрд + million: млн quadrillion: Q - thousand: K - trillion: T + thousand: тыс + trillion: трлн unit: '' pagination: next: След prev: Пред truncate: "…" + preferences: + languages: Языки + other: Другое + publishing: Публикация + web: WWW push_notifications: favourite: title: Ваш статус понравился %{name} follow: title: "%{name} теперь подписан(а) на Вас" + group: + title: "%{count} уведомлений" mention: action_boost: Продвинуть action_expand: Развернуть @@ -335,16 +489,24 @@ ru: authorized_apps: Авторизованные приложения back: Назад в Mastodon delete: Удаление аккаунта + development: Разработка edit_profile: Изменить профиль export: Экспорт данных followers: Авторизованные подписчики import: Импорт + notifications: Уведомления preferences: Настройки settings: Опции two_factor_authentication: Двухфакторная аутентификация + your_apps: Ваши приложения statuses: open_in_web: Открыть в WWW over_character_limit: превышен лимит символов (%{max}) + pin_errors: + limit: Слишком много закрепленных статусов + ownership: Нельзя закрепить чужой статус + private: Нельзя закрепить непубличный статус + reblog: Нельзя закрепить продвинутый статус show_more: Подробнее visibilities: private: Для подписчиков @@ -359,6 +521,8 @@ ru: sensitive_content: Чувствительный контент terms: title: Условия обслуживания и политика конфиденциальности %{instance} + themes: + default: Mastodon time: formats: default: "%b %d, %Y, %H:%M" @@ -367,11 +531,13 @@ ru: description_html: При включении <strong>двухфакторной аутентификации</strong>, вход потребует от Вас использования Вашего телефона, который сгенерирует входные токены. disable: Отключить enable: Включить + enabled: Двухфакторная аутентификация включена enabled_success: Двухфакторная аутентификация успешно включена generate_recovery_codes: Сгенерировать коды восстановления instructions_html: "<strong>Отсканируйте этот QR-код с помощью Google Authenticator или другого подобного приложения на Вашем телефоне</strong>. С этого момента приложение будет генерировать токены, которые будет необходимо ввести для входа." lost_recovery_codes: Коды восстановления позволяют вернуть доступ к аккаунту в случае утери телефона. Если Вы потеряли Ваши коды восстановления, вы можете заново сгенерировать их здесь. Ваши старые коды восстановления будут аннулированы. manual_instructions: 'Если Вы не можете отсканировать QR-код и хотите ввести его вручную, секрет представлен здесь открытым текстом:' + recovery_codes: Коды восстановления recovery_codes_regenerated: Коды восстановления успешно сгенерированы recovery_instructions_html: В случае утери доступа к Вашему телефону Вы можете использовать один из кодов восстановления, указанных ниже, чтобы вернуть доступ к аккаунту. Держите коды восстановления в безопасности, например, распечатав их и храня с другими важными документами. setup: Настроить @@ -379,3 +545,4 @@ ru: users: invalid_email: Введенный e-mail неверен invalid_otp_token: Введен неверный код + signed_in_as: 'Выполнен вход под именем:' diff --git a/config/locales/simple_form.pt-BR.yml b/config/locales/simple_form.pt-BR.yml index 22cae5271..9d60e0171 100644 --- a/config/locales/simple_form.pt-BR.yml +++ b/config/locales/simple_form.pt-BR.yml @@ -4,6 +4,7 @@ pt-BR: hints: defaults: avatar: PNG, GIF or JPG. Arquivos de até 2MB. Eles serão diminuídos para 120x120px + digest: Enviado após um longo período de inatividade com um resumo das menções que você recebeu em sua ausência. display_name: one: <span class="name-counter">1</span> caracter restante other: <span class="name-counter">%{count}</span> caracteres restantes @@ -13,6 +14,7 @@ pt-BR: one: <span class="note-counter">1</span> caracter restante other: <span class="note-counter">%{count}</span> caracteres restantes setting_noindex: Afeta seu perfil público e as páginas de suas postagens + setting_theme: Afeta a aparência do Mastodon quando em sua conta em qualquer aparelho. imports: data: Arquivo CSV exportado de outra instância do Mastodon sessions: @@ -42,7 +44,9 @@ pt-BR: setting_default_sensitive: Sempre marcar mídia como sensível setting_delete_modal: Mostrar diálogo de confirmação antes de deletar uma postagem setting_noindex: Não quero ser indexado por mecanismos de busca + setting_reduce_motion: Reduz movimento em animações setting_system_font_ui: Usar a fonte padrão de seu sistema + setting_theme: Tema do site setting_unfollow_modal: Mostrar diálogo de confirmação antes de deixar de seguir alguém severity: Gravidade type: Tipo de importação diff --git a/config/locales/simple_form.ru.yml b/config/locales/simple_form.ru.yml index 3bdb7870f..1b780ac26 100644 --- a/config/locales/simple_form.ru.yml +++ b/config/locales/simple_form.ru.yml @@ -4,6 +4,7 @@ ru: hints: defaults: avatar: PNG, GIF или JPG. Максимально 2MB. Будет уменьшено до 120x120px + digest: Отсылается после долгого периода неактивности с общей информацией упоминаний, полученных в Ваше отсутствие display_name: few: Осталось <span class="name-counter">%{count}</span> символа many: Осталось <span class="name-counter">%{count}</span> символов @@ -17,6 +18,7 @@ ru: one: Остался <span class="name-counter">1</span> символ other: Осталось <span class="name-counter">%{count}</span> символов setting_noindex: Относится к Вашему публичному профилю и страницам статусов + setting_theme: Влияет на внешний вид Mastodon при выполненном входе в аккаунт. imports: data: Файл CSV, экспортированный с другого узла Mastodon sessions: @@ -46,6 +48,8 @@ ru: setting_default_sensitive: Всегда отмечать медиаконтент как чувствительный setting_delete_modal: Показывать диалог подтверждения перед удалением setting_noindex: Отказаться от индексации в поисковых машинах + setting_reduce_motion: Уменьшить движение в анимации + setting_site_theme: Тема сайта setting_system_font_ui: Использовать шрифт системы по умолчанию setting_unfollow_modal: Показывать диалог подтверждения перед тем, как отписаться от аккаунта severity: Строгость diff --git a/config/navigation.rb b/config/navigation.rb index 50bfbd480..9fa029b72 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -7,6 +7,7 @@ SimpleNavigation::Configuration.run do |navigation| primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings| settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url + settings.item :keyword_mutes, safe_join([fa_icon('volume-off fw'), t('settings.keyword_mutes')]), settings_keyword_mutes_url settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url settings.item :password, safe_join([fa_icon('lock fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete} settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication} diff --git a/config/routes.rb b/config/routes.rb index 9ed081e50..aca613ed2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,6 +66,13 @@ Rails.application.routes.draw do namespace :settings do resource :profile, only: [:show, :update] + + resources :keyword_mutes do + collection do + delete :destroy_all + end + end + resource :preferences, only: [:show, :update] resource :notifications, only: [:show, :update] resource :import, only: [:show, :create] @@ -140,7 +147,7 @@ Rails.application.routes.draw do resource :two_factor_authentication, only: [:destroy] end - resources :custom_emojis, only: [:index, :new, :create, :destroy] do + resources :custom_emojis, only: [:index, :new, :create, :update, :destroy] do member do post :copy post :enable @@ -193,6 +200,7 @@ Rails.application.routes.draw do end namespace :timelines do + resource :direct, only: :show, controller: :direct resource :home, only: :show, controller: :home resource :public, only: :show, controller: :public resources :tag, only: :show diff --git a/db/migrate/20170920032311_fix_reblogs_in_feeds.rb b/db/migrate/20170920032311_fix_reblogs_in_feeds.rb index c813ecd46..439c5fca0 100644 --- a/db/migrate/20170920032311_fix_reblogs_in_feeds.rb +++ b/db/migrate/20170920032311_fix_reblogs_in_feeds.rb @@ -3,48 +3,62 @@ class FixReblogsInFeeds < ActiveRecord::Migration[5.1] redis = Redis.current fm = FeedManager.instance - # find_each is batched on the database side. - User.includes(:account).find_each do |user| - account = user.account + # Old scheme: + # Each user's feed zset had a series of score:value entries, + # where "regular" statuses had the same score and value (their + # ID). Reblogs had a score of the reblogging status' ID, and a + # value of the reblogged status' ID. - # Old scheme: - # Each user's feed zset had a series of score:value entries, - # where "regular" statuses had the same score and value (their - # ID). Reblogs had a score of the reblogging status' ID, and a - # value of the reblogged status' ID. - - # New scheme: - # The feed contains only entries with the same score and value. - # Reblogs result in the reblogging status being added to the - # feed, with an entry in a reblog tracking zset (where the score - # is once again set to the reblogging status' ID, and the value - # is set to the reblogged status' ID). This is safe for Redis' - # float coersion because in this reblog tracking zset, we only - # need the rebloggging status' ID to be able to stop tracking - # entries after they have gotten too far down the feed, which - # does not require an exact value. - - # So, first, we iterate over the user's feed to find any reblogs. - timeline_key = fm.key(:home, account.id) - reblog_key = fm.key(:home, account.id, 'reblogs') - redis.zrange(timeline_key, 0, -1, with_scores: true).each do |entry| - next if entry[0] == entry[1] + # New scheme: + # The feed contains only entries with the same score and value. + # Reblogs result in the reblogging status being added to the + # feed, with an entry in a reblog tracking zset (where the score + # is once again set to the reblogging status' ID, and the value + # is set to the reblogged status' ID). This is safe for Redis' + # float coersion because in this reblog tracking zset, we only + # need the rebloggging status' ID to be able to stop tracking + # entries after they have gotten too far down the feed, which + # does not require an exact value. + + # This process reads all feeds and writes 3 times for each reblogs. + # So we use Lua script to avoid overhead between Ruby and Redis. + script = <<-LUA + local timeline_key = KEYS[1] + local reblog_key = KEYS[2] - # The score and value don't match, so this is a reblog. - # (note that we're transitioning from IDs < 53 bits so we - # don't have to worry about the loss of precision) + -- So, first, we iterate over the user's feed to find any reblogs. + local items = redis.call('zrange', timeline_key, 0, -1, 'withscores') + + for i = 1, #items, 2 do + local reblogged_id = items[i] + local reblogging_id = items[i + 1] + if (reblogged_id ~= reblogging_id) then - reblogged_id, reblogging_id = entry + -- The score and value don't match, so this is a reblog. + -- (note that we're transitioning from IDs < 53 bits so we + -- don't have to worry about the loss of precision) - # Remove the old entry - redis.zrem(timeline_key, reblogged_id) + -- Remove the old entry + redis.call('zrem', timeline_key, reblogged_id) - # Add a new one for the reblogging status - redis.zadd(timeline_key, reblogging_id, reblogging_id) + -- Add a new one for the reblogging status + redis.call('zadd', timeline_key, reblogging_id, reblogging_id) - # Track the fact that this was a reblog - redis.zadd(reblog_key, reblogging_id, reblogged_id) + -- Track the fact that this was a reblog + redis.call('zadd', reblog_key, reblogging_id, reblogged_id) + end end + LUA + script_hash = redis.script(:load, script) + + # find_each is batched on the database side. + User.includes(:account).find_each do |user| + account = user.account + + timeline_key = fm.key(:home, account.id) + reblog_key = fm.key(:home, account.id, 'reblogs') + + redis.evalsha(script_hash, [timeline_key, reblog_key]) end end diff --git a/db/migrate/20171009222537_create_keyword_mutes.rb b/db/migrate/20171009222537_create_keyword_mutes.rb new file mode 100644 index 000000000..ec0c756fb --- /dev/null +++ b/db/migrate/20171009222537_create_keyword_mutes.rb @@ -0,0 +1,12 @@ +class CreateKeywordMutes < ActiveRecord::Migration[5.1] + def change + create_table :keyword_mutes do |t| + t.references :account, null: false + t.string :keyword, null: false + t.boolean :whole_word, null: false, default: true + t.timestamps + end + + add_foreign_key :keyword_mutes, :accounts, on_delete: :cascade + end +end diff --git a/db/migrate/20171020084748_add_visible_in_picker_to_custom_emoji.rb b/db/migrate/20171020084748_add_visible_in_picker_to_custom_emoji.rb new file mode 100644 index 000000000..60a287101 --- /dev/null +++ b/db/migrate/20171020084748_add_visible_in_picker_to_custom_emoji.rb @@ -0,0 +1,7 @@ +class AddVisibleInPickerToCustomEmoji < ActiveRecord::Migration[5.1] + def change + safety_assured { + add_column :custom_emojis, :visible_in_picker, :boolean, default: true, null: false + } + end +end diff --git a/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb new file mode 100644 index 000000000..269bb49d6 --- /dev/null +++ b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb @@ -0,0 +1,7 @@ +class MoveKeywordMutesIntoGlitchNamespace < ActiveRecord::Migration[5.1] + def change + safety_assured do + rename_table :keyword_mutes, :glitch_keyword_mutes + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 128f51ee7..f96a5340f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171010025614) do +ActiveRecord::Schema.define(version: 20171021191900) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -111,6 +111,7 @@ ActiveRecord::Schema.define(version: 20171010025614) do t.boolean "disabled", default: false, null: false t.string "uri" t.string "image_remote_url" + t.boolean "visible_in_picker", default: true, null: false t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true end @@ -155,6 +156,15 @@ ActiveRecord::Schema.define(version: 20171010025614) do t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true end + create_table "glitch_keyword_mutes", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "keyword", null: false + t.boolean "whole_word", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_glitch_keyword_mutes_on_account_id" + end + create_table "imports", force: :cascade do |t| t.integer "type", null: false t.boolean "approved", default: false, null: false @@ -472,6 +482,7 @@ ActiveRecord::Schema.define(version: 20171010025614) do add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade + add_foreign_key "glitch_keyword_mutes", "accounts", on_delete: :cascade add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify add_foreign_key "media_attachments", "statuses", on_delete: :nullify diff --git a/lib/paperclip/gif_transcoder.rb b/lib/paperclip/gif_transcoder.rb index cbe53b27b..629e37581 100644 --- a/lib/paperclip/gif_transcoder.rb +++ b/lib/paperclip/gif_transcoder.rb @@ -10,6 +10,7 @@ module Paperclip unless options[:style] == :original && num_frames > 1 tmp_file = Paperclip::TempfileFactory.new.generate(attachment.instance.file_file_name) tmp_file << file.read + tmp_file.flush return tmp_file end diff --git a/spec/controllers/settings/keyword_mutes_controller_spec.rb b/spec/controllers/settings/keyword_mutes_controller_spec.rb new file mode 100644 index 000000000..a8c37a072 --- /dev/null +++ b/spec/controllers/settings/keyword_mutes_controller_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Settings::KeywordMutesController, type: :controller do + +end diff --git a/spec/fabricators/glitch_keyword_mute_fabricator.rb b/spec/fabricators/glitch_keyword_mute_fabricator.rb new file mode 100644 index 000000000..20d393320 --- /dev/null +++ b/spec/fabricators/glitch_keyword_mute_fabricator.rb @@ -0,0 +1,2 @@ +Fabricator('Glitch::KeywordMute') do +end diff --git a/spec/fixtures/files/mini-static.gif b/spec/fixtures/files/mini-static.gif new file mode 100644 index 000000000..fe597b215 --- /dev/null +++ b/spec/fixtures/files/mini-static.gif Binary files differdiff --git a/spec/helpers/settings/keyword_mutes_helper_spec.rb b/spec/helpers/settings/keyword_mutes_helper_spec.rb new file mode 100644 index 000000000..a19d518dd --- /dev/null +++ b/spec/helpers/settings/keyword_mutes_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Settings::KeywordMutesHelper. For example: +# +# describe Settings::KeywordMutesHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Settings::KeywordMutesHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 1861cc6ed..e678d3ca4 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -119,6 +119,44 @@ RSpec.describe FeedManager do reblog = Fabricate(:status, reblog: status, account: jeff) expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true end + + it 'returns true for a status containing a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + status = Fabricate(:status, text: 'This is a hot take', account: bob) + + expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true + end + + it 'returns true for a reply containing a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + s1 = Fabricate(:status, text: 'Something', account: alice) + s2 = Fabricate(:status, text: 'This is a hot take', thread: s1, account: bob) + + expect(FeedManager.instance.filter?(:home, s2, alice.id)).to be true + end + + it 'returns true for a status whose spoiler text contains a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob) + + expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true + end + + it 'returns true for a reblog containing a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + status = Fabricate(:status, text: 'This is a hot take', account: bob) + reblog = Fabricate(:status, reblog: status, account: jeff) + + expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true + end + + it 'returns true for a reblog whose spoiler text contains a muted keyword' do + Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take') + status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob) + reblog = Fabricate(:status, reblog: status, account: jeff) + + expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true + end end context 'for mentions feed' do @@ -147,6 +185,13 @@ RSpec.describe FeedManager do bob.follow!(alice) expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false end + + it 'returns true for status that contains a muted keyword' do + Fabricate('Glitch::KeywordMute', account: bob, keyword: 'take') + status = Fabricate(:status, text: 'This is a hot take', account: alice) + bob.follow!(alice) + expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true + end end end diff --git a/spec/models/glitch/keyword_mute_spec.rb b/spec/models/glitch/keyword_mute_spec.rb new file mode 100644 index 000000000..1423823ba --- /dev/null +++ b/spec/models/glitch/keyword_mute_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' + +RSpec.describe Glitch::KeywordMute, type: :model do + let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) } + let(:bob) { Fabricate(:account, username: 'bob').tap(&:save!) } + + describe '.matcher_for' do + let(:matcher) { Glitch::KeywordMute.matcher_for(alice) } + + describe 'with no mutes' do + before do + Glitch::KeywordMute.delete_all + end + + it 'does not match' do + expect(matcher =~ 'This is a hot take').to be_falsy + end + end + + describe 'with mutes' do + it 'does not match keywords set by a different account' do + Glitch::KeywordMute.create!(account: bob, keyword: 'take') + + expect(matcher =~ 'This is a hot take').to be_falsy + end + + it 'does not match if no keywords match the status text' do + Glitch::KeywordMute.create!(account: alice, keyword: 'cold') + + expect(matcher =~ 'This is a hot take').to be_falsy + end + + it 'considers word boundaries when matching' do + Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true) + + expect(matcher =~ 'bobcats').to be_falsy + end + + it 'matches substrings if whole_word is false' do + Glitch::KeywordMute.create!(account: alice, keyword: 'take', whole_word: false) + + expect(matcher =~ 'This is a shiitake mushroom').to be_truthy + end + + it 'matches keywords at the beginning of the text' do + Glitch::KeywordMute.create!(account: alice, keyword: 'take') + + expect(matcher =~ 'Take this').to be_truthy + end + + it 'matches keywords at the end of the text' do + Glitch::KeywordMute.create!(account: alice, keyword: 'take') + + expect(matcher =~ 'This is a hot take').to be_truthy + end + + it 'matches if at least one keyword case-insensitively matches the text' do + Glitch::KeywordMute.create!(account: alice, keyword: 'hot') + + expect(matcher =~ 'This is a HOT take').to be_truthy + end + + it 'matches keywords surrounded by non-alphanumeric ornamentation' do + Glitch::KeywordMute.create!(account: alice, keyword: 'hot') + + expect(matcher =~ '(hot take)').to be_truthy + end + + it 'escapes metacharacters in keywords' do + Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)') + + expect(matcher =~ '(hot take)').to be_truthy + end + + it 'uses case-folding rules appropriate for more than just English' do + Glitch::KeywordMute.create!(account: alice, keyword: 'großeltern') + + expect(matcher =~ 'besuch der grosseltern').to be_truthy + end + + it 'matches keywords that are composed of multiple words' do + Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake') + + expect(matcher =~ 'This is a shiitake').to be_truthy + expect(matcher =~ 'This is shiitake').to_not be_truthy + end + end + end +end diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index 9fce5bc4f..435b4f326 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -20,20 +20,29 @@ RSpec.describe MediaAttachment, type: :model do end describe 'non-animated gif non-conversion' do - let(:media) { MediaAttachment.create(account: Fabricate(:account), file: attachment_fixture('attachment.gif')) } + fixtures = [ + { filename: 'attachment.gif', width: 600, height: 400, aspect: 1.5 }, + { filename: 'mini-static.gif', width: 32, height: 32, aspect: 1.0 }, + ] - it 'sets type to image' do - expect(media.type).to eq 'image' - end + fixtures.each do |fixture| + context fixture[:filename] do + let(:media) { MediaAttachment.create(account: Fabricate(:account), file: attachment_fixture(fixture[:filename])) } - it 'leaves original file as-is' do - expect(media.file_content_type).to eq 'image/gif' - end + it 'sets type to image' do + expect(media.type).to eq 'image' + end - it 'sets meta' do - expect(media.file.meta["original"]["width"]).to eq 600 - expect(media.file.meta["original"]["height"]).to eq 400 - expect(media.file.meta["original"]["aspect"]).to eq 1.5 + it 'leaves original file as-is' do + expect(media.file_content_type).to eq 'image/gif' + end + + it 'sets meta' do + expect(media.file.meta["original"]["width"]).to eq fixture[:width] + expect(media.file.meta["original"]["height"]).to eq fixture[:height] + expect(media.file.meta["original"]["aspect"]).to eq fixture[:aspect] + end + end end end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 9cb71d715..12e857169 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -232,6 +232,55 @@ RSpec.describe Status, type: :model do end end + describe '.as_direct_timeline' do + let(:account) { Fabricate(:account) } + let(:followed) { Fabricate(:account) } + let(:not_followed) { Fabricate(:account) } + + before do + Fabricate(:follow, account: account, target_account: followed) + + @self_public_status = Fabricate(:status, account: account, visibility: :public) + @self_direct_status = Fabricate(:status, account: account, visibility: :direct) + @followed_public_status = Fabricate(:status, account: followed, visibility: :public) + @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct) + @not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct) + + @results = Status.as_direct_timeline(account) + end + + it 'does not include public statuses from self' do + expect(@results).to_not include(@self_public_status) + end + + it 'includes direct statuses from self' do + expect(@results).to include(@self_direct_status) + end + + it 'does not include public statuses from followed' do + expect(@results).to_not include(@followed_public_status) + end + + it 'includes direct statuses mentioning recipient from followed' do + Fabricate(:mention, account: account, status: @followed_direct_status) + expect(@results).to include(@followed_direct_status) + end + + it 'does not include direct statuses not mentioning recipient from followed' do + expect(@results).to_not include(@followed_direct_status) + end + + it 'includes direct statuses mentioning recipient from non-followed' do + Fabricate(:mention, account: account, status: @not_followed_direct_status) + expect(@results).to include(@not_followed_direct_status) + end + + it 'does not include direct statuses not mentioning recipient from non-followed' do + expect(@results).to_not include(@not_followed_direct_status) + end + + end + describe '.as_public_timeline' do it 'only includes statuses with public visibility' do public_status = Fabricate(:status, visibility: :public) diff --git a/streaming/index.js b/streaming/index.js index 83903b89b..8adc5174a 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -402,6 +402,10 @@ const startWorker = (workerId) => { streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true); }); + app.get('/api/v1/streaming/direct', (req, res) => { + streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true); + }); + app.get('/api/v1/streaming/hashtag', (req, res) => { streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true); }); @@ -437,6 +441,9 @@ const startWorker = (workerId) => { case 'public:local': streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; + case 'direct': + streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); + break; case 'hashtag': streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; |