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
def list_params
params.permit(:title, :replies_policy, :exclusive)
params.permit(:title, :replies_policy, :exclusive, :notify)
end
end

View file

@ -151,10 +151,15 @@ export const createListFail = 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));
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));
if (shouldReset) {

View file

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

View file

@ -154,13 +154,19 @@ class ListTimeline extends PureComponent {
handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, target.value));
dispatch(updateList(id, undefined, false, undefined, target.value, undefined));
};
onExclusiveToggle = ({ target }) => {
const { dispatch } = this.props;
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 () {
@ -170,6 +176,7 @@ class ListTimeline extends PureComponent {
const title = list ? list.get('title') : id;
const replies_policy = list ? list.get('replies_policy') : undefined;
const isExclusive = list ? list.get('exclusive') : undefined;
const isNotify = list ? list.get('notify') : undefined;
const antennas = list ? (list.get('antennas')?.toArray() || []) : [];
if (typeof list === 'undefined') {
@ -216,6 +223,13 @@ class ListTimeline extends PureComponent {
</label>
</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 && (
<div role='group' aria-labelledby={`list-${id}-replies-policy`}>
<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 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'>
<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 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 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 PersonAddIcon } from '@material-symbols/svg-600/outlined/person_add-fill.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' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
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' },
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' },
@ -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) {
const { intl, unread, status } = this.props;
@ -531,6 +569,10 @@ class Notification extends ImmutablePureComponent {
return this.renderStatusReference(notification, link);
case 'status':
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':
return this.renderUpdate(notification, link);
case 'poll':

View file

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

View file

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

View file

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

View file

@ -42,6 +42,7 @@ const initialState = ImmutableMap({
mention: false,
poll: false,
status: false,
list_status: false,
update: false,
emoji_reaction: false,
status_reference: false,
@ -66,6 +67,7 @@ const initialState = ImmutableMap({
mention: true,
poll: true,
status: true,
list_status: true,
update: true,
emoji_reaction: true,
status_reference: true,
@ -81,6 +83,7 @@ const initialState = ImmutableMap({
mention: true,
poll: true,
status: true,
list_status: true,
update: true,
emoji_reaction: 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
# replies_policy :integer default("list"), not null
# exclusive :boolean default(FALSE), not null
# notify :boolean default(FALSE), not null
#
class List < ApplicationRecord
@ -25,6 +26,8 @@ class List < ApplicationRecord
has_many :list_accounts, inverse_of: :list, dependent: :destroy
has_many :accounts, through: :list_accounts
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

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

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class REST::ListSerializer < ActiveModel::Serializer
attributes :id, :title, :replies_policy, :exclusive
attributes :id, :title, :replies_policy, :exclusive, :notify
def id
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 :emoji_reaction, if: :emoji_reaction_type?, serializer: REST::NotifyEmojiReactionSerializer
belongs_to :account_warning, if: :warning_type?, serializer: REST::AccountWarningSerializer
belongs_to :list, if: :list_status_type?, serializer: REST::ListSerializer
def id
object.id.to_s
end
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
def report_type?
@ -28,4 +29,12 @@ class REST::NotificationSerializer < ActiveModel::Serializer
def emoji_reaction_type?
object.type == :emoji_reaction
end
def list_status_type?
object.type == :list_status
end
def list
object.list_status.list
end
end

View file

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

View file

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

View file

@ -25,6 +25,7 @@ class Scheduler::VacuumScheduler
applications_vacuum,
feeds_vacuum,
imports_vacuum,
list_statuses_vacuum,
]
end
@ -32,6 +33,10 @@ class Scheduler::VacuumScheduler
Vacuum::StatusesVacuum.new(content_retention_policy.content_cache_retention_period)
end
def list_statuses_vacuum
Vacuum::ListStatusesVacuum.new
end
def media_attachments_vacuum
Vacuum::MediaAttachmentsVacuum.new(content_retention_policy.media_cache_retention_period)
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.
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
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"
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|
t.bigint "account_id", 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.integer "replies_policy", default: 0, 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"
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", "follows", 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 "login_activities", "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: 'third list', replies_policy: :none),
Fabricate(:list, account: user.account, title: 'fourth list', exclusive: true),
Fabricate(:list, account: user.account, title: 'fourth list', notify: true),
]
end
@ -30,6 +31,7 @@ RSpec.describe 'Lists' do
replies_policy: list.replies_policy,
exclusive: list.exclusive,
antennas: list.antennas,
notify: list.notify,
}
end
end
@ -67,6 +69,7 @@ RSpec.describe 'Lists' do
replies_policy: list.replies_policy,
exclusive: list.exclusive,
antennas: list.antennas,
notify: list.notify,
})
end
@ -149,6 +152,7 @@ RSpec.describe 'Lists' do
replies_policy: list.replies_policy,
exclusive: list.exclusive,
antennas: list.antennas,
notify: list.notify,
})
end

View file

@ -5,6 +5,10 @@ require 'rails_helper'
describe FeedInsertWorker do
subject { described_class.new }
def notify?(account, type, activity_id)
Notification.exists?(account: account, type: type, activity_id: activity_id)
end
describe 'perform' do
let(:follower) { Fabricate(:account) }
let(:status) { Fabricate(:status) }
@ -48,5 +52,43 @@ describe FeedInsertWorker do
expect(instance).to have_received(:push_to_home).with(follower, status, update: nil)
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