Add: #95 リストへの新着投稿通知 (#192)

* Add: テーブル定義、内部処理

* Add: 通知の定期削除処理、自動削除、テスト

* Add: Web画面の表示、設定

* Fix test
This commit is contained in:
KMY(雪あすか) 2023-10-31 08:59:31 +09:00 committed by GitHub
parent 2cc60253c4
commit f8280ca5d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 300 additions and 9 deletions

View file

@ -45,6 +45,6 @@ class Api::V1::ListsController < Api::BaseController
end end
def list_params def list_params
params.permit(:title, :replies_policy, :exclusive) params.permit(:title, :replies_policy, :exclusive, :notify)
end end
end end

View file

@ -151,10 +151,15 @@ export const createListFail = error => ({
error, error,
}); });
export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => { export const updateList = (id, title, shouldReset, isExclusive, replies_policy, notify) => (dispatch, getState) => {
dispatch(updateListRequest(id)); dispatch(updateListRequest(id));
api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { api(getState).put(`/api/v1/lists/${id}`, {
title,
replies_policy,
exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive,
notify: typeof notify === 'undefined' ? undefined : !!notify,
}).then(({ data }) => {
dispatch(updateListSuccess(data)); dispatch(updateListSuccess(data));
if (shouldReset) { if (shouldReset) {

View file

@ -148,6 +148,7 @@ const excludeTypesFromFilter = filter => {
'mention', 'mention',
'poll', 'poll',
'status', 'status',
'list_status',
'update', 'update',
'admin.sign_up', 'admin.sign_up',
'admin.report', 'admin.report',

View file

@ -154,13 +154,19 @@ class ListTimeline extends PureComponent {
handleRepliesPolicyChange = ({ target }) => { handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props; const { dispatch } = this.props;
const { id } = this.props.params; const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, target.value)); dispatch(updateList(id, undefined, false, undefined, target.value, undefined));
}; };
onExclusiveToggle = ({ target }) => { onExclusiveToggle = ({ target }) => {
const { dispatch } = this.props; const { dispatch } = this.props;
const { id } = this.props.params; const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.checked, undefined)); dispatch(updateList(id, undefined, false, target.checked, undefined, undefined));
};
onNotifyToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, undefined, target.checked));
}; };
render () { render () {
@ -170,6 +176,7 @@ class ListTimeline extends PureComponent {
const title = list ? list.get('title') : id; const title = list ? list.get('title') : id;
const replies_policy = list ? list.get('replies_policy') : undefined; const replies_policy = list ? list.get('replies_policy') : undefined;
const isExclusive = list ? list.get('exclusive') : undefined; const isExclusive = list ? list.get('exclusive') : undefined;
const isNotify = list ? list.get('notify') : undefined;
const antennas = list ? (list.get('antennas')?.toArray() || []) : []; const antennas = list ? (list.get('antennas')?.toArray() || []) : [];
if (typeof list === 'undefined') { if (typeof list === 'undefined') {
@ -216,6 +223,13 @@ class ListTimeline extends PureComponent {
</label> </label>
</div> </div>
<div className='setting-toggle'>
<Toggle id={`list-${id}-exclusive`} defaultChecked={isNotify} onChange={this.onNotifyToggle} />
<label htmlFor={`list-${id}-notify`} className='setting-toggle__label'>
<FormattedMessage id='lists.notify' defaultMessage='Notify these posts' />
</label>
</div>
{ replies_policy !== undefined && ( { replies_policy !== undefined && (
<div role='group' aria-labelledby={`list-${id}-replies-policy`}> <div role='group' aria-labelledby={`list-${id}-replies-policy`}>
<span id={`list-${id}-replies-policy`} className='column-settings__section'> <span id={`list-${id}-replies-policy`} className='column-settings__section'>

View file

@ -190,6 +190,17 @@ export default class ColumnSettings extends PureComponent {
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-list_status'>
<span id='notifications-list_status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.list_status' defaultMessage='New posts of list:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'list_status']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'list_status']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'list_status']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'list_status']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-update'> <div role='group' aria-labelledby='notifications-update'>
<span id='notifications-update' className='column-settings__section'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></span> <span id='notifications-update' className='column-settings__section'><FormattedMessage id='notifications.column_settings.update' defaultMessage='Edits:' /></span>

View file

@ -13,6 +13,7 @@ import { ReactComponent as FlagIcon } from '@material-symbols/svg-600/outlined/f
import { ReactComponent as HomeIcon } from '@material-symbols/svg-600/outlined/home-fill.svg'; import { ReactComponent as HomeIcon } from '@material-symbols/svg-600/outlined/home-fill.svg';
import { ReactComponent as InsertChartIcon } from '@material-symbols/svg-600/outlined/insert_chart.svg'; import { ReactComponent as InsertChartIcon } from '@material-symbols/svg-600/outlined/insert_chart.svg';
import { ReactComponent as ReferenceIcon } from '@material-symbols/svg-600/outlined/link.svg'; import { ReactComponent as ReferenceIcon } from '@material-symbols/svg-600/outlined/link.svg';
import { ReactComponent as ListAltIcon } from '@material-symbols/svg-600/outlined/list_alt.svg';
import { ReactComponent as PersonIcon } from '@material-symbols/svg-600/outlined/person-fill.svg'; import { ReactComponent as PersonIcon } from '@material-symbols/svg-600/outlined/person-fill.svg';
import { ReactComponent as PersonAddIcon } from '@material-symbols/svg-600/outlined/person_add-fill.svg'; import { ReactComponent as PersonAddIcon } from '@material-symbols/svg-600/outlined/person_add-fill.svg';
import { ReactComponent as RepeatIcon } from '@material-symbols/svg-600/outlined/repeat.svg'; import { ReactComponent as RepeatIcon } from '@material-symbols/svg-600/outlined/repeat.svg';
@ -38,6 +39,7 @@ const messages = defineMessages({
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }, poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' }, status: { id: 'notification.status', defaultMessage: '{name} just posted' },
listStatus: { id: 'notification.list_status', defaultMessage: '{name} post is added on {listName}' },
statusReference: { id: 'notification.status_reference', defaultMessage: '{name} refered' }, statusReference: { id: 'notification.status_reference', defaultMessage: '{name} refered' },
update: { id: 'notification.update', defaultMessage: '{name} edited a post' }, update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
warning: { id: 'notification.warning', defaultMessage: 'You have been warned and "{action}" has been executed. Check your mailbox' }, warning: { id: 'notification.warning', defaultMessage: 'You have been warned and "{action}" has been executed. Check your mailbox' },
@ -358,6 +360,42 @@ class Notification extends ImmutablePureComponent {
); );
} }
renderListStatus (notification, listLink, link) {
const { intl, unread, status } = this.props;
if (!status) {
return null;
}
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-list_status focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.listStatus, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<Icon id='list-ul' icon={ListAltIcon} />
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.list_status' defaultMessage='{name} post is added to {listName}' values={{ listName: listLink, name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
contextType='notifications'
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
withoutEmojiReactions
/>
</div>
</HotKeys>
);
}
renderUpdate (notification, link) { renderUpdate (notification, link) {
const { intl, unread, status } = this.props; const { intl, unread, status } = this.props;
@ -531,6 +569,10 @@ class Notification extends ImmutablePureComponent {
return this.renderStatusReference(notification, link); return this.renderStatusReference(notification, link);
case 'status': case 'status':
return this.renderStatus(notification, link); return this.renderStatus(notification, link);
case 'list_status':
const list = notification.get('list');
const listLink = <bdi><Link className='notification__display-name' href={`/lists/${list.get('id')}`} title={list.get('title')} to={`/lists/${list.get('id')}`}>{list.get('title')}</Link></bdi>;
return this.renderListStatus(notification, listLink, link);
case 'update': case 'update':
return this.renderUpdate(notification, link); return this.renderUpdate(notification, link);
case 'poll': case 'poll':

View file

@ -399,6 +399,7 @@
"lists.exclusive": "Hide list or antenna account posts from home", "lists.exclusive": "Hide list or antenna account posts from home",
"lists.new.create": "Add list", "lists.new.create": "Add list",
"lists.new.title_placeholder": "New list title", "lists.new.title_placeholder": "New list title",
"lists.notify": "Notify these posts",
"lists.replies_policy.followed": "Any followed user", "lists.replies_policy.followed": "Any followed user",
"lists.replies_policy.list": "Members of the list", "lists.replies_policy.list": "Members of the list",
"lists.replies_policy.none": "No one", "lists.replies_policy.none": "No one",
@ -448,6 +449,7 @@
"notification.favourite": "{name} favorited your post", "notification.favourite": "{name} favorited your post",
"notification.follow": "{name} followed you", "notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you", "notification.follow_request": "{name} has requested to follow you",
"notification.list_status": "{name} post is added to {listName}",
"notification.mention": "{name} mentioned you", "notification.mention": "{name} mentioned you",
"notification.own_poll": "Your poll has ended", "notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended", "notification.poll": "A poll you have voted in has ended",

View file

@ -474,6 +474,7 @@
"lists.exclusive": "ホームからリスト・アンテナに登録されたアカウントの投稿を非表示にする", "lists.exclusive": "ホームからリスト・アンテナに登録されたアカウントの投稿を非表示にする",
"lists.new.create": "リストを作成", "lists.new.create": "リストを作成",
"lists.new.title_placeholder": "新規リスト名", "lists.new.title_placeholder": "新規リスト名",
"lists.notify": "これらの投稿を通知する",
"lists.replies_policy.followed": "フォロー中のユーザー全員", "lists.replies_policy.followed": "フォロー中のユーザー全員",
"lists.replies_policy.list": "リストのメンバー", "lists.replies_policy.list": "リストのメンバー",
"lists.replies_policy.none": "表示しない", "lists.replies_policy.none": "表示しない",
@ -525,6 +526,7 @@
"notification.favourite": "{name}さんがお気に入りしました", "notification.favourite": "{name}さんがお気に入りしました",
"notification.follow": "{name}さんにフォローされました", "notification.follow": "{name}さんにフォローされました",
"notification.follow_request": "{name}さんがあなたにフォローリクエストしました", "notification.follow_request": "{name}さんがあなたにフォローリクエストしました",
"notification.list_status": "{name}さんの投稿が{listName}に追加されました",
"notification.mention": "{name}さんがあなたに返信しました", "notification.mention": "{name}さんがあなたに返信しました",
"notification.own_poll": "アンケートが終了しました", "notification.own_poll": "アンケートが終了しました",
"notification.poll": "アンケートが終了しました", "notification.poll": "アンケートが終了しました",

View file

@ -55,6 +55,7 @@ const notificationToMap = notification => ImmutableMap({
created_at: notification.created_at, created_at: notification.created_at,
emoji_reaction: ImmutableMap(notification.emoji_reaction), emoji_reaction: ImmutableMap(notification.emoji_reaction),
status: notification.status ? notification.status.id : null, status: notification.status ? notification.status.id : null,
list: notification.list ? ImmutableMap(notification.list) : null,
report: notification.report ? fromJS(notification.report) : null, report: notification.report ? fromJS(notification.report) : null,
account_warning: notification.account_warning ? ImmutableMap(notification.account_warning) : null, account_warning: notification.account_warning ? ImmutableMap(notification.account_warning) : null,
}); });

View file

@ -42,6 +42,7 @@ const initialState = ImmutableMap({
mention: false, mention: false,
poll: false, poll: false,
status: false, status: false,
list_status: false,
update: false, update: false,
emoji_reaction: false, emoji_reaction: false,
status_reference: false, status_reference: false,
@ -66,6 +67,7 @@ const initialState = ImmutableMap({
mention: true, mention: true,
poll: true, poll: true,
status: true, status: true,
list_status: true,
update: true, update: true,
emoji_reaction: true, emoji_reaction: true,
status_reference: true, status_reference: true,
@ -81,6 +83,7 @@ const initialState = ImmutableMap({
mention: true, mention: true,
poll: true, poll: true,
status: true, status: true,
list_status: true,
update: true, update: true,
emoji_reaction: true, emoji_reaction: true,
status_reference: true, status_reference: true,

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Vacuum::ListStatusesVacuum
include Redisable
LIST_STATUS_LIFE_DURATION = 1.day.freeze
def perform
vacuum_list_statuses!
end
private
def vacuum_list_statuses!
ListStatus.where('created_at < ?', LIST_STATUS_LIFE_DURATION.ago).in_batches.destroy_all
end
end

View file

@ -11,6 +11,7 @@
# updated_at :datetime not null # updated_at :datetime not null
# replies_policy :integer default("list"), not null # replies_policy :integer default("list"), not null
# exclusive :boolean default(FALSE), not null # exclusive :boolean default(FALSE), not null
# notify :boolean default(FALSE), not null
# #
class List < ApplicationRecord class List < ApplicationRecord
@ -25,6 +26,8 @@ class List < ApplicationRecord
has_many :list_accounts, inverse_of: :list, dependent: :destroy has_many :list_accounts, inverse_of: :list, dependent: :destroy
has_many :accounts, through: :list_accounts has_many :accounts, through: :list_accounts
has_many :antennas, inverse_of: :list, dependent: :destroy has_many :antennas, inverse_of: :list, dependent: :destroy
has_many :list_statuses, inverse_of: :list, dependent: :destroy
has_many :statuses, through: :list_statuses
validates :title, presence: true validates :title, presence: true

21
app/models/list_status.rb Normal file
View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: list_statuses
#
# id :bigint(8) not null, primary key
# list_id :bigint(8) not null
# status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class ListStatus < ApplicationRecord
belongs_to :list
belongs_to :status
has_one :notification, as: :activity, dependent: :destroy
validates :status, uniqueness: { scope: :list }
end

View file

@ -22,6 +22,7 @@ class Notification < ApplicationRecord
LEGACY_TYPE_CLASS_MAP = { LEGACY_TYPE_CLASS_MAP = {
'Mention' => :mention, 'Mention' => :mention,
'Status' => :reblog, 'Status' => :reblog,
'ListStatus' => :list_status,
'Follow' => :follow, 'Follow' => :follow,
'FollowRequest' => :follow_request, 'FollowRequest' => :follow_request,
'Favourite' => :favourite, 'Favourite' => :favourite,
@ -34,6 +35,7 @@ class Notification < ApplicationRecord
TYPES = %i( TYPES = %i(
mention mention
status status
list_status
reblog reblog
status_reference status_reference
follow follow
@ -50,6 +52,7 @@ class Notification < ApplicationRecord
TARGET_STATUS_INCLUDES_BY_TYPE = { TARGET_STATUS_INCLUDES_BY_TYPE = {
status: :status, status: :status,
list_status: [list_status: :status],
reblog: [status: :reblog], reblog: [status: :reblog],
status_reference: [status_reference: :status], status_reference: [status_reference: :status],
mention: [mention: :status], mention: [mention: :status],
@ -68,6 +71,7 @@ class Notification < ApplicationRecord
with_options foreign_key: 'activity_id', optional: true do with_options foreign_key: 'activity_id', optional: true do
belongs_to :mention, inverse_of: :notification belongs_to :mention, inverse_of: :notification
belongs_to :status, inverse_of: :notification belongs_to :status, inverse_of: :notification
belongs_to :list_status, inverse_of: :notification
belongs_to :follow, inverse_of: :notification belongs_to :follow, inverse_of: :notification
belongs_to :follow_request, inverse_of: :notification belongs_to :follow_request, inverse_of: :notification
belongs_to :favourite, inverse_of: :notification belongs_to :favourite, inverse_of: :notification
@ -90,6 +94,8 @@ class Notification < ApplicationRecord
case type case type
when :status, :update when :status, :update
status status
when :list_status
list_status&.status
when :reblog when :reblog
status&.reblog status&.reblog
when :status_reference when :status_reference
@ -143,6 +149,8 @@ class Notification < ApplicationRecord
case notification.type case notification.type
when :status, :update when :status, :update
notification.status = cached_status notification.status = cached_status
when :list_status
notification.list_status.status = cached_status
when :reblog when :reblog
notification.status.reblog = cached_status notification.status.reblog = cached_status
when :status_reference when :status_reference
@ -182,7 +190,7 @@ class Notification < ApplicationRecord
case activity_type case activity_type
when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'EmojiReact', 'FollowRequest', 'Poll', 'Report', 'AccountWarning' when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'EmojiReact', 'FollowRequest', 'Poll', 'Report', 'AccountWarning'
self.from_account_id = activity&.account_id self.from_account_id = activity&.account_id
when 'Mention', 'StatusReference' when 'Mention', 'StatusReference', 'ListStatus'
self.from_account_id = activity&.status&.account_id self.from_account_id = activity&.status&.account_id
when 'Account' when 'Account'
self.from_account_id = activity&.id self.from_account_id = activity&.id

View file

@ -111,6 +111,7 @@ class Status < ApplicationRecord
has_one :trend, class_name: 'StatusTrend', inverse_of: :status has_one :trend, class_name: 'StatusTrend', inverse_of: :status
has_one :scheduled_expiration_status, inverse_of: :status, dependent: :destroy has_one :scheduled_expiration_status, inverse_of: :status, dependent: :destroy
has_one :circle_status, inverse_of: :status, dependent: :destroy has_one :circle_status, inverse_of: :status, dependent: :destroy
has_many :list_status, inverse_of: :status, dependent: :destroy
validates :uri, uniqueness: true, presence: true, unless: :local? validates :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? } validates :text, presence: true, unless: -> { with_media? || reblog? }

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::ListSerializer < ActiveModel::Serializer class REST::ListSerializer < ActiveModel::Serializer
attributes :id, :title, :replies_policy, :exclusive attributes :id, :title, :replies_policy, :exclusive, :notify
def id def id
object.id.to_s object.id.to_s

View file

@ -8,13 +8,14 @@ class REST::NotificationSerializer < ActiveModel::Serializer
belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer
belongs_to :emoji_reaction, if: :emoji_reaction_type?, serializer: REST::NotifyEmojiReactionSerializer belongs_to :emoji_reaction, if: :emoji_reaction_type?, serializer: REST::NotifyEmojiReactionSerializer
belongs_to :account_warning, if: :warning_type?, serializer: REST::AccountWarningSerializer belongs_to :account_warning, if: :warning_type?, serializer: REST::AccountWarningSerializer
belongs_to :list, if: :list_status_type?, serializer: REST::ListSerializer
def id def id
object.id.to_s object.id.to_s
end end
def status_type? def status_type?
[:favourite, :emoji_reaction, :reaction, :reblog, :status_reference, :status, :mention, :poll, :update].include?(object.type) [:favourite, :emoji_reaction, :reaction, :reblog, :status_reference, :status, :list_status, :mention, :poll, :update].include?(object.type)
end end
def report_type? def report_type?
@ -28,4 +29,12 @@ class REST::NotificationSerializer < ActiveModel::Serializer
def emoji_reaction_type? def emoji_reaction_type?
object.type == :emoji_reaction object.type == :emoji_reaction
end end
def list_status_type?
object.type == :list_status
end
def list
object.list_status.list
end
end end

View file

@ -10,6 +10,8 @@ class NotifyService < BaseService
poll poll
emoji_reaction emoji_reaction
status_reference status_reference
status
list_status
warning warning
).freeze ).freeze

View file

@ -38,6 +38,7 @@ class FeedInsertWorker
else else
perform_push perform_push
perform_notify if notify? perform_notify if notify?
perform_notify_for_list if notify_for_list?
end end
end end
@ -58,6 +59,12 @@ class FeedInsertWorker
Follow.find_by(account: @follower, target_account: @status.account)&.notify? Follow.find_by(account: @follower, target_account: @status.account)&.notify?
end end
def notify_for_list?
return false unless @type == :list
@list.notify?
end
def perform_push def perform_push
if @antenna.nil? || @antenna.insert_feeds if @antenna.nil? || @antenna.insert_feeds
case @type case @type
@ -90,6 +97,11 @@ class FeedInsertWorker
LocalNotificationWorker.perform_async(@follower.id, @status.id, 'Status', 'status') LocalNotificationWorker.perform_async(@follower.id, @status.id, 'Status', 'status')
end end
def perform_notify_for_list
list_status = ListStatus.create!(list: @list, status: @status)
LocalNotificationWorker.perform_async(@list.account_id, list_status.id, 'ListStatus', 'list_status')
end
def update? def update?
@options[:update] @options[:update]
end end

View file

@ -25,6 +25,7 @@ class Scheduler::VacuumScheduler
applications_vacuum, applications_vacuum,
feeds_vacuum, feeds_vacuum,
imports_vacuum, imports_vacuum,
list_statuses_vacuum,
] ]
end end
@ -32,6 +33,10 @@ class Scheduler::VacuumScheduler
Vacuum::StatusesVacuum.new(content_retention_policy.content_cache_retention_period) Vacuum::StatusesVacuum.new(content_retention_policy.content_cache_retention_period)
end end
def list_statuses_vacuum
Vacuum::ListStatusesVacuum.new
end
def media_attachments_vacuum def media_attachments_vacuum
Vacuum::MediaAttachmentsVacuum.new(content_retention_policy.media_cache_retention_period) Vacuum::MediaAttachmentsVacuum.new(content_retention_policy.media_cache_retention_period)
end end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class CreateListStatuses < ActiveRecord::Migration[7.1]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def change
safety_assured do
create_table :list_statuses do |t|
t.belongs_to :list, null: false, foreign_key: { on_delete: :cascade }
t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
end
add_index :list_statuses, [:list_id, :status_id], unique: true
end
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class AddNotifyToList < ActiveRecord::Migration[7.0]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def change
safety_assured do
add_column_with_default :lists, :notify, :boolean, default: false, allow_null: false
end
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_10_23_083359) do ActiveRecord::Schema[7.1].define(version: 2023_10_28_005948) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -761,6 +761,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_23_083359) do
t.index ["list_id", "account_id"], name: "index_list_accounts_on_list_id_and_account_id" t.index ["list_id", "account_id"], name: "index_list_accounts_on_list_id_and_account_id"
end end
create_table "list_statuses", force: :cascade do |t|
t.bigint "list_id", null: false
t.bigint "status_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["list_id", "status_id"], name: "index_list_statuses_on_list_id_and_status_id", unique: true
t.index ["list_id"], name: "index_list_statuses_on_list_id"
t.index ["status_id"], name: "index_list_statuses_on_status_id"
end
create_table "lists", force: :cascade do |t| create_table "lists", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.string "title", default: "", null: false t.string "title", default: "", null: false
@ -768,6 +778,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_23_083359) do
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.integer "replies_policy", default: 0, null: false t.integer "replies_policy", default: 0, null: false
t.boolean "exclusive", default: false, null: false t.boolean "exclusive", default: false, null: false
t.boolean "notify", default: false, null: false
t.index ["account_id"], name: "index_lists_on_account_id" t.index ["account_id"], name: "index_lists_on_account_id"
end end
@ -1480,6 +1491,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_23_083359) do
add_foreign_key "list_accounts", "follow_requests", on_delete: :cascade add_foreign_key "list_accounts", "follow_requests", on_delete: :cascade
add_foreign_key "list_accounts", "follows", on_delete: :cascade add_foreign_key "list_accounts", "follows", on_delete: :cascade
add_foreign_key "list_accounts", "lists", on_delete: :cascade add_foreign_key "list_accounts", "lists", on_delete: :cascade
add_foreign_key "list_statuses", "lists", on_delete: :cascade
add_foreign_key "list_statuses", "statuses", on_delete: :cascade
add_foreign_key "lists", "accounts", on_delete: :cascade add_foreign_key "lists", "accounts", on_delete: :cascade
add_foreign_key "login_activities", "users", on_delete: :cascade add_foreign_key "login_activities", "users", on_delete: :cascade
add_foreign_key "markers", "users", on_delete: :cascade add_foreign_key "markers", "users", on_delete: :cascade

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
Fabricator(:list_status) do
list { Fabricate.build(:list) }
status
end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Vacuum::ListStatusesVacuum do
subject { described_class.new }
describe '#perform' do
let!(:local_status_old) { Fabricate(:status, created_at: 2.days.ago) }
let!(:local_status_recent) { Fabricate(:status, created_at: 5.hours.ago) }
let!(:list_status_old) { Fabricate(:list_status, status: local_status_old, created_at: local_status_old.created_at) }
let!(:list_status_recent) { Fabricate(:list_status, status: local_status_recent, created_at: local_status_recent.created_at) }
before do
subject.perform
end
it 'deletes old list status' do
expect { list_status_old.reload }.to raise_error ActiveRecord::RecordNotFound
end
it 'does not delete recent status' do
expect { list_status_recent.reload }.to_not raise_error
end
it 'statuses are remain' do
expect { local_status_old }.to_not raise_error
end
end
end

View file

@ -19,6 +19,7 @@ RSpec.describe 'Lists' do
Fabricate(:list, account: user.account, title: 'second list', replies_policy: :list), Fabricate(:list, account: user.account, title: 'second list', replies_policy: :list),
Fabricate(:list, account: user.account, title: 'third list', replies_policy: :none), Fabricate(:list, account: user.account, title: 'third list', replies_policy: :none),
Fabricate(:list, account: user.account, title: 'fourth list', exclusive: true), Fabricate(:list, account: user.account, title: 'fourth list', exclusive: true),
Fabricate(:list, account: user.account, title: 'fourth list', notify: true),
] ]
end end
@ -30,6 +31,7 @@ RSpec.describe 'Lists' do
replies_policy: list.replies_policy, replies_policy: list.replies_policy,
exclusive: list.exclusive, exclusive: list.exclusive,
antennas: list.antennas, antennas: list.antennas,
notify: list.notify,
} }
end end
end end
@ -67,6 +69,7 @@ RSpec.describe 'Lists' do
replies_policy: list.replies_policy, replies_policy: list.replies_policy,
exclusive: list.exclusive, exclusive: list.exclusive,
antennas: list.antennas, antennas: list.antennas,
notify: list.notify,
}) })
end end
@ -149,6 +152,7 @@ RSpec.describe 'Lists' do
replies_policy: list.replies_policy, replies_policy: list.replies_policy,
exclusive: list.exclusive, exclusive: list.exclusive,
antennas: list.antennas, antennas: list.antennas,
notify: list.notify,
}) })
end end

View file

@ -5,6 +5,10 @@ require 'rails_helper'
describe FeedInsertWorker do describe FeedInsertWorker do
subject { described_class.new } subject { described_class.new }
def notify?(account, type, activity_id)
Notification.exists?(account: account, type: type, activity_id: activity_id)
end
describe 'perform' do describe 'perform' do
let(:follower) { Fabricate(:account) } let(:follower) { Fabricate(:account) }
let(:status) { Fabricate(:status) } let(:status) { Fabricate(:status) }
@ -48,5 +52,43 @@ describe FeedInsertWorker do
expect(instance).to have_received(:push_to_home).with(follower, status, update: nil) expect(instance).to have_received(:push_to_home).with(follower, status, update: nil)
end end
end end
context 'with notification' do
it 'skips notification when unset' do
subject.perform(status.id, follower.id)
expect(notify?(follower, 'status', status.id)).to be false
end
it 'pushes notification when read status is set' do
Fabricate(:follow, account: follower, target_account: status.account, notify: true)
subject.perform(status.id, follower.id)
expect(notify?(follower, 'status', status.id)).to be true
end
it 'skips notification when the account is registered list but not notify' do
follower.follow!(status.account)
list = Fabricate(:list, account: follower)
Fabricate(:list_account, list: list, account: status.account)
subject.perform(status.id, list.id, 'list')
list_status = ListStatus.find_by(list: list, status: status)
expect(list_status).to be_nil
end
it 'pushes notification when the account is registered list' do
follower.follow!(status.account)
list = Fabricate(:list, account: follower, notify: true)
Fabricate(:list_account, list: list, account: status.account)
subject.perform(status.id, list.id, 'list')
list_status = ListStatus.find_by(list: list, status: status)
expect(list_status).to_not be_nil
expect(notify?(follower, 'list_status', list_status.id)).to be true
end
end
end end
end end