diff --git a/app/controllers/api/v1/circles/statuses_controller.rb b/app/controllers/api/v1/circles/statuses_controller.rb
new file mode 100644
index 00000000000000..84e9b05543a7e2
--- /dev/null
+++ b/app/controllers/api/v1/circles/statuses_controller.rb
@@ -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
diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js
index 6a52e541c9a440..a497b27d5db592 100644
--- a/app/javascript/mastodon/actions/circles.js
+++ b/app/javascript/mastodon/actions/circles.js
@@ -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,
+ };
+}
+
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 1f682d1321b0c6..efe4c564066dc8 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -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,
diff --git a/app/javascript/mastodon/features/circle_statuses/index.jsx b/app/javascript/mastodon/features/circle_statuses/index.jsx
new file mode 100644
index 00000000000000..2896455ab5c6a9
--- /dev/null
+++ b/app/javascript/mastodon/features/circle_statuses/index.jsx
@@ -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 (
+
+
+
+
+
+ );
+ } else if (circle === false) {
+ return (
+
+ );
+ }
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.heading)}
+
+
+
+ );
+ }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(CircleStatuses));
diff --git a/app/javascript/mastodon/features/circles/index.jsx b/app/javascript/mastodon/features/circles/index.jsx
index 1b83876827fd0d..1cd3ae417fdbf1 100644
--- a/app/javascript/mastodon/features/circles/index.jsx
+++ b/app/javascript/mastodon/features/circles/index.jsx
@@ -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 =>
- (
-
-
-
)
+ ,
)}
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx
index af549d21edd749..280330f5309cf2 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.jsx
+++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx
@@ -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,
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
index f23fdc66fc389c..ce6196a8824e6f 100644
--- a/app/javascript/mastodon/features/ui/index.jsx
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -65,6 +65,7 @@ import {
Lists,
Antennas,
Circles,
+ CircleStatuses,
AntennaSetting,
Directory,
Explore,
@@ -259,6 +260,7 @@ class SwitchingColumnsArea extends PureComponent {
+
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 81d83ec818354f..10daf553574923 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -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');
}
diff --git a/app/javascript/mastodon/reducers/circles.js b/app/javascript/mastodon/reducers/circles.js
index 805d7f186addf5..96237219d9065c 100644
--- a/app/javascript/mastodon/reducers/circles.js
+++ b/app/javascript/mastodon/reducers/circles.js
@@ -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 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 normalizeLists = (state, circles) => {
+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;
}
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 6d5adbeb48f5a7..f6398e93240600 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -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());
diff --git a/app/models/circle.rb b/app/models/circle.rb
index cb58b97bcea8be..cec49df88c83bf 100644
--- a/app/models/circle.rb
+++ b/app/models/circle.rb
@@ -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
diff --git a/app/models/circle_status.rb b/app/models/circle_status.rb
new file mode 100644
index 00000000000000..b394a4f9270904
--- /dev/null
+++ b/app/models/circle_status.rb
@@ -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
diff --git a/app/models/status.rb b/app/models/status.rb
index b532f58a3083a6..98b274ce046cc9 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -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? }
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 493facbea773c5..0a5b48a9d9e654 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -112,5 +112,7 @@ def process_circle!
@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
diff --git a/config/routes.rb b/config/routes.rb
index 3db26c7f8c0439..ed5995b2f4042b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -18,7 +18,7 @@
/lists/(*any)
/antennasw/(*any)
/antennast/(*any)
- /circles
+ /circles/(*any)
/notifications
/favourites
/emoji_reactions
diff --git a/config/routes/api.rb b/config/routes/api.rb
index 9bf466ddab5f9f..961cd43ad35198 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -226,6 +226,7 @@
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
diff --git a/db/migrate/20230923103430_create_circle_statuses.rb b/db/migrate/20230923103430_create_circle_statuses.rb
new file mode 100644
index 00000000000000..9c14bb808ab8b9
--- /dev/null
+++ b/db/migrate/20230923103430_create_circle_statuses.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index c5dcfc6a330f56..e17320cdaa0637 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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 @@
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 @@
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
diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb
index 0db73c41fabdbb..39bb355577ebf4 100644
--- a/spec/services/process_mentions_service_spec.rb
+++ b/spec/services/process_mentions_service_spec.rb
@@ -103,4 +103,27 @@
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