Compare commits

...

19 Commits

Author SHA1 Message Date
David Yip
e130007fb2 Port 85470ec872 to glitch emoji picker.
Fixes #541.
2018-06-15 12:10:45 -05:00
Jenkins
afceef74c2 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2018-06-14 06:17:24 +00:00
Yamagishi Kazutoshi
f972815f1b Hide status content when spoiler text is not empty (#7797) 2018-06-14 08:03:51 +02:00
Yamagishi Kazutoshi
79a468016a Fix "Invalid DOM property class" (#7798) 2018-06-14 08:03:07 +02:00
David Yip
9d2b7ef9f8 Merge pull request #540 from lanodan/glitch-soc-pr/charLimit
Put maxChars back in compose.js
2018-06-13 22:46:31 -05:00
Shuhei Kitagawa
ad8814232f Add tests for following accounts controller (#7800) 2018-06-14 10:49:17 +09:00
Lain Iwakura
6bae583d2f Handle character limit in initial state in frontend. 2018-06-14 01:56:11 +02:00
chr v1.x
0338da1699 Add profile options on compose form (#7789)
* Add profile options on compose form

* Remove unused imports to appease codeclimate

* Play nicely with cancel button and use ellipsis-v instead of hamburger

* Fix whitespace and quotes to appease codeclimate
2018-06-13 14:44:50 +02:00
Shuhei Kitagawa
5b47774ab8 Add tests for followers_accounts_controller (#7794) 2018-06-13 10:28:39 +09:00
David Yip
f1bfcb50f0 Merge pull request #531 from glitch-soc/454-allow-keyword-mutes-to-skip-mentions
Allow keyword mutes to skip mentions (#454)
2018-06-12 19:28:08 -05:00
David Yip
5cff053944 Merge branch 'master' into 454-allow-keyword-mutes-to-skip-mentions 2018-06-12 18:13:30 -05:00
David Yip
f6bb50b6ec Merge pull request #539 from glitch-soc/merge-upstream
Merge upstream
2018-06-12 18:12:29 -05:00
David Yip
99b2bc2668 keyword mute: Add missing scope for regex escape test 2018-06-12 17:48:38 -05:00
David Yip
908a770d2b keyword mute: use mentions scope in home feed filtering (#454)
If a status shows up in mentions because all keyword mutes that might
apply to it are marked as "don't apply to mentions", then it ought to
show up in the home feed also.
2018-06-12 17:14:35 -05:00
David Yip
e931cf656d Merge remote-tracking branch 'glitchsoc/master' into 454-allow-keyword-mutes-to-skip-mentions
Conflicts:
 	app/models/glitch/keyword_mute.rb
2018-06-12 16:39:30 -05:00
David Yip
97d2df77aa Add apply-to-mentions option to keyword mute UI. #454. 2018-06-04 02:51:28 -05:00
David Yip
cf28049f0a Add a FeedManager example demonstrating non-mention keywords. #454. 2018-06-03 23:04:00 -05:00
David Yip
a40e322f4b Fix spacing in some FeedManager examples. 2018-06-03 23:02:01 -05:00
David Yip
26573ad7e6 Thread scopes through #matches?. #454.
Also add an apply_to_mentions attribute on Glitch::KeywordMute, which is
used to calculate scope.  Next up: additions to the test suite to
demonstrate how scoping works.
2018-06-03 23:00:50 -05:00
23 changed files with 330 additions and 104 deletions

View File

@@ -52,7 +52,7 @@ class Settings::KeywordMutesController < Settings::BaseController
end
def keyword_mute_params
params.require(:keyword_mute).permit(:keyword, :whole_word)
params.require(:keyword_mute).permit(:keyword, :whole_word, :apply_to_mentions)
end
def paginated_keyword_mutes_for_account

View File

@@ -54,9 +54,13 @@ function addCustomToPool(custom, pool) {
index = {};
}
function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
if (customEmojisList !== custom)
addCustomToPool(custom, originalPool);
function search(value, { emojisToShowFilter, maxResults, include, exclude, custom } = {}) {
if (custom !== undefined) {
if (customEmojisList !== custom)
addCustomToPool(custom, originalPool);
} else {
custom = [];
}
maxResults = maxResults || 75;
include = include || [];

View File

@@ -50,13 +50,14 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.hidden = normalOldStatus.get('hidden');
} else {
const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
normalStatus.hidden = normalStatus.sensitive;
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = spoilerText.length > 0 || normalStatus.sensitive;
}
return normalStatus;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
});
@injectIntl
export default class ActionBar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { intl } = this.props;
let menu = [];
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
return (
<div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div>
</div>
);
}
}

View File

@@ -17,6 +17,7 @@ import { isMobile } from '../../../is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { countableText } from '../util/counter';
import { maxChars } from '../../../initial_state';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
@@ -80,7 +81,7 @@ export default class ComposeForm extends ImmutablePureComponent {
const { is_submitting, is_uploading, anyMedia } = this.props;
const fulltext = [this.props.spoiler_text, countableText(this.props.text)].join('');
if (is_submitting || is_uploading || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
if (is_submitting || is_uploading || length(fulltext) > maxChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
@@ -158,7 +159,7 @@ export default class ComposeForm extends ImmutablePureComponent {
const { intl, onPaste, showSearch, anyMedia } = this.props;
const disabled = this.props.is_submitting;
const text = [this.props.spoiler_text, countableText(this.props.text)].join('');
const disabledButton = disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
const disabledButton = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
let publishText = '';
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
@@ -210,7 +211,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<SensitiveButtonContainer />
<SpoilerButtonContainer />
</div>
<div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
<div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
</div>
<div className='compose-form__publish'>

View File

@@ -1,9 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ActionBar from './action_bar';
import Avatar from '../../../components/avatar';
import IconButton from '../../../components/icon_button';
import Permalink from '../../../components/permalink';
import IconButton from '../../../components/icon_button';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -30,7 +31,10 @@ export default class NavigationBar extends ImmutablePureComponent {
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>
<IconButton title='' icon='close' onClick={this.props.onClose} />
<div className='navigation-bar__actions'>
<IconButton className='close' title='' icon='close' onClick={this.props.onClose} />
<ActionBar account={this.props.account} />
</div>
</div>
);
}

View File

@@ -11,5 +11,6 @@ export const boostModal = getMeta('boost_modal');
export const deleteModal = getMeta('delete_modal');
export const me = getMeta('me');
export const searchEnabled = getMeta('search_enabled');
export const maxChars = getMeta('max_toot_chars') || 500;
export default initialState;

View File

@@ -1510,9 +1510,21 @@ a.account__display-name {
text-decoration: none;
}
.icon-button {
pointer-events: none;
opacity: 0;
.navigation-bar__actions {
position: relative;
.icon-button.close {
position: absolute;
pointer-events: none;
transform: scale(0.0, 1.0) translate(-100%, 0);
opacity: 0;
}
.compose__action-bar .icon-button {
pointer-events: auto;
transform: scale(1.0, 1.0) translate(0, 0);
opacity: 1;
}
}
}
@@ -4932,9 +4944,18 @@ noscript {
transition: margin-top $duration $delay;
}
& > .icon-button {
will-change: opacity;
transition: opacity $duration $delay;
.navigation-bar__actions {
& > .icon-button.close {
will-change: opacity transform;
transition: opacity $duration * 0.5 $delay,
transform $duration $delay;
}
& > .compose__action-bar .icon-button {
will-change: opacity transform;
transition: opacity $duration * 0.5 $delay + $duration * 0.5,
transform $duration $delay;
}
}
}
@@ -4961,9 +4982,18 @@ noscript {
margin-top: -50px;
}
.icon-button {
pointer-events: auto;
opacity: 1;
.navigation-bar__actions {
.icon-button.close {
pointer-events: auto;
opacity: 1;
transform: scale(1.0, 1.0) translate(0, 0);
}
.compose__action-bar .icon-button {
pointer-events: none;
opacity: 0;
transform: scale(0.0, 1.0) translate(100%, 0);
}
}
}
}

View File

@@ -153,7 +153,7 @@ class FeedManager
def filter_from_home?(status, receiver_id)
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, receiver_id)
return true if keyword_filter_from_home?(status, receiver_id)
check_for_mutes = [status.account_id]
check_for_mutes.concat(status.mentions.pluck(:account_id))
@@ -182,8 +182,24 @@ class FeedManager
false
end
def keyword_filter?(status, receiver_id)
Glitch::KeywordMuteHelper.new(receiver_id).matches?(status)
def keyword_filter_from_home?(status, receiver_id)
# If this status mentions the receiver, use the mentions scope: it's
# possible that the status will show up in the receiver's mentions, which
# means it ought to show up in the home feed as well.
#
# If it doesn't mention the receiver but is still headed for the home feed,
# use the home feed scope.
scope = if status.mentions.pluck(:account_id).include?(receiver_id)
Glitch::KeywordMute::Scopes::Mentions
else
Glitch::KeywordMute::Scopes::HomeFeed
end
return true if keyword_filter?(status, receiver_id, scope)
end
def keyword_filter?(status, receiver_id, scope)
Glitch::KeywordMuteHelper.new(receiver_id).matches?(status, scope)
end
def filter_from_mentions?(status, receiver_id)
@@ -197,7 +213,7 @@ class FeedManager
should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
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, receiver_id) # or if the mention contains a muted keyword
should_filter ||= keyword_filter?(status, receiver_id, Glitch::KeywordMute::Scopes::Mentions) # or if the mention contains a muted keyword
should_filter
end

View File

@@ -3,11 +3,11 @@
#
# Table name: bookmarks
#
# id :integer not null, primary key
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# status_id :integer not null
#
class Bookmark < ApplicationRecord

View File

@@ -3,12 +3,13 @@
#
# 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
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# keyword :string not null
# whole_word :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# apply_to_mentions :boolean default(TRUE), not null
#
class Glitch::KeywordMute < ApplicationRecord
@@ -18,6 +19,12 @@ class Glitch::KeywordMute < ApplicationRecord
after_commit :invalidate_cached_matchers
module Scopes
Unscoped = 0b00
HomeFeed = 0b01
Mentions = 0b10
end
def self.text_matcher_for(account_id)
TextMatcher.new(account_id)
end
@@ -26,6 +33,13 @@ class Glitch::KeywordMute < ApplicationRecord
TagMatcher.new(account_id)
end
def scope
s = Scopes::Unscoped
s |= Scopes::HomeFeed
s |= Scopes::Mentions if apply_to_mentions?
s
end
private
def invalidate_cached_matchers
@@ -36,10 +50,12 @@ class Glitch::KeywordMute < ApplicationRecord
class CachedKeywordMute
attr_reader :keyword
attr_reader :whole_word
attr_reader :scope
def initialize(keyword, whole_word)
def initialize(keyword, whole_word, scope)
@keyword = keyword
@whole_word = whole_word
@scope = scope
end
def boundary_regex_for_keyword
@@ -49,26 +65,27 @@ class Glitch::KeywordMute < ApplicationRecord
/(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
end
def matches?(str)
str =~ (whole_word ? boundary_regex_for_keyword : /#{Regexp.escape(keyword)}/i)
def matches?(str, required_scope)
((required_scope & scope) == required_scope) && \
str =~ (whole_word ? boundary_regex_for_keyword : /#{Regexp.escape(keyword)}/i)
end
end
class Matcher
attr_reader :account_id
attr_reader :words
attr_reader :keywords
def initialize(account_id)
@account_id = account_id
@words = Rails.cache.fetch(self.class.cache_key(account_id)) { fetch_keywords }
@keywords = Rails.cache.fetch(self.class.cache_key(account_id)) { fetch_keywords }
end
protected
def fetch_keywords
Glitch::KeywordMute.where(account_id: account_id).pluck(:whole_word, :keyword).map do |whole_word, keyword|
CachedKeywordMute.new(transform_keyword(keyword), whole_word)
end
Glitch::KeywordMute.select(:whole_word, :keyword, :apply_to_mentions)
.where(account_id: account_id)
.map { |kw| CachedKeywordMute.new(transform_keyword(kw.keyword), kw.whole_word, kw.scope) }
end
def transform_keyword(keyword)
@@ -81,8 +98,8 @@ class Glitch::KeywordMute < ApplicationRecord
format('keyword_mutes:regex:text:%s', account_id)
end
def matches?(str)
words.any? { |w| w.matches?(str) }
def matches?(str, scope)
keywords.any? { |kw| kw.matches?(str, scope) }
end
end
@@ -91,9 +108,9 @@ class Glitch::KeywordMute < ApplicationRecord
format('keyword_mutes:regex:tag:%s', account_id)
end
def matches?(tags)
def matches?(tags, scope)
tags.pluck(:name).any? do |n|
words.any? { |w| w.matches?(n) }
keywords.any? { |kw| kw.matches?(n, scope) }
end
end

View File

@@ -9,16 +9,16 @@ class Glitch::KeywordMuteHelper
@tag_matcher = Glitch::KeywordMute.tag_matcher_for(receiver_id)
end
def matches?(status)
matchers_match?(status) || (status.reblog? && matchers_match?(status.reblog))
def matches?(status, scope)
matchers_match?(status, scope) || (status.reblog? && matchers_match?(status.reblog, scope))
end
private
def matchers_match?(status)
text_matcher.matches?(prepare_text(status.text)) ||
text_matcher.matches?(prepare_text(status.spoiler_text)) ||
tag_matcher.matches?(status.tags)
def matchers_match?(status, scope)
text_matcher.matches?(prepare_text(status.text), scope) ||
text_matcher.matches?(prepare_text(status.spoiler_text), scope) ||
tag_matcher.matches?(status.tags, scope)
end
def prepare_text(text)

View File

@@ -2,6 +2,9 @@
= f.input :keyword
= f.check_box :whole_word
= f.label :whole_word, t('keyword_mutes.match_whole_word')
%br
= f.check_box :apply_to_mentions
= f.label :apply_to_mentions, t('keyword_mutes.apply_to_mentions')
.actions
- if f.object.persisted?

View File

@@ -4,6 +4,9 @@
%td
- if keyword_mute.whole_word
%i.fa.fa-check
%td
- if keyword_mute.apply_to_mentions
%i.fa.fa-check
%td
= table_link_to 'edit', t('keyword_mutes.edit'), edit_settings_keyword_mute_path(keyword_mute)
%td

View File

@@ -7,6 +7,7 @@
%tr
%th= t('keyword_mutes.keyword')
%th= t('keyword_mutes.match_whole_word')
%th= t('keyword_mutes.apply_to_mentions')
%th
%th
%tbody

View File

@@ -526,6 +526,7 @@ en:
title: Invite people
keyword_mutes:
add_keyword: Add keyword
apply_to_mentions: Apply to mentions
edit: Edit
edit_keyword: Edit keyword
keyword: Keyword

View File

@@ -0,0 +1,17 @@
require 'mastodon/migration_helpers'
class AddApplyToMentionsFlagToKeywordMutes < ActiveRecord::Migration[5.2]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def up
safety_assured do
add_column_with_default :glitch_keyword_mutes, :apply_to_mentions, :boolean, allow_null: false, default: true
end
end
def down
remove_column :glitch_keyword_mutes, :apply_to_mentions
end
end

View File

@@ -10,8 +10,9 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_06_09_104432) do
ActiveRecord::Schema.define(version: 2018_06_09_104432) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -206,6 +207,7 @@ ActiveRecord::Schema.define(version: 2018_06_09_104432) do
t.boolean "whole_word", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "apply_to_mentions", default: true, null: false
t.index ["account_id"], name: "index_glitch_keyword_mutes_on_account_id"
end
@@ -466,7 +468,6 @@ ActiveRecord::Schema.define(version: 2018_06_09_104432) do
t.bigint "application_id"
t.bigint "in_reply_to_account_id"
t.boolean "local_only"
t.text "full_status_text", default: "", null: false
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc }
t.index ["conversation_id"], name: "index_statuses_on_conversation_id"
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"

View File

@@ -8,18 +8,45 @@ describe FollowerAccountsController do
let(:follower1) { Fabricate(:account) }
describe 'GET #index' do
it 'assigns follows' do
follow0 = follower0.follow!(alice)
follow1 = follower1.follow!(alice)
let!(:follow0) { follower0.follow!(alice) }
let!(:follow1) { follower1.follow!(alice) }
get :index, params: { account_username: alice.username }
context 'when format is html' do
subject(:response) { get :index, params: { account_username: alice.username, format: :html } }
assigned = assigns(:follows).to_a
expect(assigned.size).to eq 2
expect(assigned[0]).to eq follow1
expect(assigned[1]).to eq follow0
it 'assigns follows' do
expect(response).to have_http_status(200)
expect(response).to have_http_status(200)
assigned = assigns(:follows).to_a
expect(assigned.size).to eq 2
expect(assigned[0]).to eq follow1
expect(assigned[1]).to eq follow0
end
end
context 'when format is json' do
subject(:response) { get :index, params: { account_username: alice.username, page: page, format: :json } }
subject(:body) { JSON.parse(response.body) }
context 'with page' do
let(:page) { 1 }
it 'returns followers' do
expect(response).to have_http_status(200)
expect(body['totalItems']).to eq 2
expect(body['partOf']).to be_present
end
end
context 'without page' do
let(:page) { nil }
it 'returns followers' do
expect(response).to have_http_status(200)
expect(body['totalItems']).to eq 2
expect(body['partOf']).to be_blank
end
end
end
end
end

View File

@@ -8,18 +8,45 @@ describe FollowingAccountsController do
let(:followee1) { Fabricate(:account) }
describe 'GET #index' do
it 'assigns followees' do
follow0 = alice.follow!(followee0)
follow1 = alice.follow!(followee1)
let!(:follow0) { alice.follow!(followee0) }
let!(:follow1) { alice.follow!(followee1) }
get :index, params: { account_username: alice.username }
context 'when format is html' do
subject(:response) { get :index, params: { account_username: alice.username, format: :html } }
assigned = assigns(:follows).to_a
expect(assigned.size).to eq 2
expect(assigned[0]).to eq follow1
expect(assigned[1]).to eq follow0
it 'assigns follows' do
expect(response).to have_http_status(200)
expect(response).to have_http_status(200)
assigned = assigns(:follows).to_a
expect(assigned.size).to eq 2
expect(assigned[0]).to eq follow1
expect(assigned[1]).to eq follow0
end
end
context 'when format is json' do
subject(:response) { get :index, params: { account_username: alice.username, page: page, format: :json } }
subject(:body) { JSON.parse(response.body) }
context 'with page' do
let(:page) { 1 }
it 'returns followers' do
expect(response).to have_http_status(200)
expect(body['totalItems']).to eq 2
expect(body['partOf']).to be_present
end
end
context 'without page' do
let(:page) { nil }
it 'returns followers' do
expect(response).to have_http_status(200)
expect(body['totalItems']).to eq 2
expect(body['partOf']).to be_blank
end
end
end
end
end

View File

@@ -175,7 +175,7 @@ RSpec.describe FeedManager do
it 'returns true for a status with a tag that matches a muted keyword' do
Fabricate('Glitch::KeywordMute', account: alice, keyword: 'jorts')
status = Fabricate(:status, account: bob)
status.tags << Fabricate(:tag, name: 'jorts')
status.tags << Fabricate(:tag, name: 'jorts')
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
end
@@ -183,10 +183,18 @@ RSpec.describe FeedManager do
it 'returns true for a status with a tag that matches an octothorpe-prefixed muted keyword' do
Fabricate('Glitch::KeywordMute', account: alice, keyword: '#jorts')
status = Fabricate(:status, account: bob)
status.tags << Fabricate(:tag, name: 'jorts')
status.tags << Fabricate(:tag, name: 'jorts')
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
end
it 'returns false if the status is muted by a keyword mute that does not apply to mentions' do
Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take', apply_to_mentions: false)
status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
status.mentions.create!(account_id: alice.id)
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be false
end
end
context 'for mentions feed' do
@@ -222,6 +230,13 @@ RSpec.describe FeedManager do
bob.follow!(alice)
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
end
it 'returns false for a mention that contains a word muted by a keyword that does not apply to mentions' do
Fabricate('Glitch::KeywordMute', account: bob, keyword: 'take', apply_to_mentions: false)
status = Fabricate(:status, text: 'This is a hot take', account: alice)
bob.follow!(alice)
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false
end
end
end

View File

@@ -2,6 +2,8 @@ require 'rails_helper'
RSpec.describe Glitch::KeywordMuteHelper do
describe '#matches?' do
Unscoped = Glitch::KeywordMute::Scopes::Unscoped
let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) }
let(:helper) { Glitch::KeywordMuteHelper.new(alice) }
@@ -9,42 +11,42 @@ RSpec.describe Glitch::KeywordMuteHelper do
status = Fabricate(:status, text: '<addr>uh example</addr>')
Glitch::KeywordMute.create!(account: alice, keyword: 'addr')
expect(helper.matches?(status)).to be false
expect(helper.matches?(status, Unscoped)).to be false
end
it 'ignores properties of HTML tags in status text' do
status = Fabricate(:status, text: '<a href="https://www.example.org">uh example</a>')
Glitch::KeywordMute.create!(account: alice, keyword: 'href')
expect(helper.matches?(status)).to be false
expect(helper.matches?(status, Unscoped)).to be false
end
it 'matches text inside HTML tags' do
status = Fabricate(:status, text: '<p>HEY THIS IS SOMETHING ANNOYING</p>')
Glitch::KeywordMute.create!(account: alice, keyword: 'annoying')
expect(helper.matches?(status)).to be true
expect(helper.matches?(status, Unscoped)).to be true
end
it 'matches < in HTML-stripped text' do
status = Fabricate(:status, text: '<p>I <3 oats</p>')
Glitch::KeywordMute.create!(account: alice, keyword: '<3')
expect(helper.matches?(status)).to be true
expect(helper.matches?(status, Unscoped)).to be true
end
it 'matches &lt; in HTML text' do
status = Fabricate(:status, text: '<p>I &lt;3 oats</p>')
Glitch::KeywordMute.create!(account: alice, keyword: '<3')
expect(helper.matches?(status)).to be true
expect(helper.matches?(status, Unscoped)).to be true
end
it 'matches link hrefs in HTML text' do
status = Fabricate(:status, text: '<p><a href="https://example.com/it-was-milk">yep</a></p>')
Glitch::KeywordMute.create!(account: alice, keyword: 'milk')
expect(helper.matches?(status)).to be true
expect(helper.matches?(status, Unscoped)).to be true
end
end
end

View File

@@ -4,6 +4,8 @@ RSpec.describe Glitch::KeywordMute, type: :model do
let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) }
let(:bob) { Fabricate(:account, username: 'bob').tap(&:save!) }
Unscoped = Glitch::KeywordMute::Scopes::Unscoped
describe '.text_matcher_for' do
let(:matcher) { Glitch::KeywordMute.text_matcher_for(alice.id) }
@@ -13,7 +15,7 @@ RSpec.describe Glitch::KeywordMute, type: :model do
end
it 'does not match' do
expect(matcher.matches?('This is a hot take')).to be_falsy
expect(matcher.matches?('This is a hot take', Unscoped)).to be_falsy
end
end
@@ -21,87 +23,87 @@ RSpec.describe Glitch::KeywordMute, type: :model do
it 'does not match keywords set by a different account' do
Glitch::KeywordMute.create!(account: bob, keyword: 'take')
expect(matcher.matches?('This is a hot take')).to be_falsy
expect(matcher.matches?('This is a hot take', Unscoped)).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.matches?('This is a hot take')).to be_falsy
expect(matcher.matches?('This is a hot take', Unscoped)).to be_falsy
end
it 'considers word boundaries when matching' do
Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true)
expect(matcher.matches?('bobcats')).to be_falsy
expect(matcher.matches?('bobcats', Unscoped)).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.matches?('This is a shiitake mushroom')).to be_truthy
expect(matcher.matches?('This is a shiitake mushroom', Unscoped)).to be_truthy
end
it 'matches keywords at the beginning of the text' do
Glitch::KeywordMute.create!(account: alice, keyword: 'take')
expect(matcher.matches?('Take this')).to be_truthy
expect(matcher.matches?('Take this', Unscoped)).to be_truthy
end
it 'matches keywords at the end of the text' do
Glitch::KeywordMute.create!(account: alice, keyword: 'take')
expect(matcher.matches?('This is a hot take')).to be_truthy
expect(matcher.matches?('This is a hot take', Unscoped)).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.matches?('This is a HOT take')).to be_truthy
expect(matcher.matches?('This is a HOT take', Unscoped)).to be_truthy
end
it 'matches if at least one non-whole-word keyword case-insensitively matches the text' do
Glitch::KeywordMute.create!(account: alice, keyword: 'hot', whole_word: false)
expect(matcher.matches?('This is a HOTTY take')).to be_truthy
expect(matcher.matches?('This is a HOTTY take', Unscoped)).to be_truthy
end
it 'maintains case-insensitivity when combining keywords into a single matcher' do
Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
Glitch::KeywordMute.create!(account: alice, keyword: 'cold')
expect(matcher.matches?('This is a HOT take')).to be_truthy
expect(matcher.matches?('This is a HOT take', Unscoped)).to be_truthy
end
it 'matches keywords surrounded by non-alphanumeric ornamentation' do
Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
expect(matcher.matches?('(hot take)')).to be_truthy
expect(matcher.matches?('(hot take)', Unscoped)).to be_truthy
end
it 'escapes metacharacters in whole-word keywords' do
Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)')
expect(matcher.matches?('(hot take)')).to be_truthy
expect(matcher.matches?('(hot take)', Unscoped)).to be_truthy
end
it 'escapes metacharacters in non-whole-word keywords' do
Glitch::KeywordMute.create!(account: alice, keyword: '(-', whole_word: false)
expect(matcher.matches?('bad (-)')).to be_truthy
expect(matcher.matches?('bad (-)', Unscoped)).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.matches?('besuch der grosseltern')).to be_truthy
expect(matcher.matches?('besuch der grosseltern', Unscoped)).to be_truthy
end
it 'matches keywords that are composed of multiple words' do
Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake')
expect(matcher.matches?('This is a shiitake')).to be_truthy
expect(matcher.matches?('This is shiitake')).to_not be_truthy
expect(matcher.matches?('This is a shiitake', Unscoped)).to be_truthy
expect(matcher.matches?('This is shiitake', Unscoped)).to_not be_truthy
end
end
end
@@ -118,7 +120,7 @@ RSpec.describe Glitch::KeywordMute, type: :model do
it 'does not match' do
status.tags << Fabricate(:tag, name: 'xyzzy')
expect(matcher.matches?(status.tags)).to be false
expect(matcher.matches?(status.tags, Unscoped)).to be false
end
end
@@ -127,42 +129,42 @@ RSpec.describe Glitch::KeywordMute, type: :model do
status.tags << Fabricate(:tag, name: 'xyzzy')
Glitch::KeywordMute.create!(account: bob, keyword: 'take')
expect(matcher.matches?(status.tags)).to be false
expect(matcher.matches?(status.tags, Unscoped)).to be false
end
it 'matches #xyzzy when given the mute "#xyzzy"' do
status.tags << Fabricate(:tag, name: 'xyzzy')
Glitch::KeywordMute.create!(account: alice, keyword: '#xyzzy')
expect(matcher.matches?(status.tags)).to be true
expect(matcher.matches?(status.tags, Unscoped)).to be true
end
it 'matches #thingiverse when given the non-whole-word mute "#thing"' do
status.tags << Fabricate(:tag, name: 'thingiverse')
Glitch::KeywordMute.create!(account: alice, keyword: '#thing', whole_word: false)
expect(matcher.matches?(status.tags)).to be true
expect(matcher.matches?(status.tags, Unscoped)).to be true
end
it 'matches #hashtag when given the mute "##hashtag""' do
status.tags << Fabricate(:tag, name: 'hashtag')
Glitch::KeywordMute.create!(account: alice, keyword: '##hashtag')
expect(matcher.matches?(status.tags)).to be true
expect(matcher.matches?(status.tags, Unscoped)).to be true
end
it 'matches #oatmeal when given the non-whole-word mute "oat"' do
status.tags << Fabricate(:tag, name: 'oatmeal')
Glitch::KeywordMute.create!(account: alice, keyword: 'oat', whole_word: false)
expect(matcher.matches?(status.tags)).to be true
expect(matcher.matches?(status.tags, Unscoped)).to be true
end
it 'does not match #oatmeal when given the mute "#oat"' do
status.tags << Fabricate(:tag, name: 'oatmeal')
Glitch::KeywordMute.create!(account: alice, keyword: 'oat')
expect(matcher.matches?(status.tags)).to be false
expect(matcher.matches?(status.tags, Unscoped)).to be false
end
end
end