diff options
Diffstat (limited to 'app')
28 files changed, 379 insertions, 71 deletions
diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb new file mode 100644 index 000000000..c89722b85 --- /dev/null +++ b/app/controllers/api/v1/filters_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Api::V1::FiltersController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write }, except: [:index, :show] + before_action :require_user! + before_action :set_filters, only: :index + before_action :set_filter, only: [:show, :update, :destroy] + + respond_to :json + + def index + render json: @filters, each_serializer: REST::FilterSerializer + end + + def create + @filter = current_account.custom_filters.create!(resource_params) + render json: @filter, serializer: REST::FilterSerializer + end + + def show + render json: @filter, serializer: REST::FilterSerializer + end + + def update + @filter.update!(resource_params) + render json: @filter, serializer: REST::FilterSerializer + end + + def destroy + @filter.destroy! + render_empty + end + + private + + def set_filters + @filters = current_account.custom_filters + end + + def set_filter + @filter = current_account.custom_filters.find(params[:id]) + end + + def resource_params + params.permit(:phrase, :expires_at, :irreversible, context: []) + end +end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb new file mode 100644 index 000000000..03403a1ba --- /dev/null +++ b/app/controllers/filters_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class FiltersController < ApplicationController + include Authorization + + layout 'admin' + + before_action :set_filters, only: :index + before_action :set_filter, only: [:edit, :update, :destroy] + + def index + @filters = current_account.custom_filters + end + + def new + @filter = current_account.custom_filters.build + end + + def create + @filter = current_account.custom_filters.build(resource_params) + + if @filter.save + redirect_to filters_path + else + render action: :new + end + end + + def edit; end + + def update + if @filter.update(resource_params) + redirect_to filters_path + else + render action: :edit + end + end + + def destroy + @filter.destroy + redirect_to filters_path + end + + private + + def set_filters + @filters = current_account.custom_filters + end + + def set_filter + @filter = current_account.custom_filters.find(params[:id]) + end + + def resource_params + params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, context: []) + end +end diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js new file mode 100644 index 000000000..7fa1c9a70 --- /dev/null +++ b/app/javascript/mastodon/actions/filters.js @@ -0,0 +1,26 @@ +import api from '../api'; + +export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; +export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; +export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; + +export const fetchFilters = () => (dispatch, getState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + api(getState) + .get('/api/v1/filters') + .then(({ data }) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); +}; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index f56853bff..32fc67e67 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -6,6 +6,7 @@ import { disconnectTimeline, } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; +import { fetchFilters } from './filters'; import { getLocale } from '../locales'; const { messages } = getLocale(); @@ -30,6 +31,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) case 'notification': dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); break; + case 'filters_changed': + dispatch(fetchFilters()); + break; } }, }; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index fd08ff3b7..922b609ec 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -157,6 +157,21 @@ export default class Status extends ImmutablePureComponent { ); } + if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) { + const minHandlers = this.props.muted ? {} : { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + + return ( + <HotKeys handlers={minHandlers}> + <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0'> + <FormattedMessage id='status.filtered' defaultMessage='Filtered' /> + </div> + </HotKeys> + ); + } + if (featured) { prepend = ( <div className='status__prepend'> diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 1c34d0640..68c9eef54 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -25,6 +25,7 @@ export default class StatusList extends ImmutablePureComponent { prepend: PropTypes.node, emptyMessage: PropTypes.node, alwaysPrepend: PropTypes.bool, + timelineId: PropTypes.string.isRequired, }; static defaultProps = { @@ -70,7 +71,7 @@ export default class StatusList extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, onLoadMore, ...other } = this.props; + const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props; const { isLoading, isPartial } = other; if (isPartial) { @@ -102,6 +103,7 @@ export default class StatusList extends ImmutablePureComponent { id={statusId} onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} + contextType={timelineId} /> )) ) : null; @@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent { featured onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} + contextType={timelineId} /> )).concat(scrollableContent); } diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 3e7b5215b..eb6329fdc 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -42,7 +42,7 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const mapStateToProps = (state, props) => ({ - status: getStatus(state, props.id), + status: getStatus(state, props), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js index 3a1d19aa8..f4325f58d 100644 --- a/app/javascript/mastodon/features/community_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.js @@ -1,15 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import SettingText from '../../../components/setting_text'; +import { injectIntl, FormattedMessage } from 'react-intl'; import SettingToggle from '../../notifications/components/setting_toggle'; -const messages = defineMessages({ - filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, - settings: { id: 'home.settings', defaultMessage: 'Column settings' }, -}); - @injectIntl export default class ColumnSettings extends React.PureComponent { @@ -21,19 +15,13 @@ export default class ColumnSettings extends React.PureComponent { }; render () { - const { settings, onChange, intl } = this.props; + const { settings, onChange } = this.props; return ( <div> <div className='column-settings__row'> <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> </div> - - <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> - - <div className='column-settings__row'> - <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> - </div> </div> ); } diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js index 73f394c1a..5eb1eb72a 100644 --- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -7,7 +7,7 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const mapStateToProps = state => ({ - status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), + status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js index fda57f69a..63dc41d9e 100644 --- a/app/javascript/mastodon/features/direct_timeline/index.js +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -7,7 +7,6 @@ import ColumnHeader from '../../components/column_header'; import { 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({ @@ -86,9 +85,7 @@ export default class DirectTimeline extends React.PureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} - > - <ColumnSettingsContainer /> - </ColumnHeader> + /> <StatusListContainer trackScroll={!pinned} diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js index 0c0c4fa36..932ac2049 100644 --- a/app/javascript/mastodon/features/home_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js @@ -1,14 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { injectIntl, FormattedMessage } from 'react-intl'; import SettingToggle from '../../notifications/components/setting_toggle'; -import SettingText from '../../../components/setting_text'; - -const messages = defineMessages({ - filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, - settings: { id: 'home.settings', defaultMessage: 'Column settings' }, -}); @injectIntl export default class ColumnSettings extends React.PureComponent { @@ -20,7 +14,7 @@ export default class ColumnSettings extends React.PureComponent { }; render () { - const { settings, onChange, intl } = this.props; + const { settings, onChange } = this.props; return ( <div> @@ -33,12 +27,6 @@ export default class ColumnSettings extends React.PureComponent { <div className='column-settings__row'> <SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> </div> - - <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> - - <div className='column-settings__row'> - <SettingText prefix='home_timeline' settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> - </div> </div> ); } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index ca792043f..3c66536d4 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -58,7 +58,7 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const mapStateToProps = (state, props) => { - const status = getStatus(state, props.params.statusId); + const status = getStatus(state, { id: props.params.statusId }); let ancestorsIds = Immutable.List(); let descendantsIds = Immutable.List(); @@ -336,6 +336,7 @@ export default class Status extends ImmutablePureComponent { id={id} onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} + contextType='thread' /> )); } diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index e5b1edc4a..3df5b7bea 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -11,15 +11,6 @@ const makeGetStatusIds = () => createSelector([ (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), (state) => state.get('statuses'), ], (columnSettings, statusIds, statuses) => { - const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); - let regex = null; - - try { - regex = rawRegex && new RegExp(rawRegex, 'i'); - } catch (e) { - // Bad regex, don't affect filters - } - return statusIds.filter(id => { if (id === null) return true; @@ -34,11 +25,6 @@ const makeGetStatusIds = () => createSelector([ showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); } - if (showStatus && regex && statusForId.get('account') !== me) { - const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index'); - showStatus = !regex.test(searchIndex); - } - return showStatus; }); }); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 3c1a266e3..56a856230 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -12,6 +12,7 @@ import { debounce } from 'lodash'; import { uploadCompose, resetCompose } from '../../actions/compose'; import { expandHomeTimeline } from '../../actions/timelines'; import { expandNotifications } from '../../actions/notifications'; +import { fetchFilters } from '../../actions/filters'; import { clearHeight } from '../../actions/height_cache'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import UploadArea from './components/upload_area'; @@ -297,6 +298,7 @@ export default class UI extends React.PureComponent { this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); + setTimeout(() => this.props.dispatch(fetchFilters()), 500); } componentDidMount () { diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js new file mode 100644 index 000000000..33f0c6732 --- /dev/null +++ b/app/javascript/mastodon/reducers/filters.js @@ -0,0 +1,11 @@ +import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; +import { List as ImmutableList, fromJS } from 'immutable'; + +export default function filters(state = ImmutableList(), action) { + switch(action.type) { + case FILTERS_FETCH_SUCCESS: + return fromJS(action.filters); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 3d9a6a132..4a981fada 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -26,6 +26,7 @@ import height_cache from './height_cache'; import custom_emojis from './custom_emojis'; import lists from './lists'; import listEditor from './list_editor'; +import filters from './filters'; const reducers = { dropdown_menu, @@ -55,6 +56,7 @@ const reducers = { custom_emojis, lists, listEditor, + filters, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index e47ec5183..56eca1f02 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -19,16 +19,44 @@ export const makeGetAccount = () => { }); }; +const toServerSideType = columnType => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + return columnType; + default: + if (columnType.indexOf('list:') > -1) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; + +const escapeRegExp = string => + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + +const regexFromFilters = filters => { + if (filters.size === 0) { + return null; + } + + return new RegExp(filters.map(filter => escapeRegExp(filter.get('phrase'))).join('|'), 'i'); +}; + export const makeGetStatus = () => { return createSelector( [ - (state, id) => state.getIn(['statuses', id]), - (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), - (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), - (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + (state, { id }) => state.getIn(['statuses', id]), + (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))), ], - (statusBase, statusReblog, accountBase, accountReblog) => { + (statusBase, statusReblog, accountBase, accountReblog, filters) => { if (!statusBase) { return null; } @@ -39,9 +67,13 @@ export const makeGetStatus = () => { statusReblog = null; } + const regex = regexFromFilters(filters); + const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index')); + return statusBase.withMutations(map => { map.set('reblog', statusReblog); map.set('account', accountBase); + map.set('filtered', filtered); }); } ); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index c16cf3437..5fa73d58a 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -725,6 +725,20 @@ vertical-align: middle; } +.status__wrapper--filtered { + color: $dark-text-color; + border: 0; + font-size: inherit; + text-align: center; + line-height: inherit; + margin: 0; + padding: 15px; + box-sizing: border-box; + width: 100%; + clear: both; + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + .status__prepend-icon-wrapper { left: -26px; position: absolute; diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index c18c07b33..ee9185d34 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -153,6 +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 phrase_filtered?(status, receiver_id, :home) check_for_blocks = status.mentions.pluck(:account_id) check_for_blocks.concat([status.account_id]) @@ -177,6 +178,7 @@ class FeedManager def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id + return true if phrase_filtered?(status, receiver_id, :notifications) # This filter is called from NotifyService, but already after the sender of # the notification has been checked for mute/block. Therefore, it's not @@ -190,6 +192,20 @@ class FeedManager should_filter end + def phrase_filtered?(status, receiver_id, context) + active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a + + active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? } + active_filters.map! { |filter| Regexp.new(Regexp.escape(filter.phrase), true) } + + return false if active_filters.empty? + + combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) } + + !combined_regex.match(status.text).nil? || + (status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) + end + # Adds a status to an account's feed, returning true if a status was # added, and false if it was not added to the feed. Note that this is # an internal helper: callers must call trim or push updates if diff --git a/app/models/account.rb b/app/models/account.rb index c3eea79cc..40a45b1f8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -99,6 +99,7 @@ class Account < ApplicationRecord has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id has_many :report_notes, dependent: :destroy + has_many :custom_filters, inverse_of: :account, dependent: :destroy # Moderation notes has_many :account_moderation_notes, dependent: :destroy diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb new file mode 100644 index 000000000..444ccdfdb --- /dev/null +++ b/app/models/concerns/expireable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Expireable + extend ActiveSupport::Concern + + included do + scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } + + attr_reader :expires_in + + def expires_in=(interval) + self.expires_at = interval.to_i.seconds.from_now unless interval.blank? + @expires_in = interval + end + + def expire! + touch(:expires_at) + end + + def expired? + !expires_at.nil? && expires_at < Time.now.utc + end + end +end diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb new file mode 100644 index 000000000..2c1a54375 --- /dev/null +++ b/app/models/custom_filter.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: custom_filters +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# expires_at :datetime +# phrase :text default(""), not null +# context :string default([]), not null, is an Array +# irreversible :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CustomFilter < ApplicationRecord + VALID_CONTEXTS = %w( + home + notifications + public + thread + ).freeze + + include Expireable + + belongs_to :account + + validates :phrase, :context, presence: true + validate :context_must_be_valid + validate :irreversible_must_be_within_context + + scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) } + + before_validation :clean_up_contexts + after_commit :remove_cache + + private + + def clean_up_contexts + self.context = Array(context).map(&:strip).map(&:presence).compact + end + + def remove_cache + Rails.cache.delete("filters:#{account_id}") + Redis.current.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed)) + end + + def context_must_be_valid + errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } + end + + def irreversible_must_be_within_context + errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications') + end +end diff --git a/app/models/invite.rb b/app/models/invite.rb index d0cc427c4..fe2322462 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -15,33 +15,19 @@ # class Invite < ApplicationRecord + include Expireable + belongs_to :user has_many :users, inverse_of: :invite scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) } - scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } before_validation :set_code - attr_reader :expires_in - - def expires_in=(interval) - self.expires_at = interval.to_i.seconds.from_now unless interval.blank? - @expires_in = interval - end - def valid_for_use? (max_uses.nil? || uses < max_uses) && !expired? end - def expire! - touch(:expires_at) - end - - def expired? - !expires_at.nil? && expires_at < Time.now.utc - end - private def set_code diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb new file mode 100644 index 000000000..07f2516f8 --- /dev/null +++ b/app/serializers/rest/filter_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class REST::FilterSerializer < ActiveModel::Serializer + attributes :id, :phrase, :context, :expires_at +end diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml new file mode 100644 index 000000000..af5d648b8 --- /dev/null +++ b/app/views/filters/_fields.html.haml @@ -0,0 +1,11 @@ +.fields-group + = f.input :phrase, as: :string, wrapper: :with_block_label + +.fields-group + = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false + +.fields-group + = f.input :irreversible, wrapper: :with_label + +.fields-group + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') diff --git a/app/views/filters/edit.html.haml b/app/views/filters/edit.html.haml new file mode 100644 index 000000000..e971215ac --- /dev/null +++ b/app/views/filters/edit.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('filters.edit.title') + += simple_form_for @filter, url: filter_path(@filter), method: :put do |f| + = render 'fields', f: f + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/filters/index.html.haml b/app/views/filters/index.html.haml new file mode 100644 index 000000000..18ebee570 --- /dev/null +++ b/app/views/filters/index.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t('filters.index.title') + +.table-wrapper + %table.table + %thead + %tr + %th= t('simple_form.labels.defaults.phrase') + %th= t('simple_form.labels.defaults.context') + %th + %tbody + - @filters.each do |filter| + %tr + %td= filter.phrase + %td= filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ') + %td + = table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter) + = table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete + += link_to t('filters.new.title'), new_filter_path, class: 'button' diff --git a/app/views/filters/new.html.haml b/app/views/filters/new.html.haml new file mode 100644 index 000000000..05bec343f --- /dev/null +++ b/app/views/filters/new.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('filters.new.title') + += simple_form_for @filter, url: filters_path do |f| + = render 'fields', f: f + + .actions + = f.button :button, t('filters.new.title'), type: :submit |