Skip to content
Permalink
Browse files

Add bookmarks (#7107)

* Add backend support for bookmarks

Bookmarks behave like favourites, except they aren't shared with other
users and do not have an associated counter.

* Add spec for bookmark endpoints

* Add front-end support for bookmarks

* Introduce OAuth scopes for bookmarks

* Add bookmarks to archive takeout

* Fix migration

* Coding style fixes

* Fix rebase issue

* Update bookmarked_statuses to latest UI changes

* Update bookmark actions to properly reflect status changes in state

* Add bookmarks item to single-column layout

* Make active bookmarks red
  • Loading branch information
ThibG authored and Gargron committed Nov 13, 2019
1 parent afb398b commit dfea7368c934f600bd0b6b93b4a6c008a4e265b0
Showing with 712 additions and 1 deletion.
  1. +67 −0 app/controllers/api/v1/bookmarks_controller.rb
  2. +39 −0 app/controllers/api/v1/statuses/bookmarks_controller.rb
  3. +90 −0 app/javascript/mastodon/actions/bookmarks.js
  4. +80 −0 app/javascript/mastodon/actions/interactions.js
  5. +7 −0 app/javascript/mastodon/components/status_action_bar.js
  6. +10 −0 app/javascript/mastodon/containers/status_container.js
  7. +104 −0 app/javascript/mastodon/features/bookmarked_statuses/index.js
  8. +3 −1 app/javascript/mastodon/features/getting_started/index.js
  9. +7 −0 app/javascript/mastodon/features/status/components/action_bar.js
  10. +11 −0 app/javascript/mastodon/features/status/index.js
  11. +2 −0 app/javascript/mastodon/features/ui/components/columns_area.js
  12. +1 −0 app/javascript/mastodon/features/ui/components/navigation_panel.js
  13. +2 −0 app/javascript/mastodon/features/ui/index.js
  14. +4 −0 app/javascript/mastodon/features/ui/util/async-components.js
  15. +29 −0 app/javascript/mastodon/reducers/status_lists.js
  16. +6 −0 app/javascript/mastodon/reducers/statuses.js
  17. +4 −0 app/javascript/styles/mastodon/components.scss
  18. +2 −0 app/javascript/styles/mastodon/variables.scss
  19. +26 −0 app/models/bookmark.rb
  20. +1 −0 app/models/concerns/account_associations.rb
  21. +4 −0 app/models/concerns/account_interactions.rb
  22. +5 −0 app/models/status.rb
  23. +2 −0 app/presenters/status_relationships_presenter.rb
  24. +9 −0 app/serializers/rest/status_serializer.rb
  25. +21 −0 app/services/backup_service.rb
  26. +2 −0 config/initializers/doorkeeper.rb
  27. +2 −0 config/locales/doorkeeper.en.yml
  28. +4 −0 config/routes.rb
  29. +17 −0 db/migrate/20180831171112_create_bookmarks.rb
  30. +12 −0 db/schema.rb
  31. +78 −0 spec/controllers/api/v1/bookmarks_controller_spec.rb
  32. +57 −0 spec/controllers/api/v1/statuses/bookmarks_controller_spec.rb
  33. +4 −0 spec/fabricators/bookmark_fabricator.rb
@@ -0,0 +1,67 @@
# frozen_string_literal: true

class Api::V1::BookmarksController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' }
before_action :require_user!
after_action :insert_pagination_headers

respond_to :json

def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end

private

def load_statuses
cached_bookmarks
end

def cached_bookmarks
cache_collection(
Status.reorder(nil).joins(:bookmarks).merge(results),
Status
)
end

def results
@_results ||= account_bookmarks.paginate_by_max_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id]
)
end

def account_bookmarks
current_account.bookmarks
end

def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end

def next_path
api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) if records_continue?
end

def prev_path
api_v1_bookmarks_url pagination_params(since_id: pagination_since_id) unless results.empty?
end

def pagination_max_id
results.last.id
end

def pagination_since_id
results.first.id
end

def records_continue?
results.size == limit_param(DEFAULT_STATUSES_LIMIT)
end

def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true

class Api::V1::Statuses::BookmarksController < Api::BaseController
include Authorization

before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' }
before_action :require_user!

respond_to :json

def create
@status = bookmarked_status
render json: @status, serializer: REST::StatusSerializer
end

def destroy
@status = requested_status
@bookmarks_map = { @status.id => false }

bookmark = Bookmark.find_by!(account: current_user.account, status: @status)
bookmark.destroy!

render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, bookmarks_map: @bookmarks_map)
end

private

def bookmarked_status
authorize_with current_user.account, requested_status, :show?

bookmark = Bookmark.find_or_create_by!(account: current_user.account, status: requested_status)

bookmark.status.reload
end

def requested_status
Status.find(params[:status_id])
end
end
@@ -0,0 +1,90 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';

export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL';

export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST';
export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS';
export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL';

export function fetchBookmarkedStatuses() {
return (dispatch, getState) => {
if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
return;
}

dispatch(fetchBookmarkedStatusesRequest());

api(getState).get('/api/v1/bookmarks').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchBookmarkedStatusesFail(error));
});
};
};

export function fetchBookmarkedStatusesRequest() {
return {
type: BOOKMARKED_STATUSES_FETCH_REQUEST,
};
};

export function fetchBookmarkedStatusesSuccess(statuses, next) {
return {
type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
statuses,
next,
};
};

export function fetchBookmarkedStatusesFail(error) {
return {
type: BOOKMARKED_STATUSES_FETCH_FAIL,
error,
};
};

export function expandBookmarkedStatuses() {
return (dispatch, getState) => {
const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null);

if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
return;
}

dispatch(expandBookmarkedStatusesRequest());

api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandBookmarkedStatusesFail(error));
});
};
};

export function expandBookmarkedStatusesRequest() {
return {
type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
};
};

export function expandBookmarkedStatusesSuccess(statuses, next) {
return {
type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
statuses,
next,
};
};

export function expandBookmarkedStatusesFail(error) {
return {
type: BOOKMARKED_STATUSES_EXPAND_FAIL,
error,
};
};
@@ -33,6 +33,14 @@ export const UNPIN_REQUEST = 'UNPIN_REQUEST';
export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
export const UNPIN_FAIL = 'UNPIN_FAIL';

export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST';
export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS';
export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL';

export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';

export function reblog(status) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@@ -187,6 +195,78 @@ export function unfavouriteFail(status, error) {
};
};

export function bookmark(status) {
return function (dispatch, getState) {
dispatch(bookmarkRequest(status));

api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) {
dispatch(importFetchedStatus(response.data));
dispatch(bookmarkSuccess(status, response.data));
}).catch(function (error) {
dispatch(bookmarkFail(status, error));
});
};
};

export function unbookmark(status) {
return (dispatch, getState) => {
dispatch(unbookmarkRequest(status));

api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(unbookmarkSuccess(status, response.data));
}).catch(error => {
dispatch(unbookmarkFail(status, error));
});
};
};

export function bookmarkRequest(status) {
return {
type: BOOKMARK_REQUEST,
status: status,
};
};

export function bookmarkSuccess(status, response) {
return {
type: BOOKMARK_SUCCESS,
status: status,
response: response,
};
};

export function bookmarkFail(status, error) {
return {
type: BOOKMARK_FAIL,
status: status,
error: error,
};
};

export function unbookmarkRequest(status) {
return {
type: UNBOOKMARK_REQUEST,
status: status,
};
};

export function unbookmarkSuccess(status, response) {
return {
type: UNBOOKMARK_SUCCESS,
status: status,
response: response,
};
};

export function unbookmarkFail(status, error) {
return {
type: UNBOOKMARK_FAIL,
status: status,
error: error,
};
};

export function fetchReblogs(id) {
return (dispatch, getState) => {
dispatch(fetchReblogsRequest(id));
@@ -23,6 +23,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
@@ -66,6 +67,7 @@ class StatusActionBar extends ImmutablePureComponent {
onEmbed: PropTypes.func,
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
onBookmark: PropTypes.func,
withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@@ -114,6 +116,10 @@ class StatusActionBar extends ImmutablePureComponent {
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}

handleBookmarkClick = () => {
this.props.onBookmark(this.props.status);
}

handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history);
}
@@ -253,6 +259,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />

<div className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
@@ -9,8 +9,10 @@ import {
import {
reblog,
favourite,
bookmark,
unreblog,
unfavourite,
unbookmark,
pin,
unpin,
} from '../actions/interactions';
@@ -90,6 +92,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},

onBookmark (status) {
if (status.get('bookmarked')) {
dispatch(unbookmark(status));
} else {
dispatch(bookmark(status));
}
},

onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));

0 comments on commit dfea736

Please sign in to comment.
You can’t perform that action at this time.