mirror of
https://github.com/mastodon/mastodon.git
synced 2026-06-10 01:22:27 -05:00
Merge 970d6ab2e6 into 4fcb28e081
This commit is contained in:
commit
a375cb1c19
|
|
@ -0,0 +1,52 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::BookmarkFolders::BookmarksController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' }
|
||||
before_action :require_user!
|
||||
after_action :insert_pagination_headers
|
||||
|
||||
def index
|
||||
@folder = BookmarkFolder.where(account: current_account).find(params[:bookmark_folder_id])
|
||||
@bookmarks = load_bookmarks(@folder.bookmarks)
|
||||
|
||||
render_bookmarks
|
||||
end
|
||||
|
||||
def unfolded
|
||||
@bookmarks = load_bookmarks(current_account.bookmarks.where(folder_id: nil))
|
||||
|
||||
render_bookmarks
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_bookmarks(scope)
|
||||
results = scope.joins(:status)
|
||||
.eager_load(:status)
|
||||
.to_a_paginated_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||
|
||||
preload_collection(results.map(&:status), Status)
|
||||
end
|
||||
|
||||
def next_path
|
||||
folder_api_v1_bookmarks_url(@folder, pagination_params(max_id: pagination_max_id)) if records_continue?
|
||||
end
|
||||
|
||||
def prev_path
|
||||
folder_api_v1_bookmarks_url(@folder, pagination_params(min_id: pagination_since_id)) unless @bookmarks.empty?
|
||||
end
|
||||
|
||||
def render_bookmarks
|
||||
render json: @bookmarks,
|
||||
each_serializer: REST::StatusSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(@bookmarks, current_account.id)
|
||||
end
|
||||
|
||||
def pagination_collection
|
||||
@bookmarks
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@bookmarks.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||
end
|
||||
end
|
||||
43
app/controllers/api/v1/bookmark_folders_controller.rb
Normal file
43
app/controllers/api/v1/bookmark_folders_controller.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::BookmarkFoldersController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' }, only: [:index, :show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' }, except: [:index, :show]
|
||||
|
||||
before_action :require_user!
|
||||
before_action :set_folder, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@folders = BookmarkFolder.where(account: current_account).all
|
||||
render json: @folders, each_serializer: REST::BookmarkFolderSerializer
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @folder, serializer: REST::BookmarkFolderSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
@folder = BookmarkFolder.create!(folder_params.merge(account: current_account))
|
||||
render json: @folder, serializer: REST::BookmarkFolderSerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@folder.update!(folder_params)
|
||||
render json: @folder, serializer: REST::BookmarkFolderSerializer
|
||||
end
|
||||
|
||||
def destroy
|
||||
@folder.destroy!
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_folder
|
||||
@folder = BookmarkFolder.where(account: current_account).find(params[:id])
|
||||
end
|
||||
|
||||
def folder_params
|
||||
params.permit(:title)
|
||||
end
|
||||
end
|
||||
|
|
@ -6,7 +6,8 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
|
|||
skip_before_action :set_status, only: [:destroy]
|
||||
|
||||
def create
|
||||
current_account.bookmarks.find_or_create_by!(account: current_account, status: @status)
|
||||
bookmark = current_account.bookmarks.find_or_initialize_by(status: @status)
|
||||
bookmark.update!(bookmark_params) if bookmark_params.present? || bookmark.new_record?
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
|
|
@ -26,4 +27,14 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
|
|||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bookmark_params
|
||||
permitted = params.permit(:folder_id)
|
||||
|
||||
raise ActiveRecord::RecordNotFound if permitted[:folder_id].present? && !current_account.bookmark_folders.exists?(id: permitted[:folder_id])
|
||||
|
||||
permitted
|
||||
end
|
||||
end
|
||||
|
|
|
|||
34
app/javascript/mastodon/actions/bookmark_folders_typed.ts
Normal file
34
app/javascript/mastodon/actions/bookmark_folders_typed.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
apiCreateBookmarkFolder,
|
||||
apiDeleteBookmarkFolder,
|
||||
apiGetBookmarkFolder,
|
||||
apiGetBookmarkFolders,
|
||||
apiUpdateBookmarkFolder,
|
||||
} from 'mastodon/api/bookmark_folders';
|
||||
import type { BookmarkFolder } from 'mastodon/models/bookmark_folder';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
export const createBookmarkFolder = createDataLoadingThunk(
|
||||
'bookmarkFolders/create',
|
||||
(folder: Partial<BookmarkFolder>) => apiCreateBookmarkFolder(folder),
|
||||
);
|
||||
|
||||
export const updateBookmarkFolder = createDataLoadingThunk(
|
||||
'bookmarkFolders/update',
|
||||
(folder: Partial<BookmarkFolder>) => apiUpdateBookmarkFolder(folder),
|
||||
);
|
||||
|
||||
export const fetchBookmarkFolders = createDataLoadingThunk(
|
||||
'bookmarkFolders/fetch',
|
||||
() => apiGetBookmarkFolders(),
|
||||
);
|
||||
|
||||
export const fetchBookmarkFolder = createDataLoadingThunk(
|
||||
'bookmarkFolders/fetchOne',
|
||||
({ id }: { id: string }) => apiGetBookmarkFolder(id),
|
||||
);
|
||||
|
||||
export const deleteBookmarkFolder = createDataLoadingThunk(
|
||||
'bookmarkFolders/delete',
|
||||
({ id }: { id: string }) => apiDeleteBookmarkFolder(id).then(() => ({ id })),
|
||||
);
|
||||
|
|
@ -6,35 +6,69 @@ export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQU
|
|||
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
|
||||
export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL';
|
||||
|
||||
export const BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST = 'BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST';
|
||||
export const BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS = 'BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS';
|
||||
export const BOOKMARK_FOLDER_STATUSES_FETCH_FAIL = 'BOOKMARK_FOLDER_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() {
|
||||
export const BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST = 'BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST';
|
||||
export const BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS = 'BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS';
|
||||
export const BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL = 'BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL';
|
||||
|
||||
const getBookmarksListPath = (folderId) => (
|
||||
folderId ? ['status_lists', 'bookmark_folders', folderId] : ['status_lists', 'bookmarks']
|
||||
);
|
||||
|
||||
const getBookmarksListUrl = (folderId) => (
|
||||
folderId ? `/api/v1/bookmarks/folders/${folderId}` : '/api/v1/bookmarks'
|
||||
);
|
||||
|
||||
export function fetchBookmarkedStatuses(folderId) {
|
||||
return (dispatch, getState) => {
|
||||
if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
|
||||
const listPath = getBookmarksListPath(folderId);
|
||||
|
||||
if (getState().getIn([...listPath, 'isLoading'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchBookmarkedStatusesRequest());
|
||||
dispatch(fetchBookmarkedStatusesRequest(folderId));
|
||||
|
||||
api().get('/api/v1/bookmarks').then(response => {
|
||||
api().get(getBookmarksListUrl(folderId)).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId));
|
||||
}).catch(error => {
|
||||
dispatch(fetchBookmarkedStatusesFail(error));
|
||||
dispatch(fetchBookmarkedStatusesFail(error, folderId));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchBookmarkedStatusesRequest() {
|
||||
export function fetchBookmarkedStatusesRequest(folderId) {
|
||||
if (folderId) {
|
||||
return {
|
||||
type: BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST,
|
||||
folderId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchBookmarkedStatusesSuccess(statuses, next) {
|
||||
export function fetchBookmarkedStatusesSuccess(statuses, next, folderId) {
|
||||
if (folderId) {
|
||||
return {
|
||||
type: BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS,
|
||||
statuses,
|
||||
next,
|
||||
folderId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
|
||||
statuses,
|
||||
|
|
@ -42,40 +76,65 @@ export function fetchBookmarkedStatusesSuccess(statuses, next) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchBookmarkedStatusesFail(error) {
|
||||
export function fetchBookmarkedStatusesFail(error, folderId) {
|
||||
if (folderId) {
|
||||
return {
|
||||
type: BOOKMARK_FOLDER_STATUSES_FETCH_FAIL,
|
||||
folderId,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandBookmarkedStatuses() {
|
||||
export function expandBookmarkedStatuses(folderId) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null);
|
||||
const listPath = getBookmarksListPath(folderId);
|
||||
const url = getState().getIn([...listPath, 'next'], null);
|
||||
|
||||
if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
|
||||
if (url === null || getState().getIn([...listPath, 'isLoading'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandBookmarkedStatusesRequest());
|
||||
dispatch(expandBookmarkedStatusesRequest(folderId));
|
||||
|
||||
api().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));
|
||||
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId));
|
||||
}).catch(error => {
|
||||
dispatch(expandBookmarkedStatusesFail(error));
|
||||
dispatch(expandBookmarkedStatusesFail(error, folderId));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function expandBookmarkedStatusesRequest() {
|
||||
export function expandBookmarkedStatusesRequest(folderId) {
|
||||
if (folderId) {
|
||||
return {
|
||||
type: BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST,
|
||||
folderId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandBookmarkedStatusesSuccess(statuses, next) {
|
||||
export function expandBookmarkedStatusesSuccess(statuses, next, folderId) {
|
||||
if (folderId) {
|
||||
return {
|
||||
type: BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS,
|
||||
statuses,
|
||||
next,
|
||||
folderId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
|
||||
statuses,
|
||||
|
|
@ -83,7 +142,15 @@ export function expandBookmarkedStatusesSuccess(statuses, next) {
|
|||
};
|
||||
}
|
||||
|
||||
export function expandBookmarkedStatusesFail(error) {
|
||||
export function expandBookmarkedStatusesFail(error, folderId) {
|
||||
if (folderId) {
|
||||
return {
|
||||
type: BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL,
|
||||
folderId,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_EXPAND_FAIL,
|
||||
error,
|
||||
|
|
|
|||
|
|
@ -129,11 +129,14 @@ export function unfavouriteFail(status, error) {
|
|||
};
|
||||
}
|
||||
|
||||
export function bookmark(status) {
|
||||
export function bookmark(status, folderId) {
|
||||
return function (dispatch) {
|
||||
dispatch(bookmarkRequest(status));
|
||||
|
||||
api().post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) {
|
||||
// undefined omits body; null explicitly clears the folder
|
||||
const data = typeof folderId === 'undefined' ? undefined : { folder_id: folderId };
|
||||
|
||||
api().post(`/api/v1/statuses/${status.get('id')}/bookmark`, data).then(function (response) {
|
||||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(bookmarkSuccess(status, response.data));
|
||||
}).catch(function (error) {
|
||||
|
|
|
|||
28
app/javascript/mastodon/api/bookmark_folders.ts
Normal file
28
app/javascript/mastodon/api/bookmark_folders.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {
|
||||
apiRequestDelete,
|
||||
apiRequestGet,
|
||||
apiRequestPost,
|
||||
apiRequestPut,
|
||||
} from 'mastodon/api';
|
||||
import type { ApiBookmarkFolderJSON } from 'mastodon/api_types/bookmark_folders';
|
||||
|
||||
export const apiCreateBookmarkFolder = (
|
||||
folder: Partial<ApiBookmarkFolderJSON>,
|
||||
) => apiRequestPost<ApiBookmarkFolderJSON>('v1/bookmark_folders', folder);
|
||||
|
||||
export const apiUpdateBookmarkFolder = (
|
||||
folder: Partial<ApiBookmarkFolderJSON>,
|
||||
) =>
|
||||
apiRequestPut<ApiBookmarkFolderJSON>(
|
||||
`v1/bookmark_folders/${folder.id}`,
|
||||
folder,
|
||||
);
|
||||
|
||||
export const apiGetBookmarkFolders = () =>
|
||||
apiRequestGet<ApiBookmarkFolderJSON[]>('v1/bookmark_folders');
|
||||
|
||||
export const apiGetBookmarkFolder = (id: string) =>
|
||||
apiRequestGet<ApiBookmarkFolderJSON>(`v1/bookmark_folders/${id}`);
|
||||
|
||||
export const apiDeleteBookmarkFolder = (id: string) =>
|
||||
apiRequestDelete(`v1/bookmark_folders/${id}`);
|
||||
4
app/javascript/mastodon/api_types/bookmark_folders.ts
Normal file
4
app/javascript/mastodon/api_types/bookmark_folders.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface ApiBookmarkFolderJSON {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
|
@ -102,6 +102,7 @@ export interface ApiStatusJSON {
|
|||
reblogged?: boolean;
|
||||
muted?: boolean;
|
||||
bookmarked?: boolean;
|
||||
bookmark_folder_id?: string | null;
|
||||
pinned?: boolean;
|
||||
|
||||
filtered?: ApiFilterResultJSON[];
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const messages = defineMessages({
|
|||
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
addToBookmarkFolder: { id: 'status.add_to_bookmark_folder', defaultMessage: 'Add to bookmark folder' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
|
|
@ -101,6 +102,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
onBookmarkFolder: PropTypes.func,
|
||||
onFilter: PropTypes.func,
|
||||
onAddFilter: PropTypes.func,
|
||||
onInteractionModal: PropTypes.func,
|
||||
|
|
@ -156,6 +158,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
this.props.onBookmark(this.props.status);
|
||||
};
|
||||
|
||||
handleBookmarkFolderClick = () => {
|
||||
this.props.onBookmarkFolder?.(this.props.status);
|
||||
};
|
||||
|
||||
handleDeleteClick = () => {
|
||||
this.props.onDelete(this.props.status);
|
||||
};
|
||||
|
|
@ -295,6 +301,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
if (signedIn) {
|
||||
menu.push(null);
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.addToBookmarkFolder), action: this.handleBookmarkFolderClick });
|
||||
menu.push(null);
|
||||
|
||||
if (writtenByMe && pinnableStatus) {
|
||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||
menu.push(null);
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
}
|
||||
},
|
||||
|
||||
onBookmarkFolder (status) {
|
||||
dispatch(openModal({
|
||||
modalType: 'BOOKMARK_FOLDER_ADDER',
|
||||
modalProps: { status },
|
||||
}));
|
||||
},
|
||||
|
||||
onPin (status) {
|
||||
if (status.get('pinned')) {
|
||||
dispatch(unpin(status));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import type { Map as ImmutableMapType } from 'immutable';
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@/testing/rendering';
|
||||
|
||||
import type * as BookmarkFoldersTypedModule from 'mastodon/actions/bookmark_folders_typed';
|
||||
import type * as InteractionsModule from 'mastodon/actions/interactions';
|
||||
import BookmarkFolderAdder from '..';
|
||||
import type * as StoreModule from 'mastodon/store';
|
||||
|
||||
const status = ImmutableMap({ bookmark_folder_id: '1' });
|
||||
|
||||
interface CreateBookmarkFolderAction {
|
||||
type: 'bookmarkFolders/create';
|
||||
payload: {
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FetchBookmarkFoldersAction {
|
||||
type: 'bookmarkFolders/fetch';
|
||||
}
|
||||
|
||||
interface BookmarkAction {
|
||||
type: 'bookmarks/bookmark';
|
||||
payload: {
|
||||
statusValue: typeof status;
|
||||
folderId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateBookmarkFolderFulfilledAction {
|
||||
type: 'bookmarkFolders/create/fulfilled';
|
||||
meta: { requestStatus: 'fulfilled' };
|
||||
payload: { id: string; title: string };
|
||||
}
|
||||
|
||||
interface BookmarkFoldersState {
|
||||
bookmark_folders: ImmutableMapType<string, { id: string; title: string } | null>;
|
||||
}
|
||||
|
||||
type BookmarkFolderDispatchAction =
|
||||
| CreateBookmarkFolderAction
|
||||
| FetchBookmarkFoldersAction;
|
||||
|
||||
const folders = [
|
||||
{ id: '2', title: 'Zulu' },
|
||||
{ id: '1', title: 'Alpha' },
|
||||
];
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatchMock: vi.fn(),
|
||||
useAppDispatchMock: vi.fn(),
|
||||
useAppSelectorMock: vi.fn(),
|
||||
createBookmarkFolderMock: vi.fn(),
|
||||
fetchBookmarkFoldersMock: vi.fn(),
|
||||
bookmarkMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderComponent = () => {
|
||||
return render(<BookmarkFolderAdder status={status} onClose={vi.fn()} />);
|
||||
};
|
||||
|
||||
const getFolderRadio = (name: string) =>
|
||||
screen.getByLabelText<HTMLInputElement>(name);
|
||||
|
||||
const getNewFolderInput = () =>
|
||||
screen.getByPlaceholderText<HTMLInputElement>('New folder name');
|
||||
|
||||
const submitNewFolderForm = () => {
|
||||
fireEvent.submit(getNewFolderInput().closest('form') as HTMLFormElement);
|
||||
};
|
||||
|
||||
const createFolderFulfilledAction = (
|
||||
title: string,
|
||||
): CreateBookmarkFolderFulfilledAction => ({
|
||||
type: 'bookmarkFolders/create/fulfilled',
|
||||
meta: { requestStatus: 'fulfilled' },
|
||||
payload: { id: '3', title },
|
||||
});
|
||||
|
||||
vi.mock('mastodon/store', async (importOriginal) => {
|
||||
const actual: typeof StoreModule = await importOriginal();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useAppDispatch: mocks.useAppDispatchMock,
|
||||
useAppSelector: mocks.useAppSelectorMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('mastodon/actions/bookmark_folders_typed', async (importOriginal) => {
|
||||
const actual: typeof BookmarkFoldersTypedModule = await importOriginal();
|
||||
|
||||
const createBookmarkFolder = Object.assign(
|
||||
mocks.createBookmarkFolderMock,
|
||||
actual.createBookmarkFolder,
|
||||
);
|
||||
const fetchBookmarkFolders = Object.assign(
|
||||
mocks.fetchBookmarkFoldersMock,
|
||||
actual.fetchBookmarkFolders,
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createBookmarkFolder,
|
||||
fetchBookmarkFolders,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('mastodon/actions/interactions', async (importOriginal) => {
|
||||
const actual: typeof InteractionsModule = await importOriginal();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
bookmark: mocks.bookmarkMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('<BookmarkFolderAdder />', () => {
|
||||
beforeEach(() => {
|
||||
mocks.createBookmarkFolderMock.mockImplementation(
|
||||
(payload: CreateBookmarkFolderAction['payload']) => ({
|
||||
type: 'bookmarkFolders/create',
|
||||
payload,
|
||||
}),
|
||||
);
|
||||
mocks.fetchBookmarkFoldersMock.mockImplementation(() => ({
|
||||
type: 'bookmarkFolders/fetch',
|
||||
}));
|
||||
mocks.bookmarkMock.mockImplementation(
|
||||
(statusValue: typeof status, folderId: string | null): BookmarkAction => ({
|
||||
type: 'bookmarks/bookmark',
|
||||
payload: { statusValue, folderId },
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.dispatchMock.mockImplementation(
|
||||
(action: BookmarkFolderDispatchAction) => {
|
||||
if (action.type === 'bookmarkFolders/create') {
|
||||
const fulfilledAction = createFolderFulfilledAction(
|
||||
action.payload.title,
|
||||
);
|
||||
|
||||
mocks.bookmarkMock(status, fulfilledAction.payload.id);
|
||||
|
||||
return Promise.resolve(fulfilledAction);
|
||||
}
|
||||
|
||||
return action;
|
||||
},
|
||||
);
|
||||
|
||||
mocks.useAppDispatchMock.mockReturnValue(mocks.dispatchMock);
|
||||
mocks.useAppSelectorMock.mockImplementation(
|
||||
(selector: (state: BookmarkFoldersState) => unknown) =>
|
||||
selector({
|
||||
bookmark_folders: ImmutableMap(
|
||||
folders.map((folder) => [folder.id, folder]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fetches folders on mount and renders them in title order', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(mocks.fetchBookmarkFoldersMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const titles = screen
|
||||
.getAllByRole('radio')
|
||||
.map((radio) => radio.closest('label')?.querySelector('span')?.textContent);
|
||||
|
||||
expect(titles).toEqual(['No folder', 'Alpha', 'Zulu']);
|
||||
});
|
||||
|
||||
it('keeps the current folder selected and can move back to no folder', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(getFolderRadio('Alpha').checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('No folder'));
|
||||
|
||||
expect(mocks.bookmarkMock).toHaveBeenCalledWith(status, null);
|
||||
});
|
||||
|
||||
it('selects another existing folder', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Zulu'));
|
||||
|
||||
expect(mocks.bookmarkMock).toHaveBeenCalledWith(status, '2');
|
||||
});
|
||||
|
||||
it('creates a new folder and bookmarks the status into it', async () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.change(getNewFolderInput(), {
|
||||
target: { value: 'Work' },
|
||||
});
|
||||
|
||||
submitNewFolderForm();
|
||||
|
||||
expect(mocks.createBookmarkFolderMock).toHaveBeenCalledWith({
|
||||
title: 'Work',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.bookmarkMock).toHaveBeenCalledWith(status, '3');
|
||||
});
|
||||
});
|
||||
});
|
||||
197
app/javascript/mastodon/features/bookmark_folder_adder/index.tsx
Normal file
197
app/javascript/mastodon/features/bookmark_folder_adder/index.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import {
|
||||
createBookmarkFolder,
|
||||
fetchBookmarkFolders,
|
||||
} from 'mastodon/actions/bookmark_folders_typed';
|
||||
import { bookmark } from 'mastodon/actions/interactions';
|
||||
import type { ApiBookmarkFolderJSON } from 'mastodon/api_types/bookmark_folders';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { RadioButton } from 'mastodon/components/form_fields';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { getOrderedBookmarkFolders } from 'mastodon/selectors/bookmark_folders';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
newFolder: {
|
||||
id: 'bookmark_folders.new_folder_name',
|
||||
defaultMessage: 'New folder name',
|
||||
},
|
||||
createFolder: {
|
||||
id: 'bookmark_folders.create',
|
||||
defaultMessage: 'Create folder',
|
||||
},
|
||||
close: {
|
||||
id: 'lightbox.close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
noFolder: {
|
||||
id: 'bookmark_folders.no_folder',
|
||||
defaultMessage: 'No folder',
|
||||
},
|
||||
});
|
||||
|
||||
const FolderItem: React.FC<{
|
||||
id: string;
|
||||
title: string;
|
||||
checked: boolean;
|
||||
onChange: (id: string) => void;
|
||||
}> = ({ id, title, checked, onChange }) => {
|
||||
const handleChange = useCallback(() => {
|
||||
onChange(id);
|
||||
}, [id, onChange]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
<label className='lists__item'>
|
||||
<div className='lists__item__title'>
|
||||
<Icon id='bookmark' icon={BookmarkBorderIcon} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
|
||||
<RadioButton
|
||||
name='bookmark-folder'
|
||||
value={id}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const NewFolderItem: React.FC<{
|
||||
onCreate: (folder: ApiBookmarkFolderJSON) => void;
|
||||
}> = ({ onCreate }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const [title, setTitle] = useState('');
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(value);
|
||||
},
|
||||
[setTitle],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (title.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void dispatch(createBookmarkFolder({ title })).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
onCreate(result.payload);
|
||||
setTitle('');
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}, [title, dispatch, onCreate]);
|
||||
|
||||
return (
|
||||
<form className='lists__item' onSubmit={handleSubmit}>
|
||||
<label className='lists__item__title'>
|
||||
<Icon id='bookmark' icon={BookmarkBorderIcon} />
|
||||
|
||||
<input
|
||||
type='text'
|
||||
value={title}
|
||||
onChange={handleChange}
|
||||
maxLength={30}
|
||||
required
|
||||
placeholder={intl.formatMessage(messages.newFolder)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button text={intl.formatMessage(messages.createFolder)} type='submit' />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const BookmarkFolderAdder: React.FC<{
|
||||
status: ImmutableMap<string, unknown>;
|
||||
onClose: () => void;
|
||||
}> = ({ status, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const folders = useAppSelector((state) => getOrderedBookmarkFolders(state));
|
||||
const [localFolderId, setLocalFolderId] = useState<string | null>(null);
|
||||
const propFolderId =
|
||||
(status.get('bookmark_folder_id') as string | null | undefined) ?? 'none';
|
||||
const selectedFolderId = localFolderId ?? propFolderId;
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchBookmarkFolders());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedId: string) => {
|
||||
setLocalFolderId(selectedId);
|
||||
dispatch(bookmark(status, selectedId === 'none' ? null : selectedId));
|
||||
},
|
||||
[dispatch, status],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
(folder: ApiBookmarkFolderJSON) => {
|
||||
setLocalFolderId(folder.id);
|
||||
dispatch(bookmark(status, folder.id));
|
||||
},
|
||||
[dispatch, status],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<IconButton
|
||||
className='dialog-modal__header__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<span className='dialog-modal__header__title'>
|
||||
<FormattedMessage
|
||||
id='bookmark_folders.add_to_folder'
|
||||
defaultMessage='Add to bookmark folder'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
<div className='lists-scrollable'>
|
||||
<NewFolderItem onCreate={handleCreate} />
|
||||
|
||||
<FolderItem
|
||||
id='none'
|
||||
title={intl.formatMessage(messages.noFolder)}
|
||||
checked={selectedFolderId === 'none'}
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
|
||||
{folders.map((folder) => (
|
||||
<FolderItem
|
||||
key={folder.id}
|
||||
id={folder.id}
|
||||
title={folder.title}
|
||||
checked={selectedFolderId === folder.id}
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default BookmarkFolderAdder;
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { render, screen, fireEvent } from '@/testing/rendering';
|
||||
|
||||
import { ConfirmDeleteBookmarkFolderModal } from '../../ui/components/confirmation_modals/delete_bookmark_folder';
|
||||
|
||||
import type * as BookmarkFoldersTypedModule from 'mastodon/actions/bookmark_folders_typed';
|
||||
import type * as ReactRouterModule from 'react-router';
|
||||
import type * as StoreModule from 'mastodon/store';
|
||||
|
||||
interface DeleteBookmarkFolderAction {
|
||||
type: 'bookmarkFolders/delete';
|
||||
payload: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
type DeleteBookmarkFolderDispatchAction = DeleteBookmarkFolderAction;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatchMock: vi.fn(),
|
||||
useAppDispatchMock: vi.fn(),
|
||||
useAppSelectorMock: vi.fn(),
|
||||
deleteBookmarkFolderMock: vi.fn(),
|
||||
pushMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('mastodon/store', async () => {
|
||||
const actual = await vi.importActual<typeof StoreModule>('mastodon/store');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useAppDispatch: mocks.useAppDispatchMock,
|
||||
useAppSelector: mocks.useAppSelectorMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('mastodon/actions/bookmark_folders_typed', async () => {
|
||||
const actual = await vi.importActual<typeof BookmarkFoldersTypedModule>(
|
||||
'mastodon/actions/bookmark_folders_typed',
|
||||
);
|
||||
|
||||
const deleteBookmarkFolder = Object.assign(
|
||||
mocks.deleteBookmarkFolderMock,
|
||||
actual.deleteBookmarkFolder,
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
deleteBookmarkFolder,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('react-router', async () => {
|
||||
const actual = await vi.importActual<typeof ReactRouterModule>('react-router');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useHistory: () => ({ push: mocks.pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<ConfirmDeleteBookmarkFolderModal />', () => {
|
||||
beforeEach(() => {
|
||||
mocks.deleteBookmarkFolderMock.mockImplementation(
|
||||
({ id }: DeleteBookmarkFolderAction['payload']) => ({
|
||||
type: 'bookmarkFolders/delete',
|
||||
payload: { id },
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.dispatchMock.mockImplementation(
|
||||
(action: DeleteBookmarkFolderDispatchAction) => action,
|
||||
);
|
||||
|
||||
mocks.useAppDispatchMock.mockReturnValue(mocks.dispatchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches delete and navigates back', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<ConfirmDeleteBookmarkFolderModal id='42' onClose={onClose} />);
|
||||
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Delete' }).closest('form') as HTMLFormElement,);
|
||||
|
||||
expect(mocks.deleteBookmarkFolderMock).toHaveBeenCalledWith({ id: '42' });
|
||||
expect(mocks.pushMock).toHaveBeenCalledWith('/bookmarks/folders');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import type { Map as ImmutableMapType } from 'immutable';
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@/testing/rendering';
|
||||
|
||||
import NewBookmarkFolderWrapper from '../new';
|
||||
import type * as BookmarkFoldersTypedModule from 'mastodon/actions/bookmark_folders_typed';
|
||||
import type * as ReactRouterDomModule from 'react-router-dom';
|
||||
import type * as StoreModule from 'mastodon/store';
|
||||
|
||||
const folder = { id: '1', title: 'Alpha' };
|
||||
|
||||
interface BookmarkFoldersState {
|
||||
bookmark_folders: ImmutableMapType<string, typeof folder>;
|
||||
}
|
||||
|
||||
interface UpdateBookmarkFolderAction {
|
||||
type: 'bookmarkFolders/update';
|
||||
payload: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateBookmarkFolderFulfilledAction {
|
||||
type: 'bookmarkFolders/update/fulfilled';
|
||||
meta: { requestStatus: 'fulfilled' };
|
||||
payload: { id: string; title: string };
|
||||
}
|
||||
|
||||
interface FetchBookmarkFolderAction {
|
||||
type: 'bookmarkFolders/fetchOne';
|
||||
payload: { id: string };
|
||||
}
|
||||
|
||||
type BookmarkFolderDispatchAction =
|
||||
| UpdateBookmarkFolderAction
|
||||
| FetchBookmarkFolderAction;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatchMock: vi.fn(),
|
||||
useAppDispatchMock: vi.fn(),
|
||||
useAppSelectorMock: vi.fn(),
|
||||
updateBookmarkFolderMock: vi.fn(),
|
||||
fetchBookmarkFolderMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('mastodon/store', async (importOriginal) => {
|
||||
const actual: typeof StoreModule = await importOriginal();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useAppDispatch: mocks.useAppDispatchMock,
|
||||
useAppSelector: mocks.useAppSelectorMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/mastodon/store', async (importOriginal) => {
|
||||
const actual: typeof StoreModule = await importOriginal();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useAppDispatch: mocks.useAppDispatchMock,
|
||||
useAppSelector: mocks.useAppSelectorMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('mastodon/actions/bookmark_folders_typed', async (importOriginal) => {
|
||||
const actual: typeof BookmarkFoldersTypedModule = await importOriginal();
|
||||
|
||||
const updateBookmarkFolder = Object.assign(
|
||||
mocks.updateBookmarkFolderMock,
|
||||
actual.updateBookmarkFolder,
|
||||
);
|
||||
const fetchBookmarkFolder = Object.assign(
|
||||
mocks.fetchBookmarkFolderMock,
|
||||
actual.fetchBookmarkFolder,
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
updateBookmarkFolder,
|
||||
fetchBookmarkFolder,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('react-router-dom', async (importOriginal) => {
|
||||
const actual: typeof ReactRouterDomModule = await importOriginal();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ id: '1' }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<NewBookmarkFolder (update) />', () => {
|
||||
beforeEach(() => {
|
||||
mocks.updateBookmarkFolderMock.mockImplementation(
|
||||
(payload: UpdateBookmarkFolderAction['payload']) => ({
|
||||
type: 'bookmarkFolders/update',
|
||||
payload,
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.fetchBookmarkFolderMock.mockImplementation(
|
||||
({ id }: FetchBookmarkFolderAction['payload']) => ({
|
||||
type: 'bookmarkFolders/fetchOne',
|
||||
payload: { id },
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.dispatchMock.mockImplementation(
|
||||
(action: BookmarkFolderDispatchAction) => {
|
||||
if (action.type === 'bookmarkFolders/update') {
|
||||
return Promise.resolve({
|
||||
type: 'bookmarkFolders/update/fulfilled',
|
||||
meta: { requestStatus: 'fulfilled' },
|
||||
payload: { id: action.payload.id, title: action.payload.title },
|
||||
} satisfies UpdateBookmarkFolderFulfilledAction);
|
||||
}
|
||||
|
||||
return action;
|
||||
},
|
||||
);
|
||||
|
||||
mocks.useAppDispatchMock.mockReturnValue(mocks.dispatchMock);
|
||||
mocks.useAppSelectorMock.mockImplementation(
|
||||
(selector: (state: BookmarkFoldersState) => typeof folder | undefined) =>
|
||||
selector({
|
||||
bookmark_folders: ImmutableMap([[folder.id, folder]]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('submits an update when editing a folder', async () => {
|
||||
render(<NewBookmarkFolderWrapper />);
|
||||
|
||||
const input = screen.getByDisplayValue('Alpha');
|
||||
fireEvent.change(input, { target: { value: 'Work' } });
|
||||
|
||||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.updateBookmarkFolderMock).toHaveBeenCalledWith({
|
||||
id: '1',
|
||||
title: 'Work',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
148
app/javascript/mastodon/features/bookmark_folders/index.tsx
Normal file
148
app/javascript/mastodon/features/bookmark_folders/index.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { useEffect, useMemo, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { fetchBookmarkFolders } from 'mastodon/actions/bookmark_folders_typed';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import { getOrderedBookmarkFolders } from 'mastodon/selectors/bookmark_folders';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'column.bookmark_folders',
|
||||
defaultMessage: 'Bookmark folders',
|
||||
},
|
||||
create: { id: 'bookmark_folders.create', defaultMessage: 'Create folder' },
|
||||
edit: { id: 'bookmark_folders.edit', defaultMessage: 'Edit folder' },
|
||||
delete: { id: 'bookmark_folders.delete', defaultMessage: 'Delete folder' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
const FolderItem: React.FC<{
|
||||
id: string;
|
||||
title: string;
|
||||
}> = ({ id, title }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_DELETE_BOOKMARK_FOLDER',
|
||||
modalProps: { id },
|
||||
}),
|
||||
);
|
||||
}, [dispatch, id]);
|
||||
|
||||
const menu = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: intl.formatMessage(messages.edit),
|
||||
to: `/bookmarks/folders/${id}/edit`,
|
||||
},
|
||||
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
|
||||
],
|
||||
[intl, id, handleDeleteClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='lists__item'>
|
||||
<Link to={`/bookmarks/folders/${id}`} className='lists__item__title'>
|
||||
<Icon id='bookmarks' icon={BookmarksIcon} />
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
|
||||
<Dropdown
|
||||
scrollKey='bookmark_folders'
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AllBookmarksItem: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='lists__item'>
|
||||
<Link to='/bookmarks' className='lists__item__title'>
|
||||
<Icon id='bookmarks' icon={BookmarksIcon} />
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: 'bookmarks.all',
|
||||
defaultMessage: 'All Bookmarks',
|
||||
})}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BookmarkFolders: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const folders = useAppSelector((state) => getOrderedBookmarkFolders(state));
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchBookmarkFolders());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={
|
||||
<Link
|
||||
to='/bookmarks/folders/new'
|
||||
className='column-header__button'
|
||||
title={intl.formatMessage(messages.create)}
|
||||
aria-label={intl.formatMessage(messages.create)}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='bookmark_folders'
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
<AllBookmarksItem />
|
||||
{folders.map((folder) => (
|
||||
<FolderItem key={folder.id} id={folder.id} title={folder.title} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default BookmarkFolders;
|
||||
160
app/javascript/mastodon/features/bookmark_folders/new.tsx
Normal file
160
app/javascript/mastodon/features/bookmark_folders/new.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams, useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import {
|
||||
createBookmarkFolder,
|
||||
fetchBookmarkFolder,
|
||||
updateBookmarkFolder,
|
||||
} from 'mastodon/actions/bookmark_folders_typed';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { TextInputField } from 'mastodon/components/form_fields';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import type { BookmarkFolder } from 'mastodon/models/bookmark_folder';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
edit: { id: 'column.edit_bookmark_folder', defaultMessage: 'Edit folder' },
|
||||
create: {
|
||||
id: 'column.create_bookmark_folder',
|
||||
defaultMessage: 'Create folder',
|
||||
},
|
||||
});
|
||||
|
||||
const NewBookmarkFolder: React.FC<{
|
||||
folder?: BookmarkFolder | null;
|
||||
}> = ({ folder }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const { id, title: initialTitle = '' } = folder ?? {};
|
||||
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(value);
|
||||
},
|
||||
[setTitle],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setSubmitting(true);
|
||||
|
||||
if (id) {
|
||||
void dispatch(updateBookmarkFolder({ id, title })).then(() => {
|
||||
setSubmitting(false);
|
||||
return '';
|
||||
});
|
||||
} else {
|
||||
void dispatch(createBookmarkFolder({ title })).then((result) => {
|
||||
setSubmitting(false);
|
||||
|
||||
if (isFulfilled(result)) {
|
||||
history.push(`/bookmarks/folders`);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
}, [dispatch, history, id, title]);
|
||||
|
||||
return (
|
||||
<form className='simple_form app-form' onSubmit={handleSubmit}>
|
||||
<div className='fields-group'>
|
||||
<TextInputField
|
||||
required
|
||||
maxLength={30}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='bookmark_folders.folder_name'
|
||||
defaultMessage='Folder name'
|
||||
/>
|
||||
}
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
id='bookmark_folder_title'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='actions'>
|
||||
<button className='button' type='submit'>
|
||||
{submitting ? (
|
||||
<LoadingIndicator />
|
||||
) : id ? (
|
||||
<FormattedMessage
|
||||
id='bookmark_folders.save'
|
||||
defaultMessage='Save'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='bookmark_folders.create'
|
||||
defaultMessage='Create folder'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const NewBookmarkFolderWrapper: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const folder = useAppSelector((state) =>
|
||||
id ? state.bookmark_folders.get(id) : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
void dispatch(fetchBookmarkFolder({ id }));
|
||||
}
|
||||
}, [dispatch, id]);
|
||||
|
||||
const isLoading = id && !folder;
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(id ? messages.edit : messages.create)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(id ? messages.edit : messages.create)}
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<div className='scrollable'>
|
||||
{isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<NewBookmarkFolder folder={folder} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(id ? messages.edit : messages.create)}
|
||||
</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default NewBookmarkFolderWrapper;
|
||||
|
|
@ -3,22 +3,36 @@ import { useEffect, useRef, useCallback } from 'react';
|
|||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import { fetchBookmarkFolders } from 'mastodon/actions/bookmark_folders_typed';
|
||||
import {
|
||||
fetchBookmarkedStatuses,
|
||||
expandBookmarkedStatuses,
|
||||
} from 'mastodon/actions/bookmarks';
|
||||
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import StatusList from 'mastodon/components/status_list';
|
||||
import { getStatusList } from 'mastodon/selectors';
|
||||
import { getOrderedBookmarkFolders } from 'mastodon/selectors/bookmark_folders';
|
||||
import { getBookmarkFolderStatusList } from 'mastodon/selectors/statuses';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
allBookmarks: { id: 'bookmarks.all', defaultMessage: 'All Bookmarks' },
|
||||
createFolder: {
|
||||
id: 'bookmark_folders.create',
|
||||
defaultMessage: 'Create folder',
|
||||
},
|
||||
});
|
||||
|
||||
const Bookmarks: React.FC<{
|
||||
|
|
@ -28,19 +42,38 @@ const Bookmarks: React.FC<{
|
|||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const { folderId } = useParams<{ folderId?: string }>();
|
||||
const statusIds = useAppSelector((state) =>
|
||||
getStatusList(state, 'bookmarks'),
|
||||
folderId
|
||||
? getBookmarkFolderStatusList(state, folderId)
|
||||
: getStatusList(state, 'bookmarks'),
|
||||
);
|
||||
const folders = useAppSelector((state) => getOrderedBookmarkFolders(state));
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
state.status_lists.getIn(['bookmarks', 'isLoading'], true) as boolean,
|
||||
(folderId
|
||||
? state.status_lists.getIn(
|
||||
['bookmark_folders', folderId, 'isLoading'],
|
||||
true,
|
||||
)
|
||||
: state.status_lists.getIn(
|
||||
['bookmarks', 'isLoading'],
|
||||
true,
|
||||
)) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.status_lists.getIn(['bookmarks', 'next']),
|
||||
(state) =>
|
||||
!!(folderId
|
||||
? state.status_lists.getIn(['bookmark_folders', folderId, 'next'])
|
||||
: state.status_lists.getIn(['bookmarks', 'next'])),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchBookmarkedStatuses());
|
||||
dispatch(fetchBookmarkedStatuses(folderId));
|
||||
}, [dispatch, folderId]);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchBookmarkFolders());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
|
|
@ -62,13 +95,35 @@ const Bookmarks: React.FC<{
|
|||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
if (folderId) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_DELETE_BOOKMARK_FOLDER',
|
||||
modalProps: { id: folderId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, folderId]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch(expandBookmarkedStatuses());
|
||||
}, [dispatch]);
|
||||
dispatch(expandBookmarkedStatuses(folderId));
|
||||
}, [dispatch, folderId]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
const currentFolder = folderId
|
||||
? folders.find((folder) => folder.id === folderId)
|
||||
: null;
|
||||
const currentFolderLabel = folderId
|
||||
? (currentFolder?.title ?? folderId)
|
||||
: intl.formatMessage(messages.allBookmarks);
|
||||
|
||||
const emptyMessage = (
|
||||
const emptyMessage = folderId ? (
|
||||
<FormattedMessage
|
||||
id='empty_column.bookmark_folder'
|
||||
defaultMessage='No bookmarks in this folder yet.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='empty_column.bookmarked_statuses'
|
||||
defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here."
|
||||
|
|
@ -79,23 +134,65 @@ const Bookmarks: React.FC<{
|
|||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
label={currentFolderLabel}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
title={currentFolderLabel}
|
||||
onPin={handlePin}
|
||||
onMove={handleMove}
|
||||
onClick={handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
extraButton={
|
||||
!folderId ? (
|
||||
<Link
|
||||
to='/bookmarks/folders/new'
|
||||
className='column-header__button'
|
||||
title={intl.formatMessage(messages.createFolder)}
|
||||
aria-label={intl.formatMessage(messages.createFolder)}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{folderId && (
|
||||
<div className='column-settings'>
|
||||
<section className='column-header__links'>
|
||||
<Link
|
||||
to={`/bookmarks/folders/${folderId}/edit`}
|
||||
className='text-btn column-header__setting-btn'
|
||||
>
|
||||
<Icon id='pencil' icon={EditIcon} />{' '}
|
||||
<FormattedMessage
|
||||
id='bookmark_folders.edit'
|
||||
defaultMessage='Edit folder'
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
className='text-btn column-header__setting-btn'
|
||||
tabIndex={0}
|
||||
onClick={handleDeleteClick}
|
||||
>
|
||||
<Icon id='trash' icon={DeleteIcon} />{' '}
|
||||
<FormattedMessage
|
||||
id='bookmark_folders.delete'
|
||||
defaultMessage='Delete folder'
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</ColumnHeader>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
scrollKey={`bookmarked_statuses-${folderId ?? 'all'}-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
|
|
@ -105,7 +202,11 @@ const Bookmarks: React.FC<{
|
|||
/>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<title>
|
||||
{folderId
|
||||
? `${currentFolderLabel} - ${intl.formatMessage(messages.heading)}`
|
||||
: currentFolderLabel}
|
||||
</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
|
||||
import { fetchBookmarkFolders } from 'mastodon/actions/bookmark_folders_typed';
|
||||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||
import { getOrderedBookmarkFolders } from 'mastodon/selectors/bookmark_folders';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { CollapsiblePanel } from './collapsible_panel';
|
||||
|
||||
const messages = defineMessages({
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
expand: {
|
||||
id: 'navigation_panel.expand_bookmark_folders',
|
||||
defaultMessage: 'Expand bookmark folders',
|
||||
},
|
||||
collapse: {
|
||||
id: 'navigation_panel.collapse_bookmark_folders',
|
||||
defaultMessage: 'Collapse bookmark folders',
|
||||
},
|
||||
});
|
||||
|
||||
export const BookmarkFoldersPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const folders = useAppSelector((state) => getOrderedBookmarkFolders(state));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const hasFolders = folders.length > 0;
|
||||
const allBookmarksLink = hasFolders ? (
|
||||
<ColumnLink
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage({
|
||||
id: 'bookmarks.all',
|
||||
defaultMessage: 'All Bookmarks',
|
||||
})}
|
||||
to='/bookmarks'
|
||||
exact
|
||||
transparent
|
||||
/>
|
||||
) : null;
|
||||
const folderLinks = hasFolders
|
||||
? folders.map((folder) => (
|
||||
<ColumnLink
|
||||
icon='bookmarks'
|
||||
key={folder.id}
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={folder.title}
|
||||
to={`/bookmarks/folders/${folder.id}`}
|
||||
transparent
|
||||
/>
|
||||
))
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchBookmarkFolders()).then(() => {
|
||||
setLoading(false);
|
||||
|
||||
return '';
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
to={hasFolders ? '/bookmarks/folders' : '/bookmarks'}
|
||||
activePath={
|
||||
hasFolders ? ['/bookmarks', '/bookmarks/folders'] : '/bookmarks'
|
||||
}
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
title={intl.formatMessage(messages.bookmarks)}
|
||||
collapseTitle={intl.formatMessage(messages.collapse)}
|
||||
expandTitle={intl.formatMessage(messages.expand)}
|
||||
loading={loading}
|
||||
>
|
||||
{allBookmarksLink}
|
||||
{folderLinks}
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback, useId } from 'react';
|
||||
import { Children, useState, useCallback, useId } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
||||
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
|
||||
|
|
@ -8,8 +9,9 @@ import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
|||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||
|
||||
export const CollapsiblePanel: React.FC<{
|
||||
children: React.ReactNode[];
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
activePath?: string | string[];
|
||||
title: string;
|
||||
collapseTitle: string;
|
||||
expandTitle: string;
|
||||
|
|
@ -20,6 +22,7 @@ export const CollapsiblePanel: React.FC<{
|
|||
}> = ({
|
||||
children,
|
||||
to,
|
||||
activePath,
|
||||
icon,
|
||||
iconComponent,
|
||||
activeIconComponent,
|
||||
|
|
@ -30,6 +33,7 @@ export const CollapsiblePanel: React.FC<{
|
|||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const accessibilityId = useId();
|
||||
const childCount = Children.toArray(children).length;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setExpanded((value) => !value);
|
||||
|
|
@ -41,6 +45,7 @@ export const CollapsiblePanel: React.FC<{
|
|||
<ColumnLink
|
||||
transparent
|
||||
to={to}
|
||||
activePath={activePath}
|
||||
icon={icon}
|
||||
iconComponent={iconComponent}
|
||||
activeIconComponent={activeIconComponent}
|
||||
|
|
@ -48,7 +53,7 @@ export const CollapsiblePanel: React.FC<{
|
|||
id={`${accessibilityId}-title`}
|
||||
/>
|
||||
|
||||
{(loading || children.length > 0) && (
|
||||
{(loading || childCount > 0) && (
|
||||
<>
|
||||
<div className='navigation-panel__list-panel__header__sep' />
|
||||
|
||||
|
|
@ -70,7 +75,7 @@ export const CollapsiblePanel: React.FC<{
|
|||
)}
|
||||
</div>
|
||||
|
||||
{children.length > 0 && expanded && (
|
||||
{childCount > 0 && expanded && (
|
||||
<div
|
||||
className='navigation-panel__list-panel__items'
|
||||
role='region'
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import { useDrag } from '@use-gesture/react';
|
|||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
|
||||
import CollectionsActiveIcon from '@/material-icons/400-24px/category-fill.svg?react';
|
||||
import CollectionsIcon from '@/material-icons/400-24px/category.svg?react';
|
||||
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
|
|
@ -52,6 +50,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
|||
|
||||
import { AnnualReportNavItem } from '../annual_report/nav_item';
|
||||
|
||||
import { BookmarkFoldersPanel } from './components/bookmark_folders_panel';
|
||||
import { DisabledAccountBanner } from './components/disabled_account_banner';
|
||||
import { FollowedTagsPanel } from './components/followed_tags_panel';
|
||||
import { ListPanel } from './components/list_panel';
|
||||
|
|
@ -79,7 +78,6 @@ const messages = defineMessages({
|
|||
},
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
collections: {
|
||||
id: 'navigation_bar.collections',
|
||||
defaultMessage: 'Collections',
|
||||
|
|
@ -202,13 +200,6 @@ const ProfileCard: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const isFirehoseActive = (
|
||||
match: unknown,
|
||||
{ pathname }: { pathname: string },
|
||||
) => {
|
||||
return !!match || pathname.startsWith('/public');
|
||||
};
|
||||
|
||||
const MENU_WIDTH = 284;
|
||||
|
||||
export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
|
|
@ -309,7 +300,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
|||
}
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
isActive={isFirehoseActive}
|
||||
activePath={['/public', '/public/local', '/public/remote']}
|
||||
text={intl.formatMessage(
|
||||
canViewFeed(signedIn, permissions, localLiveFeedAccess) &&
|
||||
canViewFeed(signedIn, permissions, remoteLiveFeedAccess)
|
||||
|
|
@ -350,16 +341,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
|||
text={intl.formatMessage(messages.favourites)}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/bookmarks'
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
</li>
|
||||
<BookmarkFoldersPanel />
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const messages = defineMessages({
|
|||
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
addToBookmarkFolder: { id: 'status.add_to_bookmark_folder', defaultMessage: 'Add to bookmark folder' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
|
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
|
|
@ -77,6 +78,7 @@ class ActionBar extends PureComponent {
|
|||
onReblog: PropTypes.func.isRequired,
|
||||
onFavourite: PropTypes.func.isRequired,
|
||||
onBookmark: PropTypes.func.isRequired,
|
||||
onBookmarkFolder: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onRevokeQuote: PropTypes.func,
|
||||
onQuotePolicyChange: PropTypes.func,
|
||||
|
|
@ -112,6 +114,10 @@ class ActionBar extends PureComponent {
|
|||
this.props.onBookmark(this.props.status, e);
|
||||
};
|
||||
|
||||
handleBookmarkFolderClick = () => {
|
||||
this.props.onBookmarkFolder(this.props.status);
|
||||
};
|
||||
|
||||
handleDeleteClick = () => {
|
||||
this.props.onDelete(this.props.status);
|
||||
};
|
||||
|
|
@ -250,6 +256,9 @@ class ActionBar extends PureComponent {
|
|||
if (signedIn) {
|
||||
menu.push(null);
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.addToBookmarkFolder), action: this.handleBookmarkFolderClick });
|
||||
menu.push(null);
|
||||
|
||||
if (writtenByMe) {
|
||||
if (pinnableStatus) {
|
||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||
|
|
|
|||
|
|
@ -242,6 +242,13 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleBookmarkFolderClick = (status) => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'BOOKMARK_FOLDER_ADDER',
|
||||
modalProps: { status },
|
||||
}));
|
||||
};
|
||||
|
||||
handleDeleteClick = (status, withRedraft = false) => {
|
||||
const { dispatch, history } = this.props;
|
||||
|
||||
|
|
@ -608,6 +615,7 @@ class Status extends ImmutablePureComponent {
|
|||
onFavourite={this.handleFavouriteClick}
|
||||
onReblog={this.handleReblogClick}
|
||||
onBookmark={this.handleBookmarkClick}
|
||||
onBookmarkFolder={this.handleBookmarkFolderClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onRevokeQuote={this.handleRevokeQuoteClick}
|
||||
onQuotePolicyChange={this.handleQuotePolicyChange}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import classNames from 'classnames';
|
||||
import { useRouteMatch, NavLink } from 'react-router-dom';
|
||||
import { matchPath, useLocation, NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
|
|
@ -9,13 +9,14 @@ export const ColumnLink: React.FC<{
|
|||
iconComponent?: IconProp;
|
||||
activeIcon?: React.ReactNode;
|
||||
activeIconComponent?: IconProp;
|
||||
isActive?: (match: unknown, location: { pathname: string }) => boolean;
|
||||
text: string;
|
||||
to?: string;
|
||||
activePath?: string | string[];
|
||||
href?: string;
|
||||
method?: string;
|
||||
badge?: React.ReactNode;
|
||||
transparent?: boolean;
|
||||
exact?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}> = ({
|
||||
|
|
@ -25,15 +26,22 @@ export const ColumnLink: React.FC<{
|
|||
activeIconComponent,
|
||||
text,
|
||||
to,
|
||||
activePath,
|
||||
href,
|
||||
method,
|
||||
badge,
|
||||
transparent,
|
||||
exact,
|
||||
...other
|
||||
}) => {
|
||||
const match = useRouteMatch(to ?? '');
|
||||
const location = useLocation();
|
||||
const match = to
|
||||
? matchPath(location.pathname, { path: activePath ?? to, exact })
|
||||
: null;
|
||||
const active = !!match;
|
||||
const className = classNames('column-link', {
|
||||
'column-link--transparent': transparent,
|
||||
active,
|
||||
});
|
||||
const badgeElement =
|
||||
typeof badge !== 'undefined' ? (
|
||||
|
|
@ -59,8 +67,6 @@ export const ColumnLink: React.FC<{
|
|||
) : (
|
||||
iconElement
|
||||
));
|
||||
const active = !!match;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className={className} data-method={method} {...other}>
|
||||
|
|
@ -71,7 +77,7 @@ export const ColumnLink: React.FC<{
|
|||
);
|
||||
} else if (to) {
|
||||
return (
|
||||
<NavLink to={to} className={className} {...other}>
|
||||
<NavLink to={to} className={className} exact={exact} {...other}>
|
||||
{active ? activeIconElement : iconElement}
|
||||
<span>{text}</span>
|
||||
{badgeElement}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
import { deleteBookmarkFolder } from 'mastodon/actions/bookmark_folders_typed';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteBookmarkFolderTitle: {
|
||||
id: 'confirmations.delete_bookmark_folder.title',
|
||||
defaultMessage: 'Delete bookmark folder?',
|
||||
},
|
||||
deleteBookmarkFolderMessage: {
|
||||
id: 'confirmations.delete_bookmark_folder.message',
|
||||
defaultMessage:
|
||||
'Are you sure you want to permanently delete this bookmark folder?',
|
||||
},
|
||||
deleteBookmarkFolderConfirm: {
|
||||
id: 'confirmations.delete_bookmark_folder.confirm',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
});
|
||||
|
||||
export const ConfirmDeleteBookmarkFolderModal: React.FC<
|
||||
{
|
||||
id: string;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ id, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
void dispatch(deleteBookmarkFolder({ id }));
|
||||
history.push('/bookmarks/folders');
|
||||
}, [dispatch, history, id]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.deleteBookmarkFolderTitle)}
|
||||
message={intl.formatMessage(messages.deleteBookmarkFolderMessage)}
|
||||
confirm={intl.formatMessage(messages.deleteBookmarkFolderConfirm)}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@ export type { BaseConfirmationModalProps } from './confirmation_modal';
|
|||
export { ConfirmationModal } from './confirmation_modal';
|
||||
export { ConfirmDeleteStatusModal } from './delete_status';
|
||||
export { ConfirmDeleteListModal } from './delete_list';
|
||||
export { ConfirmDeleteBookmarkFolderModal } from './delete_bookmark_folder';
|
||||
export { ConfirmDeleteCollectionModal } from './delete_collection';
|
||||
export {
|
||||
ConfirmReplyModal,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
ReportCollectionModal,
|
||||
EmbedModal,
|
||||
ListAdder,
|
||||
BookmarkFolderAdder,
|
||||
CompareHistoryModal,
|
||||
FilterModal,
|
||||
InteractionModal,
|
||||
|
|
@ -30,6 +31,7 @@ import {
|
|||
ConfirmationModal,
|
||||
ConfirmDeleteStatusModal,
|
||||
ConfirmDeleteListModal,
|
||||
ConfirmDeleteBookmarkFolderModal,
|
||||
ConfirmDeleteCollectionModal,
|
||||
ConfirmReplyModal,
|
||||
ConfirmEditStatusModal,
|
||||
|
|
@ -59,6 +61,7 @@ export const MODAL_COMPONENTS = {
|
|||
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
|
||||
'CONFIRM_DELETE_STATUS': () => Promise.resolve({ default: ConfirmDeleteStatusModal }),
|
||||
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
|
||||
'CONFIRM_DELETE_BOOKMARK_FOLDER': () => Promise.resolve({ default: ConfirmDeleteBookmarkFolderModal }),
|
||||
'CONFIRM_DELETE_COLLECTION': () => Promise.resolve({ default: ConfirmDeleteCollectionModal }),
|
||||
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
|
||||
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
|
||||
|
|
@ -84,6 +87,7 @@ export const MODAL_COMPONENTS = {
|
|||
'EMBED': EmbedModal,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),
|
||||
'LIST_ADDER': ListAdder,
|
||||
'BOOKMARK_FOLDER_ADDER': BookmarkFolderAdder,
|
||||
'COMPARE_HISTORY': CompareHistoryModal,
|
||||
'FILTER': FilterModal,
|
||||
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ import {
|
|||
FollowRequests,
|
||||
FavouritedStatuses,
|
||||
BookmarkedStatuses,
|
||||
BookmarkFolders,
|
||||
BookmarkFolderEdit,
|
||||
FollowedTags,
|
||||
LinkTimeline,
|
||||
ListTimeline,
|
||||
|
|
@ -219,7 +221,11 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||
|
||||
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
||||
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} exact />
|
||||
<WrappedRoute path='/bookmarks/folders/new' component={BookmarkFolderEdit} content={children} />
|
||||
<WrappedRoute path='/bookmarks/folders/:id/edit' component={BookmarkFolderEdit} content={children} />
|
||||
<WrappedRoute path='/bookmarks/folders' component={BookmarkFolders} content={children} exact />
|
||||
<WrappedRoute path='/bookmarks/folders/:folderId' component={BookmarkedStatuses} content={children} />
|
||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
||||
|
||||
<WrappedRoute path='/start/profile' exact component={OnboardingProfile} content={children} />
|
||||
|
|
|
|||
|
|
@ -134,6 +134,14 @@ export function BookmarkedStatuses () {
|
|||
return import('../../bookmarked_statuses');
|
||||
}
|
||||
|
||||
export function BookmarkFolders () {
|
||||
return import('../../bookmark_folders');
|
||||
}
|
||||
|
||||
export function BookmarkFolderEdit () {
|
||||
return import('../../bookmark_folders/new');
|
||||
}
|
||||
|
||||
export function Blocks () {
|
||||
return import('../../blocks');
|
||||
}
|
||||
|
|
@ -187,6 +195,10 @@ export function ListAdder () {
|
|||
return import('../../list_adder');
|
||||
}
|
||||
|
||||
export function BookmarkFolderAdder () {
|
||||
return import('../../bookmark_folder_adder');
|
||||
}
|
||||
|
||||
export function Tesseract () {
|
||||
return import('tesseract.js');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,6 +337,15 @@
|
|||
"block_modal.they_will_know": "They can see that they're blocked.",
|
||||
"block_modal.title": "Block user?",
|
||||
"block_modal.you_wont_see_mentions": "You won't see posts from others that mention them.",
|
||||
"bookmark_folders.add_to_folder": "Add to bookmark folder",
|
||||
"bookmark_folders.create": "Create folder",
|
||||
"bookmark_folders.delete": "Delete folder",
|
||||
"bookmark_folders.edit": "Edit folder",
|
||||
"bookmark_folders.folder_name": "Folder name",
|
||||
"bookmark_folders.new_folder_name": "New folder name",
|
||||
"bookmark_folders.no_folder": "No folder",
|
||||
"bookmark_folders.save": "Save",
|
||||
"bookmarks.all": "All bookmarks",
|
||||
"boost_modal.combo": "You can press {combo} to skip this next time",
|
||||
"boost_modal.reblog": "Boost post?",
|
||||
"boost_modal.undo_reblog": "Unboost post?",
|
||||
|
|
@ -448,12 +457,15 @@
|
|||
"collections.visibility_unlisted_hint": "Visible to anyone with a link. Hidden from search results and recommendations.",
|
||||
"column.about": "About",
|
||||
"column.blocks": "Blocked users",
|
||||
"column.bookmark_folders": "Bookmark folders",
|
||||
"column.bookmarks": "Bookmarks",
|
||||
"column.community": "Local timeline",
|
||||
"column.create_bookmark_folder": "Create folder",
|
||||
"column.create_list": "Create list",
|
||||
"column.direct": "Private mentions",
|
||||
"column.directory": "Browse profiles",
|
||||
"column.domain_blocks": "Blocked domains",
|
||||
"column.edit_bookmark_folder": "Edit folder",
|
||||
"column.edit_list": "Edit list",
|
||||
"column.favourites": "Favorites",
|
||||
"column.firehose": "Live feeds",
|
||||
|
|
@ -515,6 +527,9 @@
|
|||
"confirmations.delete.confirm": "Delete",
|
||||
"confirmations.delete.message": "Are you sure you want to delete this post?",
|
||||
"confirmations.delete.title": "Delete post?",
|
||||
"confirmations.delete_bookmark_folder.confirm": "Delete",
|
||||
"confirmations.delete_bookmark_folder.message": "Are you sure you want to permanently delete this bookmark folder?",
|
||||
"confirmations.delete_bookmark_folder.title": "Delete bookmark folder?",
|
||||
"confirmations.delete_collection.confirm": "Delete",
|
||||
"confirmations.delete_collection.message": "This action cannot be undone.",
|
||||
"confirmations.delete_collection.title": "Delete \"{name}\"?",
|
||||
|
|
@ -645,6 +660,7 @@
|
|||
"empty_column.account_timeline": "No posts here!",
|
||||
"empty_column.account_unavailable": "Profile unavailable",
|
||||
"empty_column.blocks": "You haven't blocked any users yet.",
|
||||
"empty_column.bookmark_folder": "No bookmarks in this folder yet.",
|
||||
"empty_column.bookmarked_statuses": "You don't have any bookmarked posts yet. When you bookmark one, it will show up here.",
|
||||
"empty_column.collections": "{acct} has not created any collections yet.",
|
||||
"empty_column.collections.featured_in": "You have not been added to any collections yet.",
|
||||
|
|
@ -928,8 +944,10 @@
|
|||
"navigation_bar.privacy_and_reach": "Privacy and reach",
|
||||
"navigation_bar.search": "Search",
|
||||
"navigation_bar.search_trends": "Search / Trending",
|
||||
"navigation_panel.collapse_bookmark_folders": "Collapse bookmark folders",
|
||||
"navigation_panel.collapse_followed_tags": "Collapse followed hashtags menu",
|
||||
"navigation_panel.collapse_lists": "Collapse list menu",
|
||||
"navigation_panel.expand_bookmark_folders": "Expand bookmark folders",
|
||||
"navigation_panel.expand_followed_tags": "Expand followed hashtags menu",
|
||||
"navigation_panel.expand_lists": "Expand list menu",
|
||||
"not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
|
||||
|
|
@ -1220,6 +1238,7 @@
|
|||
"skip_links.hotkey": "<span>Hotkey</span> {hotkey}",
|
||||
"skip_links.skip_to_content": "Skip to main content",
|
||||
"skip_links.skip_to_navigation": "Skip to main navigation",
|
||||
"status.add_to_bookmark_folder": "Add to bookmark folder",
|
||||
"status.admin_account": "Open moderation interface for @{name}",
|
||||
"status.admin_domain": "Open moderation interface for {domain}",
|
||||
"status.admin_status": "Open this post in the moderation interface",
|
||||
|
|
|
|||
16
app/javascript/mastodon/models/bookmark_folder.ts
Normal file
16
app/javascript/mastodon/models/bookmark_folder.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { RecordOf } from 'immutable';
|
||||
import { Record } from 'immutable';
|
||||
|
||||
import type { ApiBookmarkFolderJSON } from 'mastodon/api_types/bookmark_folders';
|
||||
|
||||
type BookmarkFolderShape = Required<ApiBookmarkFolderJSON>;
|
||||
export type BookmarkFolder = RecordOf<BookmarkFolderShape>;
|
||||
|
||||
const BookmarkFolderFactory = Record<BookmarkFolderShape>({
|
||||
id: '',
|
||||
title: '',
|
||||
});
|
||||
|
||||
export function createBookmarkFolder(attributes: Partial<BookmarkFolderShape>) {
|
||||
return BookmarkFolderFactory(attributes);
|
||||
}
|
||||
50
app/javascript/mastodon/reducers/bookmark_folders.ts
Normal file
50
app/javascript/mastodon/reducers/bookmark_folders.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { Reducer } from '@reduxjs/toolkit';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
createBookmarkFolder,
|
||||
deleteBookmarkFolder,
|
||||
fetchBookmarkFolder,
|
||||
fetchBookmarkFolders,
|
||||
updateBookmarkFolder,
|
||||
} from 'mastodon/actions/bookmark_folders_typed';
|
||||
import type { ApiBookmarkFolderJSON } from 'mastodon/api_types/bookmark_folders';
|
||||
import { createBookmarkFolder as createBookmarkFolderFromJSON } from 'mastodon/models/bookmark_folder';
|
||||
import type { BookmarkFolder } from 'mastodon/models/bookmark_folder';
|
||||
|
||||
const initialState = ImmutableMap<string, BookmarkFolder | null>();
|
||||
type State = typeof initialState;
|
||||
|
||||
const normalizeFolder = (state: State, folder: ApiBookmarkFolderJSON) =>
|
||||
state.set(folder.id, createBookmarkFolderFromJSON(folder));
|
||||
|
||||
const normalizeFolders = (state: State, folders: ApiBookmarkFolderJSON[]) => {
|
||||
folders.forEach((folder) => {
|
||||
state = normalizeFolder(state, folder);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const bookmarkFoldersReducer: Reducer<State> = (
|
||||
state = initialState,
|
||||
action,
|
||||
) => {
|
||||
if (
|
||||
createBookmarkFolder.fulfilled.match(action) ||
|
||||
updateBookmarkFolder.fulfilled.match(action)
|
||||
) {
|
||||
return normalizeFolder(state, action.payload);
|
||||
}
|
||||
|
||||
if (fetchBookmarkFolder.fulfilled.match(action))
|
||||
return normalizeFolder(state, action.payload);
|
||||
|
||||
if (fetchBookmarkFolders.fulfilled.match(action))
|
||||
return normalizeFolders(state, action.payload);
|
||||
|
||||
if (deleteBookmarkFolder.fulfilled.match(action))
|
||||
return state.delete(action.payload.id);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ import { accountsFamiliarFollowersReducer } from './accounts_familiar_followers'
|
|||
import { accountsMapReducer } from './accounts_map';
|
||||
import { alertsReducer } from './alerts';
|
||||
import announcements from './announcements';
|
||||
import { bookmarkFoldersReducer } from './bookmark_folders';
|
||||
import { composeReducer } from './compose';
|
||||
import { contextsReducer } from './contexts';
|
||||
import conversations from './conversations';
|
||||
|
|
@ -51,6 +52,7 @@ const reducers = {
|
|||
modal: modalReducer,
|
||||
user_lists,
|
||||
status_lists,
|
||||
bookmark_folders: bookmarkFoldersReducer,
|
||||
accounts: accountsReducer,
|
||||
accounts_map: accountsMapReducer,
|
||||
accounts_familiar_followers: accountsFamiliarFollowersReducer,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,15 @@ import {
|
|||
BOOKMARKED_STATUSES_FETCH_REQUEST,
|
||||
BOOKMARKED_STATUSES_FETCH_SUCCESS,
|
||||
BOOKMARKED_STATUSES_FETCH_FAIL,
|
||||
BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST,
|
||||
BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS,
|
||||
BOOKMARK_FOLDER_STATUSES_FETCH_FAIL,
|
||||
BOOKMARKED_STATUSES_EXPAND_REQUEST,
|
||||
BOOKMARKED_STATUSES_EXPAND_SUCCESS,
|
||||
BOOKMARKED_STATUSES_EXPAND_FAIL,
|
||||
BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST,
|
||||
BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS,
|
||||
BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL,
|
||||
} from '../actions/bookmarks';
|
||||
import {
|
||||
FAVOURITED_STATUSES_FETCH_REQUEST,
|
||||
|
|
@ -31,6 +37,9 @@ import {
|
|||
import {
|
||||
fetchQuotes
|
||||
} from '../actions/interactions_typed';
|
||||
import {
|
||||
deleteBookmarkFolder
|
||||
} from '../actions/bookmark_folders_typed';
|
||||
import {
|
||||
PINNED_STATUSES_FETCH_SUCCESS,
|
||||
} from '../actions/pin_statuses';
|
||||
|
|
@ -54,6 +63,7 @@ const initialState = ImmutableMap({
|
|||
loaded: false,
|
||||
items: ImmutableOrderedSet(),
|
||||
}),
|
||||
bookmark_folders: ImmutableMap(),
|
||||
pins: ImmutableMap({
|
||||
next: null,
|
||||
loaded: false,
|
||||
|
|
@ -72,6 +82,13 @@ const initialState = ImmutableMap({
|
|||
}),
|
||||
});
|
||||
|
||||
const defaultListState = ImmutableMap({
|
||||
next: null,
|
||||
loaded: false,
|
||||
items: ImmutableOrderedSet(),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const normalizeList = (state, listType, statuses, next) => {
|
||||
return state.update(listType, listMap => listMap.withMutations(map => {
|
||||
map.set('next', next);
|
||||
|
|
@ -103,6 +120,72 @@ const removeOneFromList = (state, listType, status) => {
|
|||
return state.updateIn([listType, 'items'], (list) => list.delete(status.get('id')));
|
||||
};
|
||||
|
||||
const updateBookmarkFolderList = (state, folderId, updater) => (
|
||||
state.update('bookmark_folders', (listMap) => (
|
||||
listMap.update(folderId, defaultListState, updater)
|
||||
))
|
||||
);
|
||||
|
||||
const normalizeFolderList = (state, folderId, statuses, next) => (
|
||||
updateBookmarkFolderList(state, folderId, 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 appendFolderList = (state, folderId, statuses, next) => (
|
||||
updateBookmarkFolderList(state, folderId, listMap => listMap.withMutations(map => {
|
||||
map.set('next', next);
|
||||
map.set('isLoading', false);
|
||||
map.set('items', map.get('items').union(statuses.map(item => item.id)));
|
||||
}))
|
||||
);
|
||||
|
||||
const prependOneToFolderList = (state, folderId, status) => (
|
||||
updateBookmarkFolderList(state, folderId, listMap => (
|
||||
listMap.update('items', (list) => {
|
||||
if (list.includes(status.get('id'))) {
|
||||
return list;
|
||||
} else {
|
||||
return ImmutableOrderedSet([status.get('id')]).union(list);
|
||||
}
|
||||
})
|
||||
))
|
||||
);
|
||||
|
||||
const removeOneFromFolderList = (state, folderId, status) => (
|
||||
updateBookmarkFolderList(state, folderId, listMap => (
|
||||
listMap.update('items', (list) => list.delete(status.get('id')))
|
||||
))
|
||||
);
|
||||
|
||||
|
||||
const handleBookmarkSuccess = (state, action) => {
|
||||
const previousFolderId = action.status.get('bookmark_folder_id');
|
||||
const nextFolderId = action.response?.bookmark_folder_id ?? null;
|
||||
let nextState = prependOneToList(state, 'bookmarks', action.status);
|
||||
|
||||
if (previousFolderId && previousFolderId !== nextFolderId)
|
||||
nextState = removeOneFromFolderList(nextState, previousFolderId, action.status);
|
||||
|
||||
if (nextFolderId)
|
||||
nextState = prependOneToFolderList(nextState, nextFolderId, action.status);
|
||||
|
||||
return nextState;
|
||||
};
|
||||
|
||||
const handleUnbookmarkSuccess = (state, action) => {
|
||||
const previousFolderId = action.status.get('bookmark_folder_id');
|
||||
let nextState = removeOneFromList(state, 'bookmarks', action.status);
|
||||
|
||||
if (previousFolderId)
|
||||
nextState = removeOneFromFolderList(nextState, previousFolderId, action.status);
|
||||
|
||||
return nextState;
|
||||
};
|
||||
|
||||
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||
export default function statusLists(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
|
|
@ -119,13 +202,23 @@ export default function statusLists(state = initialState, action) {
|
|||
case BOOKMARKED_STATUSES_FETCH_REQUEST:
|
||||
case BOOKMARKED_STATUSES_EXPAND_REQUEST:
|
||||
return state.setIn(['bookmarks', 'isLoading'], true);
|
||||
case BOOKMARK_FOLDER_STATUSES_FETCH_REQUEST:
|
||||
case BOOKMARK_FOLDER_STATUSES_EXPAND_REQUEST:
|
||||
return updateBookmarkFolderList(state, action.folderId, listMap => listMap.set('isLoading', true));
|
||||
case BOOKMARKED_STATUSES_FETCH_FAIL:
|
||||
case BOOKMARKED_STATUSES_EXPAND_FAIL:
|
||||
return state.setIn(['bookmarks', 'isLoading'], false);
|
||||
case BOOKMARK_FOLDER_STATUSES_FETCH_FAIL:
|
||||
case BOOKMARK_FOLDER_STATUSES_EXPAND_FAIL:
|
||||
return updateBookmarkFolderList(state, action.folderId, listMap => listMap.set('isLoading', false));
|
||||
case BOOKMARKED_STATUSES_FETCH_SUCCESS:
|
||||
return normalizeList(state, 'bookmarks', action.statuses, action.next);
|
||||
case BOOKMARK_FOLDER_STATUSES_FETCH_SUCCESS:
|
||||
return normalizeFolderList(state, action.folderId, action.statuses, action.next);
|
||||
case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
|
||||
return appendToList(state, 'bookmarks', action.statuses, action.next);
|
||||
case BOOKMARK_FOLDER_STATUSES_EXPAND_SUCCESS:
|
||||
return appendFolderList(state, action.folderId, action.statuses, action.next);
|
||||
case TRENDS_STATUSES_FETCH_REQUEST:
|
||||
case TRENDS_STATUSES_EXPAND_REQUEST:
|
||||
return state.setIn(['trending', 'isLoading'], true);
|
||||
|
|
@ -141,9 +234,9 @@ export default function statusLists(state = initialState, action) {
|
|||
case UNFAVOURITE_SUCCESS:
|
||||
return removeOneFromList(state, 'favourites', action.status);
|
||||
case BOOKMARK_SUCCESS:
|
||||
return prependOneToList(state, 'bookmarks', action.status);
|
||||
return handleBookmarkSuccess(state, action);
|
||||
case UNBOOKMARK_SUCCESS:
|
||||
return removeOneFromList(state, 'bookmarks', action.status);
|
||||
return handleUnbookmarkSuccess(state, action);
|
||||
case PINNED_STATUSES_FETCH_SUCCESS:
|
||||
return normalizeList(state, 'pins', action.statuses, action.next);
|
||||
case PIN_SUCCESS:
|
||||
|
|
@ -154,7 +247,9 @@ export default function statusLists(state = initialState, action) {
|
|||
case muteAccountSuccess.type:
|
||||
return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.payload.statuses.getIn([statusId, 'account']) === action.payload.relationship.id));
|
||||
default:
|
||||
if (fetchQuotes.fulfilled.match(action))
|
||||
if (deleteBookmarkFolder.fulfilled.match(action))
|
||||
return state.update('bookmark_folders', map => map.delete(action.payload.id));
|
||||
else if (fetchQuotes.fulfilled.match(action))
|
||||
return normalizeList(state, 'quotes', action.payload.statuses, action.payload.next).set('statusId', action.meta.arg.statusId);
|
||||
else if (fetchQuotes.pending.match(action))
|
||||
return state.setIn(['quotes', 'isLoading'], true).setIn(['quotes', 'statusId'], action.meta.arg.statusId);
|
||||
|
|
|
|||
17
app/javascript/mastodon/selectors/bookmark_folders.ts
Normal file
17
app/javascript/mastodon/selectors/bookmark_folders.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { BookmarkFolder } from 'mastodon/models/bookmark_folder';
|
||||
import { createAppSelector } from 'mastodon/store';
|
||||
|
||||
const getBookmarkFolders = createAppSelector(
|
||||
[(state) => state.bookmark_folders],
|
||||
(
|
||||
folders: ImmutableMap<string, BookmarkFolder | null>,
|
||||
): ImmutableList<BookmarkFolder> =>
|
||||
folders.toList().filter((item): item is BookmarkFolder => !!item),
|
||||
);
|
||||
|
||||
export const getOrderedBookmarkFolders = createAppSelector(
|
||||
[(state) => getBookmarkFolders(state)],
|
||||
(folders) => folders.sort((a, b) => a.title.localeCompare(b.title)).toArray(),
|
||||
);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
|
|
@ -13,3 +13,15 @@ export const getStatusList = createSelector(
|
|||
],
|
||||
(items) => items.toList(),
|
||||
);
|
||||
|
||||
// Per-folder bookmark list selector
|
||||
export const getBookmarkFolderStatusList = createSelector(
|
||||
[
|
||||
(state: RootState, folderId: string) =>
|
||||
state.status_lists.getIn(
|
||||
['bookmark_folders', folderId, 'items'],
|
||||
ImmutableOrderedSet<string>(),
|
||||
) as ImmutableOrderedSet<string>,
|
||||
],
|
||||
(items) => items.toList(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,9 +34,10 @@ class StatusCacheHydrator
|
|||
|
||||
def hydrate_reblog_payload(empty_payload, account, nested: false)
|
||||
empty_payload.tap do |payload|
|
||||
payload[:muted] = false
|
||||
payload[:bookmarked] = false
|
||||
payload[:pinned] = false if @status.account_id == account.id
|
||||
payload[:muted] = false
|
||||
payload[:bookmarked] = false
|
||||
payload[:bookmark_folder_id] = nil
|
||||
payload[:pinned] = false if @status.account_id == account.id
|
||||
|
||||
# If the reblogged status is being delivered to the author who disabled the display of the application
|
||||
# used to create the status, we need to hydrate it here too
|
||||
|
|
@ -53,12 +54,13 @@ class StatusCacheHydrator
|
|||
end
|
||||
|
||||
def fill_status_payload(payload, status, account, nested: false, fresh: true)
|
||||
payload[:favourited] = Favourite.exists?(account_id: account.id, status_id: status.id)
|
||||
payload[:reblogged] = Status.exists?(account_id: account.id, reblog_of_id: status.id)
|
||||
payload[:muted] = ConversationMute.exists?(account_id: account.id, conversation_id: status.conversation_id)
|
||||
payload[:bookmarked] = Bookmark.exists?(account_id: account.id, status_id: status.id)
|
||||
payload[:pinned] = StatusPin.exists?(account_id: account.id, status_id: status.id) if status.account_id == account.id
|
||||
payload[:filtered] = mapped_applied_custom_filter(account, status)
|
||||
payload[:favourited] = Favourite.exists?(account_id: account.id, status_id: status.id)
|
||||
payload[:reblogged] = Status.exists?(account_id: account.id, reblog_of_id: status.id)
|
||||
payload[:muted] = ConversationMute.exists?(account_id: account.id, conversation_id: status.conversation_id)
|
||||
payload[:bookmarked] = Bookmark.exists?(account_id: account.id, status_id: status.id)
|
||||
payload[:bookmark_folder_id] = payload[:bookmarked] ? Bookmark.where(account_id: account.id, status_id: status.id).pick(:folder_id)&.to_s : nil
|
||||
payload[:pinned] = StatusPin.exists?(account_id: account.id, status_id: status.id) if status.account_id == account.id
|
||||
payload[:filtered] = mapped_applied_custom_filter(account, status)
|
||||
payload[:quote_approval][:current_user] = status.quote_policy_for_account(account) if payload[:quote_approval]
|
||||
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account, nested:) if payload[:quote]
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# folder_id :bigint(8)
|
||||
# status_id :bigint(8) not null
|
||||
#
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ class Bookmark < ApplicationRecord
|
|||
|
||||
belongs_to :account, inverse_of: :bookmarks
|
||||
belongs_to :status, inverse_of: :bookmarks
|
||||
belongs_to :bookmark_folder, foreign_key: :folder_id, optional: true, inverse_of: :bookmarks
|
||||
|
||||
validates :status_id, uniqueness: { scope: :account_id }
|
||||
|
||||
|
|
|
|||
33
app/models/bookmark_folder.rb
Normal file
33
app/models/bookmark_folder.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: bookmark_folders
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# title :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class BookmarkFolder < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
PER_ACCOUNT_LIMIT = 50
|
||||
TITLE_LENGTH_LIMIT = 256
|
||||
|
||||
belongs_to :account
|
||||
|
||||
has_many :bookmarks, foreign_key: 'folder_id', dependent: :nullify, inverse_of: :bookmark_folder
|
||||
|
||||
validates :title, presence: true, length: { maximum: TITLE_LENGTH_LIMIT }
|
||||
|
||||
validate :validate_account_folders_limit, on: :create
|
||||
|
||||
private
|
||||
|
||||
def validate_account_folders_limit
|
||||
errors.add(:base, I18n.t('bookmark_folders.errors.limit')) if account.bookmark_folders.count >= PER_ACCOUNT_LIMIT
|
||||
end
|
||||
end
|
||||
|
|
@ -15,6 +15,7 @@ module Account::Associations
|
|||
has_many :action_logs, class_name: 'Admin::ActionLog'
|
||||
has_many :aliases, class_name: 'AccountAlias'
|
||||
has_many :bookmarks
|
||||
has_many :bookmark_folders
|
||||
has_many :collections
|
||||
has_many :collection_items
|
||||
has_many :curated_collection_items, through: :collections, class_name: 'CollectionItem', source: :collection_items
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ class StatusRelationshipsPresenter
|
|||
PINNABLE_VISIBILITIES = %w(public unlisted private).freeze
|
||||
|
||||
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
|
||||
:bookmarks_map, :filters_map, :attributes_map
|
||||
:bookmark_folders_map, :bookmarks_map, :filters_map,
|
||||
:attributes_map
|
||||
|
||||
def initialize(statuses, current_account_id = nil, **options)
|
||||
@current_account_id = current_account_id
|
||||
|
|
@ -15,13 +16,14 @@ class StatusRelationshipsPresenter
|
|||
|
||||
if current_account_id.nil?
|
||||
@preloaded_account_relations = {}
|
||||
@filters_map = {}
|
||||
@reblogs_map = {}
|
||||
@favourites_map = {}
|
||||
@bookmarks_map = {}
|
||||
@mutes_map = {}
|
||||
@pins_map = {}
|
||||
@attributes_map = {}
|
||||
@filters_map = {}
|
||||
@reblogs_map = {}
|
||||
@favourites_map = {}
|
||||
@bookmarks_map = {}
|
||||
@bookmark_folders_map = {}
|
||||
@mutes_map = {}
|
||||
@pins_map = {}
|
||||
@attributes_map = {}
|
||||
else
|
||||
@preloaded_account_relations = nil
|
||||
|
||||
|
|
@ -33,7 +35,10 @@ class StatusRelationshipsPresenter
|
|||
@filters_map = build_filters_map(statuses.flat_map { |s| [s, s.proper.quote&.quoted_status] }.compact.uniq, current_account_id).merge(options[:filters_map] || {})
|
||||
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
|
||||
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
|
||||
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
|
||||
|
||||
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
|
||||
@bookmark_folders_map = Bookmark.where(account_id: current_account_id, status_id: status_ids).pluck(:status_id, :folder_id).to_h { |id, folder_id| [id, folder_id] }.merge(options[:bookmark_folders_map] || {})
|
||||
|
||||
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
|
||||
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
|
||||
@attributes_map = options[:attributes_map] || {}
|
||||
|
|
|
|||
9
app/serializers/rest/bookmark_folder_serializer.rb
Normal file
9
app/serializers/rest/bookmark_folder_serializer.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::BookmarkFolderSerializer < ActiveModel::Serializer
|
||||
attributes :id, :title
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
end
|
||||
|
|
@ -14,6 +14,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
attribute :reblogged, if: :current_user?
|
||||
attribute :muted, if: :current_user?
|
||||
attribute :bookmarked, if: :current_user?
|
||||
attribute :bookmark_folder_id, if: :current_user?
|
||||
attribute :pinned, if: :pinnable?
|
||||
has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user?
|
||||
|
||||
|
|
@ -136,6 +137,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
end
|
||||
end
|
||||
|
||||
def bookmark_folder_id
|
||||
if relationships
|
||||
relationships.bookmark_folders_map[object.id]&.to_s
|
||||
else
|
||||
current_user&.account&.bookmarks&.find_by(status_id: object.id)&.folder_id&.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def pinned
|
||||
if relationships
|
||||
relationships.pins_map[object.id] || false
|
||||
|
|
|
|||
|
|
@ -343,6 +343,9 @@ en:
|
|||
unpublish: Unpublish
|
||||
unpublished_msg: Announcement successfully unpublished!
|
||||
updated_msg: Announcement successfully updated!
|
||||
bookmark_folders:
|
||||
errors:
|
||||
limit: You have reached the maximum number of bookmark folders
|
||||
collections:
|
||||
accounts: Accounts
|
||||
back_to_account: Back to account page
|
||||
|
|
|
|||
|
|
@ -271,6 +271,16 @@ namespace :api, format: false do
|
|||
resources :votes, only: :create, module: :polls
|
||||
end
|
||||
|
||||
resources :bookmark_folders, only: [:index, :show, :create, :update, :destroy]
|
||||
|
||||
resources :bookmarks, only: [:index] do
|
||||
collection do
|
||||
scope module: :bookmark_folders, controller: :bookmarks do
|
||||
get 'folders/:bookmark_folder_id', action: :index, as: :folder
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :push do
|
||||
resource :subscription, only: [:create, :show, :update, :destroy]
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
%w(
|
||||
/blocks
|
||||
/bookmarks
|
||||
/bookmarks/(*any)
|
||||
/collections/(*any)
|
||||
/conversations
|
||||
/deck/(*any)
|
||||
|
|
|
|||
12
db/migrate/20260503121707_create_bookmark_folders.rb
Normal file
12
db/migrate/20260503121707_create_bookmark_folders.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateBookmarkFolders < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :bookmark_folders do |t|
|
||||
t.references :account, null: false, foreign_key: { on_delete: :cascade }
|
||||
t.string :title, null: false, default: ''
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
9
db/migrate/20260503141932_add_folder_to_bookmarks.rb
Normal file
9
db/migrate/20260503141932_add_folder_to_bookmarks.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddFolderToBookmarks < ActiveRecord::Migration[8.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_reference :bookmarks, :folder, null: true, index: { algorithm: :concurrently }
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddForeignKeyToBookmarksFolders < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_foreign_key :bookmarks, :bookmark_folders, column: :folder_id, on_delete: :nullify, validate: false
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ValidateForeignKeyOnBookmarksFolders < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
validate_foreign_key :bookmarks, :bookmark_folders, column: :folder_id
|
||||
end
|
||||
end
|
||||
30
db/schema.rb
30
db/schema.rb
|
|
@ -315,12 +315,22 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
|
|||
t.index ["target_account_id"], name: "index_blocks_on_target_account_id"
|
||||
end
|
||||
|
||||
create_table "bookmark_folders", force: :cascade do |t|
|
||||
t.bigint "account_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "title", default: "", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_bookmark_folders_on_account_id"
|
||||
end
|
||||
|
||||
create_table "bookmarks", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.bigint "folder_id"
|
||||
t.bigint "status_id", null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.index ["account_id", "status_id"], name: "index_bookmarks_on_account_id_and_status_id", unique: true
|
||||
t.index ["folder_id"], name: "index_bookmarks_on_folder_id"
|
||||
t.index ["status_id"], name: "index_bookmarks_on_status_id"
|
||||
end
|
||||
|
||||
|
|
@ -1483,7 +1493,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
|
|||
add_foreign_key "backups", "users", on_delete: :nullify
|
||||
add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade
|
||||
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
|
||||
add_foreign_key "bookmark_folders", "accounts", on_delete: :cascade
|
||||
add_foreign_key "bookmarks", "accounts", on_delete: :cascade
|
||||
add_foreign_key "bookmarks", "bookmark_folders", column: "folder_id", on_delete: :nullify
|
||||
add_foreign_key "bookmarks", "statuses", on_delete: :cascade
|
||||
add_foreign_key "bulk_import_rows", "bulk_imports", on_delete: :cascade
|
||||
add_foreign_key "bulk_imports", "accounts", on_delete: :cascade
|
||||
|
|
@ -1623,9 +1635,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
|
|||
add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
|
||||
|
||||
create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
|
||||
SELECT account_id,
|
||||
sum(rank) AS rank,
|
||||
array_agg(reason) AS reason
|
||||
SELECT t0.account_id,
|
||||
sum(t0.rank) AS rank,
|
||||
array_agg(t0.reason) AS reason
|
||||
FROM ( SELECT account_summaries.account_id,
|
||||
((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
|
||||
'most_followed'::text AS reason
|
||||
|
|
@ -1649,8 +1661,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
|
|||
WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
|
||||
GROUP BY account_summaries.account_id
|
||||
HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
|
||||
GROUP BY account_id
|
||||
ORDER BY (sum(rank)) DESC;
|
||||
GROUP BY t0.account_id
|
||||
ORDER BY (sum(t0.rank)) DESC;
|
||||
SQL
|
||||
add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true
|
||||
|
||||
|
|
@ -1680,9 +1692,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
|
|||
add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
|
||||
|
||||
create_view "user_ips", sql_definition: <<-SQL
|
||||
SELECT user_id,
|
||||
ip,
|
||||
max(used_at) AS used_at
|
||||
SELECT t0.user_id,
|
||||
t0.ip,
|
||||
max(t0.used_at) AS used_at
|
||||
FROM ( SELECT users.id AS user_id,
|
||||
users.sign_up_ip AS ip,
|
||||
users.created_at AS used_at
|
||||
|
|
@ -1699,6 +1711,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
|
|||
login_activities.created_at
|
||||
FROM login_activities
|
||||
WHERE (login_activities.success = true)) t0
|
||||
GROUP BY user_id, ip;
|
||||
GROUP BY t0.user_id, t0.ip;
|
||||
SQL
|
||||
end
|
||||
|
|
|
|||
6
spec/fabricators/bookmark_folder_fabricator.rb
Normal file
6
spec/fabricators/bookmark_folder_fabricator.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:bookmark_folder) do
|
||||
account { Fabricate.build(:account) }
|
||||
title 'MyString'
|
||||
end
|
||||
36
spec/models/bookmark_folder_spec.rb
Normal file
36
spec/models/bookmark_folder_spec.rb
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe BookmarkFolder do
|
||||
describe 'Associations' do
|
||||
it { is_expected.to have_many(:bookmarks).with_foreign_key('folder_id').dependent(:nullify) }
|
||||
end
|
||||
|
||||
describe 'Validations' do
|
||||
subject { Fabricate.build :bookmark_folder }
|
||||
|
||||
it { is_expected.to validate_presence_of(:title) }
|
||||
it { is_expected.to validate_length_of(:title).is_at_most(described_class::TITLE_LENGTH_LIMIT) }
|
||||
|
||||
context 'when account has hit max folder limit' do
|
||||
let(:account) { Fabricate :account }
|
||||
|
||||
before do
|
||||
stub_const 'BookmarkFolder::PER_ACCOUNT_LIMIT', 1
|
||||
|
||||
Fabricate(:bookmark_folder, account: account)
|
||||
end
|
||||
|
||||
context 'when creating a new folder' do
|
||||
it { is_expected.to_not allow_value(account).for(:account).against(:base).with_message(I18n.t('bookmark_folders.errors.limit')) }
|
||||
end
|
||||
|
||||
context 'when updating an existing folder' do
|
||||
before { subject.save(validate: false) }
|
||||
|
||||
it { is_expected.to allow_value(account).for(:account).against(:base) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,7 @@ RSpec.describe Bookmark do
|
|||
describe 'Associations' do
|
||||
it { is_expected.to belong_to(:account).required }
|
||||
it { is_expected.to belong_to(:status).required }
|
||||
it { is_expected.to belong_to(:bookmark_folder).with_foreign_key(:folder_id).optional.inverse_of(:bookmarks) }
|
||||
end
|
||||
|
||||
describe 'Validations' do
|
||||
|
|
|
|||
103
spec/requests/api/v1/bookmark_folders_spec.rb
Normal file
103
spec/requests/api/v1/bookmark_folders_spec.rb
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'BookmarkFolders' do
|
||||
describe 'GET /api/v1/bookmark_folders' do
|
||||
subject do
|
||||
get '/api/v1/bookmark_folders', headers: headers, params: params
|
||||
end
|
||||
|
||||
include_context 'with API authentication', oauth_scopes: 'read:bookmarks'
|
||||
|
||||
let(:params) { {} }
|
||||
let!(:bookmark_folders) { Fabricate.times(2, :bookmark_folder, account: user.account) }
|
||||
|
||||
let(:expected_response) do
|
||||
bookmark_folders.map do |folder|
|
||||
a_hash_including(id: folder.id.to_s, title: folder.title)
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'forbidden for wrong scope', 'write'
|
||||
|
||||
it 'returns http success and the bookmark folders' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.content_type).to start_with('application/json')
|
||||
expect(response.parsed_body).to match_array(expected_response)
|
||||
end
|
||||
|
||||
context 'with an invalid authorization header' do
|
||||
let(:headers) { { 'Authorization' => 'Bearer token_false' } }
|
||||
|
||||
it 'returns http unauthorized' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(401)
|
||||
expect(response.content_type).to start_with('application/json')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/bookmark_folders' do
|
||||
subject do
|
||||
post '/api/v1/bookmark_folders', headers: headers, params: params
|
||||
end
|
||||
|
||||
include_context 'with API authentication', oauth_scopes: 'write:bookmarks'
|
||||
|
||||
let(:params) { { title: 'New Folder' } }
|
||||
|
||||
it_behaves_like 'forbidden for wrong scope', 'read'
|
||||
|
||||
it 'returns http success and creates the folder' do
|
||||
expect { subject }.to change(user.account.bookmark_folders, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.content_type).to start_with('application/json')
|
||||
expect(response.parsed_body).to include('title' => 'New Folder')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/bookmark_folders/:id' do
|
||||
subject do
|
||||
put "/api/v1/bookmark_folders/#{folder.id}", headers: headers, params: params
|
||||
end
|
||||
|
||||
include_context 'with API authentication', oauth_scopes: 'write:bookmarks'
|
||||
|
||||
let!(:folder) { Fabricate(:bookmark_folder, account: user.account, title: 'Old Name') }
|
||||
let(:params) { { title: 'New Name' } }
|
||||
|
||||
it_behaves_like 'forbidden for wrong scope', 'read'
|
||||
|
||||
it 'returns http success and updates the folder' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(folder.reload.title).to eq('New Name')
|
||||
expect(response.parsed_body).to include('title' => 'New Name')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/bookmark_folders/:id' do
|
||||
subject do
|
||||
delete "/api/v1/bookmark_folders/#{folder.id}", headers: headers
|
||||
end
|
||||
|
||||
include_context 'with API authentication', oauth_scopes: 'write:bookmarks'
|
||||
|
||||
let!(:folder) { Fabricate(:bookmark_folder, account: user.account) }
|
||||
|
||||
it_behaves_like 'forbidden for wrong scope', 'read'
|
||||
|
||||
it 'returns http success and deletes the folder' do
|
||||
expect { subject }.to change(user.account.bookmark_folders, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.parsed_body).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user