Add circle posts history support (#18)

* Wip: make web backend

* Wip: keep statuses if edit circle

* Wip: Add circle history page and record circle posts

* Add circle post to history in web ui when post

* Add test
This commit is contained in:
KMY(雪あすか) 2023-09-24 13:01:09 +09:00 committed by GitHub
parent 0a42f4b7e2
commit df3b3f4185
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 544 additions and 15 deletions

View file

@ -0,0 +1,66 @@
# frozen_string_literal: true
class Api::V1::Circles::StatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user!
before_action :set_circle
after_action :insert_pagination_headers, only: :show
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer
end
private
def set_circle
@circle = current_account.circles.find(params[:circle_id])
end
def load_statuses
if unlimited?
@circle.statuses.includes(:status_stat).all
else
@circle.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
end
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
return if unlimited?
api_v1_circle_statuses_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
return if unlimited?
api_v1_circle_statuses_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
def records_continue?
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
def unlimited?
params[:limit] == '0'
end
end

View file

@ -1,7 +1,7 @@
import api from '../api';
import api, { getLinks } from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST';
export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS';
@ -50,6 +50,14 @@ export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_RE
export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS';
export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL';
export const CIRCLE_STATUSES_FETCH_REQUEST = 'CIRCLE_STATUSES_FETCH_REQUEST';
export const CIRCLE_STATUSES_FETCH_SUCCESS = 'CIRCLE_STATUSES_FETCH_SUCCESS';
export const CIRCLE_STATUSES_FETCH_FAIL = 'CIRCLE_STATUSES_FETCH_FAIL';
export const CIRCLE_STATUSES_EXPAND_REQUEST = 'CIRCLE_STATUSES_EXPAND_REQUEST';
export const CIRCLE_STATUSES_EXPAND_SUCCESS = 'CIRCLE_STATUSES_EXPAND_SUCCESS';
export const CIRCLE_STATUSES_EXPAND_FAIL = 'CIRCLE_STATUSES_EXPAND_FAIL';
export const fetchCircle = id => (dispatch, getState) => {
if (getState().getIn(['circles', id])) {
return;
@ -370,3 +378,89 @@ export const removeFromCircleAdder = circleId => (dispatch, getState) => {
dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId'])));
};
export function fetchCircleStatuses(circleId) {
return (dispatch, getState) => {
if (getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) {
return;
}
dispatch(fetchCircleStatusesRequest(circleId));
api(getState).get(`/api/v1/circles/${circleId}/statuses`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchCircleStatusesSuccess(circleId, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchCircleStatusesFail(circleId, error));
});
};
}
export function fetchCircleStatusesRequest(id) {
return {
type: CIRCLE_STATUSES_FETCH_REQUEST,
id,
};
}
export function fetchCircleStatusesSuccess(id, statuses, next) {
return {
type: CIRCLE_STATUSES_FETCH_SUCCESS,
id,
statuses,
next,
};
}
export function fetchCircleStatusesFail(id, error) {
return {
type: CIRCLE_STATUSES_FETCH_FAIL,
id,
error,
};
}
export function expandCircleStatuses(circleId) {
return (dispatch, getState) => {
const url = getState().getIn(['circles', circleId, 'statuses', 'next'], null);
if (url === null || getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) {
return;
}
dispatch(expandCircleStatusesRequest(circleId));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandCircleStatusesSuccess(circleId, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandCircleStatusesFail(circleId, error));
});
};
}
export function expandCircleStatusesRequest(id) {
return {
type: CIRCLE_STATUSES_EXPAND_REQUEST,
id,
};
}
export function expandCircleStatusesSuccess(id, statuses, next) {
return {
type: CIRCLE_STATUSES_EXPAND_SUCCESS,
id,
statuses,
next,
};
}
export function expandCircleStatusesFail(id, error) {
return {
type: CIRCLE_STATUSES_EXPAND_FAIL,
id,
error,
};
}

View file

@ -28,6 +28,8 @@ export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_WITH_CIRCLE_SUCCESS = 'COMPOSE_WITH_CIRCLE_SUCCESS';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
@ -174,6 +176,7 @@ export function submitCompose(routerHistory) {
const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null);
const circleId = getState().getIn(['compose', 'circle_id'], null);
if ((!status || !status.length) && media.size === 0) {
return;
@ -253,6 +256,10 @@ export function submitCompose(routerHistory) {
insertIfOnline(`account:${response.data.account.id}`);
}
if (statusId === null && circleId !== null && circleId !== 0) {
dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId));
}
dispatch(showAlert({
message: statusId === null ? messages.published : messages.saved,
action: messages.open,
@ -278,6 +285,14 @@ export function submitComposeSuccess(status) {
};
}
export function submitComposeWithCircleSuccess(status, circleId) {
return {
type: COMPOSE_WITH_CIRCLE_SUCCESS,
status,
circleId,
}
}
export function submitComposeFail(error) {
return {
type: COMPOSE_SUBMIT_FAIL,

View file

@ -0,0 +1,182 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { deleteCircle, expandCircleStatuses, fetchCircle, fetchCircleStatuses } from 'mastodon/actions/circles';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column';
import { getCircleStatusList } from 'mastodon/selectors';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' },
deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' },
heading: { id: 'column.circles', defaultMessage: 'Circles' },
});
const mapStateToProps = (state, { params }) => ({
circle: state.getIn(['circles', params.id]),
statusIds: getCircleStatusList(state, params.id),
isLoading: state.getIn(['circles', params.id, 'isLoading'], true),
isEditing: state.getIn(['circleEditor', 'circleId']) === params.id,
hasMore: !!state.getIn(['circles', params.id, 'next']),
});
class CircleStatuses extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
circle: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchCircle(this.props.params.id));
this.props.dispatch(fetchCircleStatuses(this.props.params.id));
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('CIRCLE_STATUSES', { id: this.props.params.id }));
this.context.router.history.push('/');
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'CIRCLE_EDITOR',
modalProps: { circleId: this.props.params.id },
}));
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteCircle(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.context.router.history.push('/circles');
}
},
},
}));
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandCircleStatuses());
}, 300, { leading: true });
render () {
const { intl, circle, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
if (typeof circle === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (circle === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
const emptyMessage = <FormattedMessage id='empty_column.circle_statuses' defaultMessage="You don't have any circle posts yet. When you post one as circle, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='user-circle'
title={circle.get('title')}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings__row column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' /> <FormattedMessage id='circles.edit' defaultMessage='Edit circle' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' /> <FormattedMessage id='circles.delete' defaultMessage='Delete circle' />
</button>
</div>
</ColumnHeader>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`circle_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(CircleStatuses));

View file

@ -13,7 +13,6 @@ import { fetchCircles, deleteCircle } from 'mastodon/actions/circles';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
@ -106,10 +105,7 @@ class Circles extends ImmutablePureComponent {
bindToDocument={!multiColumn}
>
{circles.map(circle =>
(<div key={circle.get('id')} className='circle-item'>
<ColumnLink to={`#`} data-id={circle.get('id')} onClick={this.handleEditClick} icon='user-circle' text={circle.get('title')} />
<IconButton icon='trash' data_id={circle.get('id')} onClick={this.handleRemoveClick} />
</div>)
<ColumnLink key={circle.get('id')} to={`/circles/${circle.get('id')}`} icon='user-circle' text={circle.get('title')} />,
)}
</ScrollableList>

View file

@ -24,6 +24,7 @@ import {
BookmarkCategoryStatuses,
AntennaSetting,
AntennaTimeline,
CircleStatuses,
} from '../util/async-components';
import BundleColumnError from './bundle_column_error';
@ -45,6 +46,7 @@ const componentMap = {
'EMOJI_REACTIONS': EmojiReactedStatuses,
'BOOKMARKS': BookmarkedStatuses,
'BOOKMARKS_EX': BookmarkCategoryStatuses,
'CIRCLE_STATUSES': CircleStatuses,
'ANTENNA': AntennaSetting,
'ANTENNA_TIMELINE': AntennaTimeline,
'LIST': ListTimeline,

View file

@ -65,6 +65,7 @@ import {
Lists,
Antennas,
Circles,
CircleStatuses,
AntennaSetting,
Directory,
Explore,
@ -259,6 +260,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/antennasw' component={Antennas} content={children} />
<WrappedRoute path='/circles/:id' component={CircleStatuses} content={children} />
<WrappedRoute path='/circles' component={Circles} content={children} />
<Route component={BundleColumnError} />

View file

@ -54,6 +54,10 @@ export function Circles () {
return import(/* webpackChunkName: "features/circles" */'../../circles');
}
export function CircleStatuses () {
return import(/* webpackChunkName: "features/circle_statuses" */'../../circle_statuses');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}

View file

@ -1,4 +1,4 @@
import { List as ImmutableList, fromJS } from 'immutable';
import { List as ImmutableList, Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import {
CIRCLE_FETCH_SUCCESS,
@ -7,31 +7,106 @@ import {
CIRCLE_CREATE_SUCCESS,
CIRCLE_UPDATE_SUCCESS,
CIRCLE_DELETE_SUCCESS,
CIRCLE_STATUSES_FETCH_REQUEST,
CIRCLE_STATUSES_FETCH_SUCCESS,
CIRCLE_STATUSES_FETCH_FAIL,
CIRCLE_STATUSES_EXPAND_REQUEST,
CIRCLE_STATUSES_EXPAND_SUCCESS,
CIRCLE_STATUSES_EXPAND_FAIL,
} from '../actions/circles';
import {
COMPOSE_WITH_CIRCLE_SUCCESS,
} from '../actions/compose';
const initialState = ImmutableList();
const normalizeList = (state, circle) => state.set(circle.id, fromJS(circle));
const initialStatusesState = ImmutableMap({
items: ImmutableList(),
isLoading: false,
next: null,
});
const normalizeLists = (state, circles) => {
const normalizeCircle = (state, circle) => {
const old = state.get(circle.id);
if (old === false) {
return state;
}
let s = state.set(circle.id, fromJS(circle));
if (old) {
s = s.setIn([circle.id, 'statuses'], old.get('statuses'));
} else {
s = s.setIn([circle.id, 'statuses'], initialStatusesState);
}
return s;
};
const normalizeCircles = (state, circles) => {
circles.forEach(circle => {
state = normalizeList(state, circle);
state = normalizeCircle(state, circle);
});
return state;
};
const normalizeCircleStatuses = (state, circleId, statuses, next) => {
return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('loaded', true);
map.set('isLoading', false);
map.set('items', ImmutableOrderedSet(statuses.map(item => item.id)));
}));
};
const appendToCircleStatuses = (state, circleId, statuses, next) => {
return appendToCircleStatusesById(state, circleId, statuses.map(item => item.id), next);
};
const appendToCircleStatusesById = (state, circleId, statuses, next) => {
return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => {
if (typeof next !== 'undefined') {
map.set('next', next);
}
map.set('isLoading', false);
if (map.get('items')) {
map.set('items', map.get('items').union(statuses));
}
}));
};
const prependToCircleStatusById = (state, circleId, statusId) => {
if (!state.get(circleId)) return state;
return state.updateIn([circleId], circle => circle.withMutations(map => {
if (map.getIn(['statuses', 'items'])) {
map.updateIn(['statuses', 'items'], list => ImmutableOrderedSet([statusId]).union(list));
}
}));
}
export default function circles(state = initialState, action) {
switch(action.type) {
case CIRCLE_FETCH_SUCCESS:
case CIRCLE_CREATE_SUCCESS:
case CIRCLE_UPDATE_SUCCESS:
return normalizeList(state, action.circle);
return normalizeCircle(state, action.circle);
case CIRCLES_FETCH_SUCCESS:
return normalizeLists(state, action.circles);
return normalizeCircles(state, action.circles);
case CIRCLE_DELETE_SUCCESS:
case CIRCLE_FETCH_FAIL:
return state.set(action.id, false);
case CIRCLE_STATUSES_FETCH_REQUEST:
case CIRCLE_STATUSES_EXPAND_REQUEST:
return state.setIn([action.id, 'statuses', 'isLoading'], true);
case CIRCLE_STATUSES_FETCH_FAIL:
case CIRCLE_STATUSES_EXPAND_FAIL:
return state.setIn([action.id, 'statuses', 'isLoading'], false);
case CIRCLE_STATUSES_FETCH_SUCCESS:
return normalizeCircleStatuses(state, action.id, action.statuses, action.next);
case CIRCLE_STATUSES_EXPAND_SUCCESS:
return appendToCircleStatuses(state, action.id, action.statuses, action.next);
case COMPOSE_WITH_CIRCLE_SUCCESS:
return prependToCircleStatusById(state, action.circleId, action.status.id);
default:
return state;
}

View file

@ -135,3 +135,7 @@ export const getStatusList = createSelector([
export const getBookmarkCategoryStatusList = createSelector([
(state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']),
], (items) => items ? items.toList() : ImmutableList());
export const getCircleStatusList = createSelector([
(state, circleId) => state.getIn(['circles', circleId, 'statuses', 'items']),
], (items) => items ? items.toList() : ImmutableList());

View file

@ -20,6 +20,8 @@ class Circle < ApplicationRecord
has_many :circle_accounts, inverse_of: :circle, dependent: :destroy
has_many :accounts, through: :circle_accounts
has_many :circle_statuses, inverse_of: :circle, dependent: :destroy
has_many :statuses, through: :circle_statuses
validates :title, presence: true

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: circle_statuses
#
# id :bigint(8) not null, primary key
# circle_id :bigint(8)
# status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class CircleStatus < ApplicationRecord
belongs_to :circle
belongs_to :status
validates :status, uniqueness: { scope: :circle }
validate :account_own_status
private
def account_own_status
errors.add(:status_id, :invalid) unless status.account_id == circle.account_id
end
end

View file

@ -106,6 +106,7 @@ class Status < ApplicationRecord
has_one :poll, inverse_of: :status, dependent: :destroy
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
validates :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? }

View file

@ -112,5 +112,7 @@ class ProcessMentionsService < BaseService
@circle.accounts.find_each do |target_account|
@current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id)
end
@circle.statuses << @status
end
end

View file

@ -18,7 +18,7 @@ Rails.application.routes.draw do
/lists/(*any)
/antennasw/(*any)
/antennast/(*any)
/circles
/circles/(*any)
/notifications
/favourites
/emoji_reactions

View file

@ -226,6 +226,7 @@ namespace :api, format: false do
resources :circles, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts'
resource :statuses, only: [:show], controller: 'circles/statuses'
end
resources :bookmark_categories, only: [:index, :create, :show, :update, :destroy] do

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class CreateCircleStatuses < ActiveRecord::Migration[7.0]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def change
safety_assured do
create_table :circle_statuses do |t|
t.belongs_to :circle, null: true, 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 :circle_statuses, [:circle_id, :status_id], unique: true
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_09_19_232836) do
ActiveRecord::Schema[7.0].define(version: 2023_09_23_103430) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -447,6 +447,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_19_232836) do
t.index ["follow_id"], name: "index_circle_accounts_on_follow_id"
end
create_table "circle_statuses", force: :cascade do |t|
t.bigint "circle_id"
t.bigint "status_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["circle_id", "status_id"], name: "index_circle_statuses_on_circle_id_and_status_id", unique: true
t.index ["circle_id"], name: "index_circle_statuses_on_circle_id"
t.index ["status_id"], name: "index_circle_statuses_on_status_id"
end
create_table "circles", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "title", default: "", null: false
@ -1414,6 +1424,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_19_232836) do
add_foreign_key "circle_accounts", "accounts", on_delete: :cascade
add_foreign_key "circle_accounts", "circles", on_delete: :cascade
add_foreign_key "circle_accounts", "follows", on_delete: :cascade
add_foreign_key "circle_statuses", "circles", on_delete: :cascade
add_foreign_key "circle_statuses", "statuses", on_delete: :cascade
add_foreign_key "circles", "accounts", on_delete: :cascade
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade

View file

@ -103,4 +103,27 @@ RSpec.describe ProcessMentionsService, type: :service do
end
end
end
context 'with circle post' do
let(:status) { Fabricate(:status, account: account) }
let(:circle) { Fabricate(:circle, account: account) }
let(:follower) { Fabricate(:account) }
let(:other) { Fabricate(:account) }
before do
follower.follow!(account)
other.follow!(account)
circle.accounts << follower
described_class.new.call(status, limited_type: :circle, circle: circle)
end
it 'remains circle post on history' do
expect(CircleStatus.exists?(circle_id: circle.id, status_id: status.id)).to be true
end
it 'post is delivered to circle members' do
expect(status.mentioned_accounts.count).to eq 1
expect(status.mentioned_accounts.first.id).to eq follower.id
end
end
end