Bookmark folders

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-bookmark-f-zlv7hu/deployments/4463
marcin mikołajczak 2024-03-20 23:58:53 +01:00
rodzic 460e22ce2b
commit eceafedec4
31 zmienionych plików z 748 dodań i 60 usunięć

Wyświetl plik

@ -15,70 +15,77 @@ const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL';
const noOp = () => new Promise(f => f(undefined));
const fetchBookmarkedStatuses = () =>
const fetchBookmarkedStatuses = (folderId?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (getState().status_lists.get('bookmarks')?.isLoading) {
if (getState().status_lists.get(folderId ? `bookmarks:${folderId}` : 'bookmarks')?.isLoading) {
return dispatch(noOp);
}
dispatch(fetchBookmarkedStatusesRequest());
dispatch(fetchBookmarkedStatusesRequest(folderId));
return api(getState).get('/api/v1/bookmarks').then(response => {
return api(getState).get(`/api/v1/bookmarks${folderId ? `?folder_id=${folderId}` : ''}`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
return dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
return dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId));
}).catch(error => {
dispatch(fetchBookmarkedStatusesFail(error));
dispatch(fetchBookmarkedStatusesFail(error, folderId));
});
};
const fetchBookmarkedStatusesRequest = () => ({
const fetchBookmarkedStatusesRequest = (folderId?: string) => ({
type: BOOKMARKED_STATUSES_FETCH_REQUEST,
folderId,
});
const fetchBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({
const fetchBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null, folderId?: string) => ({
type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
statuses,
next,
folderId,
});
const fetchBookmarkedStatusesFail = (error: unknown) => ({
const fetchBookmarkedStatusesFail = (error: unknown, folderId?: string) => ({
type: BOOKMARKED_STATUSES_FETCH_FAIL,
error,
folderId,
});
const expandBookmarkedStatuses = () =>
const expandBookmarkedStatuses = (folderId?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const url = getState().status_lists.get('bookmarks')?.next || null;
const list = folderId ? `bookmarks:${folderId}` : 'bookmarks';
const url = getState().status_lists.get(list)?.next || null;
if (url === null || getState().status_lists.get('bookmarks')?.isLoading) {
if (url === null || getState().status_lists.get(list)?.isLoading) {
return dispatch(noOp);
}
dispatch(expandBookmarkedStatusesRequest());
dispatch(expandBookmarkedStatusesRequest(folderId));
return api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
return dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
return dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null, folderId));
}).catch(error => {
dispatch(expandBookmarkedStatusesFail(error));
dispatch(expandBookmarkedStatusesFail(error, folderId));
});
};
const expandBookmarkedStatusesRequest = () => ({
const expandBookmarkedStatusesRequest = (folderId?: string) => ({
type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
folderId,
});
const expandBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({
const expandBookmarkedStatusesSuccess = (statuses: APIEntity[], next: string | null, folderId?: string) => ({
type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
statuses,
next,
folderId,
});
const expandBookmarkedStatusesFail = (error: unknown) => ({
const expandBookmarkedStatusesFail = (error: unknown, folderId?: string) => ({
type: BOOKMARKED_STATUSES_EXPAND_FAIL,
error,
folderId,
});
export {

Wyświetl plik

@ -1,12 +1,14 @@
import { defineMessages } from 'react-intl';
import toast from 'soapbox/toast';
import toast, { type IToastOptions } from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer';
import { openModal } from './modals';
import { expandGroupFeaturedTimeline } from './timelines';
import type { AppDispatch, RootState } from 'soapbox/store';
@ -85,7 +87,9 @@ const ZAP_FAIL = 'ZAP_FAIL';
const messages = defineMessages({
bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' },
bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' },
folderChanged: { id: 'status.bookmark_folder_changed', defaultMessage: 'Changed folder' },
view: { id: 'toast.view', defaultMessage: 'View' },
selectFolder: { id: 'status.bookmark.select_folder', defaultMessage: 'Select folder' },
});
const reblog = (status: StatusEntity) =>
@ -342,17 +346,35 @@ const zapFail = (status: StatusEntity, error: unknown) => ({
skipLoading: true,
});
const bookmark = (status: StatusEntity) =>
const bookmark = (status: StatusEntity, folderId?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
dispatch(bookmarkRequest(status));
api(getState).post(`/api/v1/statuses/${status.id}/bookmark`).then(function(response) {
return api(getState).post(`/api/v1/statuses/${status.id}/bookmark`, {
folder_id: folderId,
}).then(function(response) {
dispatch(importFetchedStatus(response.data));
dispatch(bookmarkSuccess(status, response.data));
toast.success(messages.bookmarkAdded, {
let opts: IToastOptions = {
actionLabel: messages.view,
actionLink: '/bookmarks',
});
actionLink: folderId ? `/bookmarks/${folderId}` : '/bookmarks/all',
};
if (features.bookmarkFolders && typeof folderId !== 'string') {
opts = {
actionLabel: messages.selectFolder,
action: () => dispatch(openModal('SELECT_BOOKMARK_FOLDER', {
statusId: status.id,
})),
};
}
toast.success(typeof folderId === 'string' ? messages.folderChanged : messages.bookmarkAdded, opts);
}).catch(function(error) {
dispatch(bookmarkFail(status, error));
});

Wyświetl plik

@ -44,6 +44,13 @@ export { useUnmuteGroup } from './groups/useUnmuteGroup';
export { useUpdateGroup } from './groups/useUpdateGroup';
export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
// Statuses
export { useBookmarkFolders } from './statuses/useBookmarkFolders';
export { useBookmarkFolder } from './statuses/useBookmarkFolder';
export { useCreateBookmarkFolder } from './statuses/useCreateBookmarkFolder';
export { useDeleteBookmarkFolder } from './statuses/useDeleteBookmarkFolder';
export { useUpdateBookmarkFolder } from './statuses/useUpdateBookmarkFolder';
// Streaming
export { useUserStream } from './streaming/useUserStream';
export { useCommunityStream } from './streaming/useCommunityStream';

Wyświetl plik

@ -0,0 +1,31 @@
import { Entities } from 'soapbox/entity-store/entities';
import { selectEntity } from 'soapbox/entity-store/selectors';
import { useAppSelector } from 'soapbox/hooks';
import { type BookmarkFolder } from 'soapbox/schemas/bookmark-folder';
import { useBookmarkFolders } from './useBookmarkFolders';
function useBookmarkFolder(folderId?: string) {
const {
isError,
isFetched,
isFetching,
isLoading,
invalidate,
} = useBookmarkFolders();
const bookmarkFolder = useAppSelector(state => folderId
? selectEntity<BookmarkFolder>(state, Entities.BOOKMARK_FOLDERS, folderId)
: undefined);
return {
bookmarkFolder,
isError,
isFetched,
isFetching,
isLoading,
invalidate,
};
}
export { useBookmarkFolder };

Wyświetl plik

@ -0,0 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { useFeatures } from 'soapbox/hooks/useFeatures';
import { bookmarkFolderSchema, type BookmarkFolder } from 'soapbox/schemas/bookmark-folder';
function useBookmarkFolders() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<BookmarkFolder>(
[Entities.BOOKMARK_FOLDERS],
() => api.get('/api/v1/pleroma/bookmark_folders'),
{ enabled: features.bookmarkFolders, schema: bookmarkFolderSchema },
);
const bookmarkFolders = entities;
return {
...result,
bookmarkFolders,
};
}
export { useBookmarkFolders };

Wyświetl plik

@ -0,0 +1,31 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { bookmarkFolderSchema } from 'soapbox/schemas/bookmark-folder';
interface CreateBookmarkFolderParams {
name: string;
emoji?: string;
}
function useCreateBookmarkFolder() {
const api = useApi();
const { createEntity, ...rest } = useCreateEntity(
[Entities.BOOKMARK_FOLDERS],
(params: CreateBookmarkFolderParams) =>
api.post('/api/v1/pleroma/bookmark_folders', params, {
headers: {
'Content-Type': 'multipart/form-data',
},
}),
{ schema: bookmarkFolderSchema },
);
return {
createBookmarkFolder: createEntity,
...rest,
};
}
export { useCreateBookmarkFolder };

Wyświetl plik

@ -0,0 +1,16 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
function useDeleteBookmarkFolder() {
const { deleteEntity, isSubmitting } = useEntityActions(
[Entities.BOOKMARK_FOLDERS],
{ delete: '/api/v1/pleroma/bookmark_folders/:id' },
);
return {
deleteBookmarkFolder: deleteEntity,
isSubmitting,
};
}
export { useDeleteBookmarkFolder };

Wyświetl plik

@ -0,0 +1,31 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { bookmarkFolderSchema } from 'soapbox/schemas/bookmark-folder';
interface UpdateBookmarkFolderParams {
name: string;
emoji?: string;
}
function useUpdateBookmarkFolder(folderId: string) {
const api = useApi();
const { createEntity, ...rest } = useCreateEntity(
[Entities.BOOKMARK_FOLDERS],
(params: UpdateBookmarkFolderParams) =>
api.patch(`/api/v1/pleroma/bookmark_folders/${folderId}`, params, {
headers: {
'Content-Type': 'multipart/form-data',
},
}),
{ schema: bookmarkFolderSchema },
);
return {
updateBookmarkFolder: createEntity,
...rest,
};
}
export { useUpdateBookmarkFolder };

Wyświetl plik

@ -2,6 +2,7 @@ import type * as Schemas from 'soapbox/schemas';
enum Entities {
ACCOUNTS = 'Accounts',
BOOKMARK_FOLDERS = 'BookmarkFolders',
GROUPS = 'Groups',
GROUP_MEMBERSHIPS = 'GroupMemberships',
GROUP_MUTES = 'GroupMutes',
@ -14,6 +15,7 @@ enum Entities {
interface EntityTypes {
[Entities.ACCOUNTS]: Schemas.Account;
[Entities.BOOKMARK_FOLDERS]: Schemas.BookmarkFolder;
[Entities.GROUPS]: Schemas.Group;
[Entities.GROUP_MEMBERSHIPS]: Schemas.GroupMember;
[Entities.GROUP_RELATIONSHIPS]: Schemas.GroupRelationship;

Wyświetl plik

@ -0,0 +1,61 @@
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useCreateBookmarkFolder } from 'soapbox/api/hooks';
import { Button, Form, HStack, Input } from 'soapbox/components/ui';
import { useTextField } from 'soapbox/hooks/forms';
import toast from 'soapbox/toast';
const messages = defineMessages({
label: { id: 'bookmark_folders.new.title_placeholder', defaultMessage: 'New folder title' },
createSuccess: { id: 'bookmark_folders.add.success', defaultMessage: 'Bookmark folder created successfully' },
createFail: { id: 'bookmark_folders.add.fail', defaultMessage: 'Failed to create bookmark folder' },
});
const NewFolderForm: React.FC = () => {
const intl = useIntl();
const name = useTextField();
const { createBookmarkFolder, isSubmitting } = useCreateBookmarkFolder();
const handleSubmit = (e: React.FormEvent<Element>) => {
e.preventDefault();
createBookmarkFolder({
name: name.value,
}).then(() => {
toast.success(messages.createSuccess);
}).catch(() => {
toast.success(messages.createFail);
});
};
const label = intl.formatMessage(messages.label);
return (
<Form onSubmit={handleSubmit}>
<HStack space={2}>
<label className='grow'>
<span style={{ display: 'none' }}>{label}</span>
<Input
type='text'
placeholder={label}
disabled={isSubmitting}
{...name}
/>
</label>
<Button
disabled={isSubmitting}
onClick={handleSubmit}
theme='primary'
>
<FormattedMessage id='bookmark_folders.new.create_title' defaultMessage='Add folder' />
</Button>
</HStack>
</Form>
);
};
export default NewFolderForm;

Wyświetl plik

@ -0,0 +1,72 @@
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Redirect } from 'react-router-dom';
import { useBookmarkFolders } from 'soapbox/api/hooks';
import List, { ListItem } from 'soapbox/components/list';
import { Column, Emoji, HStack, Icon, Spinner, Stack } from 'soapbox/components/ui';
import { useFeatures } from 'soapbox/hooks';
import NewFolderForm from './components/new-folder-form';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const BookmarkFolders: React.FC = () => {
const intl = useIntl();
const features = useFeatures();
const { bookmarkFolders, isFetching } = useBookmarkFolders();
if (!features.bookmarkFolders) return <Redirect to='/bookmarks/all' />;
if (isFetching) {
return (
<Column>
<Spinner />
</Column>
);
}
return (
<Column label={intl.formatMessage(messages.heading)}>
<Stack space={4}>
<NewFolderForm />
<List>
<ListItem
to='/bookmarks/all'
label={
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/bookmarks.svg')} size={20} />
<span><FormattedMessage id='bookmark_folders.all_bookmarks' defaultMessage='All bookmarks' /></span>
</HStack>
}
/>
{bookmarkFolders?.map((folder) => (
<ListItem
key={folder.id}
to={`/bookmarks/${folder.id}`}
label={
<HStack alignItems='center' space={2}>
{folder.emoji ? (
<Emoji
emoji={folder.emoji}
src={folder.emoji_url || undefined}
className='h-5 w-5 flex-none'
/>
) : <Icon src={require('@tabler/icons/folder.svg')} size={20} />}
<span>{folder.name}</span>
</HStack>
}
/>
))}
</List>
</Stack>
</Column>
);
};
export default BookmarkFolders;

Wyświetl plik

@ -1,48 +1,114 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks';
import { openModal } from 'soapbox/actions/modals';
import { useBookmarkFolder, useDeleteBookmarkFolder } from 'soapbox/api/hooks';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import StatusList from 'soapbox/components/status-list';
import { Column } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import toast from 'soapbox/toast';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
editFolder: { id: 'bookmarks.edit_folder', defaultMessage: 'Edit folder' },
deleteFolder: { id: 'bookmarks.delete_folder', defaultMessage: 'Delete folder' },
deleteFolderHeading: { id: 'confirmations.delete_bookmark_folder.heading', defaultMessage: 'Delete "{name}" folder?' },
deleteFolderMessage: { id: 'confirmations.delete_bookmark_folder.message', defaultMessage: 'Are you sure you want to delete the folder? The bookmarks will still be stored.' },
deleteFolderConfirm: { id: 'confirmations.delete_bookmark_folder.confirm', defaultMessage: 'Delete folder' },
deleteFolderSuccess: { id: 'bookmarks.delete_folder.success', defaultMessage: 'Folder deleted' },
deleteFolderFail: { id: 'bookmarks.delete_folder.fail', defaultMessage: 'Failed to delete folder' },
});
const handleLoadMore = debounce((dispatch) => {
dispatch(expandBookmarkedStatuses());
const handleLoadMore = debounce((dispatch, folderId) => {
dispatch(expandBookmarkedStatuses(folderId));
}, 300, { leading: true });
const Bookmarks: React.FC = () => {
interface IBookmarks {
params?: {
id?: string;
};
}
const Bookmarks: React.FC<IBookmarks> = ({ params }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const history = useHistory();
const statusIds = useAppSelector((state) => state.status_lists.get('bookmarks')!.items);
const isLoading = useAppSelector((state) => state.status_lists.get('bookmarks')!.isLoading);
const hasMore = useAppSelector((state) => !!state.status_lists.get('bookmarks')!.next);
const folderId = params?.id;
const { bookmarkFolder: folder } = useBookmarkFolder(folderId);
const { deleteBookmarkFolder } = useDeleteBookmarkFolder();
const bookmarksKey = folderId ? `bookmarks:${folderId}` : 'bookmarks';
const statusIds = useAppSelector((state) => state.status_lists.get(bookmarksKey)?.items || ImmutableOrderedSet<string>());
const isLoading = useAppSelector((state) => state.status_lists.get(bookmarksKey)?.isLoading === true);
const hasMore = useAppSelector((state) => !!state.status_lists.get(bookmarksKey)?.next);
React.useEffect(() => {
dispatch(fetchBookmarkedStatuses());
}, []);
dispatch(fetchBookmarkedStatuses(folderId));
}, [folderId]);
const handleRefresh = () => {
return dispatch(fetchBookmarkedStatuses());
return dispatch(fetchBookmarkedStatuses(folderId));
};
const handleEditFolder = () => {
dispatch(openModal('EDIT_BOOKMARK_FOLDER', { folderId }));
};
const handleDeleteFolder = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.deleteFolderHeading, { name: folder?.name }),
message: intl.formatMessage(messages.deleteFolderMessage),
confirm: intl.formatMessage(messages.deleteFolderConfirm),
onConfirm: () => {
deleteBookmarkFolder(folderId!).then(() => {
toast.success(messages.deleteFolderSuccess);
history.push('/bookmarks');
}).catch(() => {
toast.error(messages.deleteFolderFail);
});
},
}));
};
const emptyMessage = <FormattedMessage id='empty_column.bookmarks' defaultMessage="You don't have any bookmarks yet. When you add one, it will show up here." />;
const items = folderId ? [
{
text: intl.formatMessage(messages.editFolder),
action: handleEditFolder,
icon: require('@tabler/icons/edit.svg'),
},
{
text: intl.formatMessage(messages.deleteFolder),
action: handleDeleteFolder,
icon: require('@tabler/icons/trash.svg'),
},
] : [];
return (
<Column label={intl.formatMessage(messages.heading)} transparent>
<Column
label={folder ? folder.name : intl.formatMessage(messages.heading)}
action={
<DropdownMenu items={items} src={require('@tabler/icons/dots-vertical.svg')} />
}
transparent
>
<PullToRefresh onRefresh={handleRefresh}>
<StatusList
statusIds={statusIds}
scrollKey='bookmarked_statuses'
hasMore={hasMore}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => handleLoadMore(dispatch)}
onLoadMore={() => handleLoadMore(dispatch, folderId)}
emptyMessage={emptyMessage}
divideType='space'
/>

Wyświetl plik

@ -16,7 +16,6 @@ import type { RootState } from 'soapbox/store';
import type { List as ListEntity } from 'soapbox/types/entities';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
add: { id: 'lists.new.create', defaultMessage: 'Add list' },
});

Wyświetl plik

@ -10,7 +10,6 @@ import EditListForm from './components/edit-list-form';
import Search from './components/search';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
changeTitle: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
addToList: { id: 'lists.account.add', defaultMessage: 'Add to list' },
removeFromList: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },

Wyświetl plik

@ -14,6 +14,7 @@ import {
CryptoDonateModal,
DislikesModal,
EditAnnouncementModal,
EditBookmarkFolderModal,
EditFederationModal,
EmbedModal,
EventMapModal,
@ -36,6 +37,7 @@ import {
ReblogsModal,
ReplyMentionsModal,
ReportModal,
SelectBookmarkFolderModal,
UnauthorizedModal,
VideoModal,
} from 'soapbox/features/ui/util/async-components';
@ -57,6 +59,7 @@ const MODAL_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
'CRYPTO_DONATE': CryptoDonateModal,
'DISLIKES': DislikesModal,
'EDIT_ANNOUNCEMENT': EditAnnouncementModal,
'EDIT_BOOKMARK_FOLDER': EditBookmarkFolderModal,
'EDIT_FEDERATION': EditFederationModal,
'EMBED': EmbedModal,
'EVENT_MAP': EventMapModal,
@ -78,6 +81,7 @@ const MODAL_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
'REBLOGS': ReblogsModal,
'REPLY_MENTIONS': ReplyMentionsModal,
'REPORT': ReportModal,
'SELECT_BOOKMARK_FOLDER': SelectBookmarkFolderModal,
'UNAUTHORIZED': UnauthorizedModal,
'VIDEO': VideoModal,
};

Wyświetl plik

@ -12,7 +12,6 @@ import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles } from 'soa
import ComposeForm from '../../../compose/components/compose-form';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' },
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
});

Wyświetl plik

@ -0,0 +1,160 @@
import { useFloating, shift } from '@floating-ui/react';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { closeModal } from 'soapbox/actions/modals';
import { useBookmarkFolder, useUpdateBookmarkFolder } from 'soapbox/api/hooks';
import { Emoji, HStack, Icon, Input, Modal } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/components/emoji-picker-dropdown';
import { messages as emojiMessages } from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
import { useAppDispatch, useClickOutside } from 'soapbox/hooks';
import { useTextField } from 'soapbox/hooks/forms';
import toast from 'soapbox/toast';
import type { Emoji as EmojiType } from 'soapbox/features/emoji';
const messages = defineMessages({
label: { id: 'bookmark_folders.new.title_placeholder', defaultMessage: 'New folder title' },
editSuccess: { id: 'bookmark_folders.edit.success', defaultMessage: 'Bookmark folder edited successfully' },
editFail: { id: 'bookmark_folders.edit.fail', defaultMessage: 'Failed to edit bookmark folder' },
});
interface IEmojiPicker {
emoji?: string;
emojiUrl?: string;
onPickEmoji?: (emoji: EmojiType) => void;
}
const EmojiPicker: React.FC<IEmojiPicker> = ({ emoji, emojiUrl, ...props }) => {
const intl = useIntl();
const title = intl.formatMessage(emojiMessages.emoji);
const [visible, setVisible] = useState(false);
const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({
middleware: [shift()],
});
useClickOutside(refs, () => {
setVisible(false);
});
const handleToggle: React.KeyboardEventHandler<HTMLButtonElement> & React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
setVisible(!visible);
};
return (
<div className='relative'>
<button
className='mt-1 flex h-[38px] w-[38px] items-center justify-center rounded-md border border-solid border-gray-400 bg-white text-gray-900 ring-1 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:ring-gray-800 dark:focus:border-primary-500 dark:focus:ring-primary-500'
ref={refs.setReference}
title={title}
aria-label={title}
aria-expanded={visible}
onClick={handleToggle}
onKeyDown={handleToggle}
tabIndex={0}
>
{emoji
? <Emoji height={20} width={20} emoji={emoji} />
: <Icon className='h-5 w-5 text-gray-600 hover:text-gray-700 dark:hover:text-gray-500' src={require('@tabler/icons/mood-happy.svg')} />}
</button>
{createPortal(
<div
className='z-[101]'
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
>
<EmojiPickerDropdown
visible={visible}
setVisible={setVisible}
update={update}
{...props}
/>
</div>,
document.body,
)}
</div>
);
};
interface IEditBookmarkFolderModal {
folderId: string;
onClose: (type: string) => void;
}
const EditBookmarkFolderModal: React.FC<IEditBookmarkFolderModal> = ({ folderId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { bookmarkFolder } = useBookmarkFolder(folderId);
const { updateBookmarkFolder, isSubmitting } = useUpdateBookmarkFolder(folderId);
const [emoji, setEmoji] = useState(bookmarkFolder?.emoji);
const [emojiUrl, setEmojiUrl] = useState(bookmarkFolder?.emoji_url);
const name = useTextField(bookmarkFolder?.name);
const handleEmojiPick = (data: EmojiType) => {
if (data.custom) {
setEmojiUrl(data.imageUrl);
setEmoji(data.colons);
} else {
setEmoji(data.native);
}
};
const onClickClose = () => {
onClose('EDIT_BOOKMARK_FOLDER');
};
const handleSubmit = () => {
updateBookmarkFolder({
name: name.value,
emoji,
}).then(() => {
toast.success(intl.formatMessage(messages.editSuccess));
dispatch(closeModal('EDIT_BOOKMARK_FOLDER'));
})
.catch(() => {
toast.success(intl.formatMessage(messages.editFail));
});
};
const label = intl.formatMessage(messages.label);
return (
<Modal
title={<FormattedMessage id='edit_bookmark_folder_modal.header_title' defaultMessage='Edit folder' />}
onClose={onClickClose}
confirmationAction={handleSubmit}
confirmationText={<FormattedMessage id='edit_bookmark_folder_modal.confirm' defaultMessage='Save' />}
>
<HStack space={2}>
<EmojiPicker
emoji={emoji}
emojiUrl={emojiUrl}
onPickEmoji={handleEmojiPick}
/>
<label className='grow'>
<span style={{ display: 'none' }}>{label}</span>
<Input
type='text'
placeholder={label}
disabled={isSubmitting}
{...name}
/>
</label>
</HStack>
</Modal>
);
};
export default EditBookmarkFolderModal;

Wyświetl plik

@ -13,7 +13,6 @@ import { ReactionRecord } from 'soapbox/reducers/user-lists';
import type { Item } from 'soapbox/components/ui/tabs/tabs';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
all: { id: 'reactions.all', defaultMessage: 'All' },
});

Wyświetl plik

@ -0,0 +1,96 @@
import React, { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { bookmark } from 'soapbox/actions/interactions';
import { useBookmarkFolders } from 'soapbox/api/hooks';
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
import { Emoji, HStack, Icon, Modal, Spinner, Stack } from 'soapbox/components/ui';
import NewFolderForm from 'soapbox/features/bookmark-folders/components/new-folder-form';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
import type { Status as StatusEntity } from 'soapbox/types/entities';
interface ISelectBookmarkFolderModal {
statusId: string;
onClose: (type: string) => void;
}
const SelectBookmarkFolderModal: React.FC<ISelectBookmarkFolderModal> = ({ statusId, onClose }) => {
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id: statusId })) as StatusEntity;
const dispatch = useAppDispatch();
const [selectedFolder, setSelectedFolder] = useState(status.pleroma.get('bookmark_folder'));
const { isFetching, bookmarkFolders } = useBookmarkFolders();
const onChange: React.ChangeEventHandler<HTMLInputElement> = e => {
const folderId = e.target.value;
setSelectedFolder(folderId);
dispatch(bookmark(status, folderId)).then(() => {
onClose('SELECT_BOOKMARK_FOLDER');
}).catch(() => {});
};
const onClickClose = () => {
onClose('SELECT_BOOKMARK_FOLDER');
};
const items = [
<RadioItem
label={
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/bookmarks.svg')} size={20} />
<span><FormattedMessage id='bookmark_folders.all_bookmarks' defaultMessage='All bookmarks' /></span>
</HStack>
}
checked={selectedFolder === null}
value={''}
/>,
];
if (!isFetching) {
items.push(...(bookmarkFolders.map((folder) => (
<RadioItem
key={folder.id}
label={
<HStack alignItems='center' space={2}>
{folder.emoji ? (
<Emoji
emoji={folder.emoji}
src={folder.emoji_url || undefined}
className='h-5 w-5 flex-none'
/>
) : <Icon src={require('@tabler/icons/folder.svg')} size={20} />}
<span>{folder.name}</span>
</HStack>
}
checked={selectedFolder === folder.id}
value={folder.id}
/>
))));
}
const body = isFetching ? <Spinner /> : (
<Stack space={4}>
<NewFolderForm />
<RadioGroup onChange={onChange}>
{items}
</RadioGroup>
</Stack>
);
return (
<Modal
title={<FormattedMessage id='select_bookmark_folder_modal.header_title' defaultMessage='Select folder' />}
onClose={onClickClose}
>
{body}
</Modal>
);
};
export default SelectBookmarkFolderModal;

Wyświetl plik

@ -9,7 +9,6 @@ import { selectAccount } from 'soapbox/selectors';
import toast from 'soapbox/toast';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
accountPlaceholder: { id: 'remote_interaction.account_placeholder', defaultMessage: 'Enter your username@domain you want to act from' },
userNotFoundError: { id: 'remote_interaction.user_not_found_error', defaultMessage: 'Couldn\'t find given user' },
});

Wyświetl plik

@ -136,6 +136,7 @@ import {
RegisterInvite,
ExternalLogin,
LandingTimeline,
BookmarkFolders,
} from './util/async-components';
import GlobalHotkeys from './util/global-hotkeys';
import { WrappedRoute } from './util/react-router-helpers';
@ -243,7 +244,9 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
{features.lists && <WrappedRoute path='/lists' page={DefaultPage} component={Lists} content={children} />}
{features.lists && <WrappedRoute path='/list/:id' page={DefaultPage} component={ListTimeline} content={children} />}
{features.bookmarks && <WrappedRoute path='/bookmarks' page={DefaultPage} component={Bookmarks} content={children} />}
{features.bookmarks && <WrappedRoute path='/bookmarks/all' page={DefaultPage} component={Bookmarks} content={children} />}
{features.bookmarks && <WrappedRoute path='/bookmarks/:id' page={DefaultPage} component={Bookmarks} content={children} />}
{features.bookmarkFolders && <WrappedRoute path='/bookmarks' page={DefaultPage} component={BookmarkFolders} content={children} />}
<WrappedRoute path='/notifications' page={DefaultPage} component={Notifications} content={children} />

Wyświetl plik

@ -164,3 +164,6 @@ export const AccountNotePanel = lazy(() => import('soapbox/features/ui/component
export const ComposeEditor = lazy(() => import('soapbox/features/compose/editor'));
export const NostrSignupModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-signup-modal/nostr-signup-modal'));
export const NostrLoginModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-login-modal/nostr-login-modal'));
export const BookmarkFolders = lazy(() => import('soapbox/features/bookmark-folders'));
export const EditBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/edit-bookmark-folder-modal'));
export const SelectBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/select-bookmark-folder-modal'));

Wyświetl plik

@ -19,9 +19,6 @@ const messages = defineMessages({
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
hide: { id: 'video.hide', defaultMessage: 'Hide video' },
expand: { id: 'video.expand', defaultMessage: 'Expand video' },
close: { id: 'video.close', defaultMessage: 'Close video' },
fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
});

Wyświetl plik

@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
* Returns props for `<input type="text">`.
* If `initialValue` changes from undefined to a string, it will set the value.
*/
function useTextField(initialValue: string | undefined) {
function useTextField(initialValue?: string | undefined) {
const [value, setValue] = useState(initialValue);
const hasInitialValue = typeof initialValue === 'string';

Wyświetl plik

@ -197,6 +197,17 @@
"badge_input.placeholder": "Enter a badge…",
"birthday_panel.title": "Birthdays",
"birthdays_modal.empty": "None of your friends have birthday today.",
"bookmark_folders.add.fail": "Failed to create bookmark folder",
"bookmark_folders.add.success": "Bookmark folder created successfully",
"bookmark_folders.all_bookmarks": "All bookmarks",
"bookmark_folders.edit.fail": "Failed to edit bookmark folder",
"bookmark_folders.edit.success": "Bookmark folder edited successfully",
"bookmark_folders.new.create_title": "Add folder",
"bookmark_folders.new.title_placeholder": "New folder title",
"bookmarks.delete_folder": "Delete folder",
"bookmarks.delete_folder.fail": "Failed to delete folder",
"bookmarks.delete_folder.success": "Folder deleted",
"bookmarks.edit_folder": "Edit folder",
"boost_modal.combo": "You can press {combo} to skip this next time",
"boost_modal.title": "Repost?",
"bundle_column_error.body": "Something went wrong while loading this page.",
@ -484,6 +495,9 @@
"confirmations.delete.confirm": "Delete",
"confirmations.delete.heading": "Delete post",
"confirmations.delete.message": "Are you sure you want to delete this post?",
"confirmations.delete_bookmark_folder.confirm": "Delete folder",
"confirmations.delete_bookmark_folder.heading": "Delete \"{name}\" folder?",
"confirmations.delete_bookmark_folder.message": "Are you sure you want to delete the folder? The bookmarks will still be stored.",
"confirmations.delete_event.confirm": "Delete",
"confirmations.delete_event.heading": "Delete event",
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
@ -565,6 +579,8 @@
"directory.local": "From {domain} only",
"directory.new_arrivals": "New arrivals",
"directory.recently_active": "Recently active",
"edit_bookmark_folder_modal.confirm": "Save",
"edit_bookmark_folder_modal.header_title": "Edit folder",
"edit_email.header": "Change Email",
"edit_email.placeholder": "me@example.com",
"edit_federation.followers_only": "Hide posts except to followers",
@ -1322,6 +1338,7 @@
"security.update_email.success": "Email successfully updated.",
"security.update_password.fail": "Update password failed.",
"security.update_password.success": "Password successfully updated.",
"select_bookmark_folder_modal.header_title": "Select folder",
"settings.account_migration": "Move Account",
"settings.blocks": "Blocks",
"settings.change_email": "Change Email",
@ -1397,6 +1414,8 @@
"status.approval.pending": "Pending approval",
"status.approval.rejected": "Rejected",
"status.bookmark": "Bookmark",
"status.bookmark.select_folder": "Select folder",
"status.bookmark_folder_changed": "Changed folder",
"status.bookmarked": "Bookmark added.",
"status.cancel_reblog_private": "Un-repost",
"status.cannot_reblog": "This post cannot be reposted",
@ -1525,12 +1544,9 @@
"upload_form.preview": "Preview",
"upload_form.undo": "Delete",
"upload_progress.label": "Uploading…",
"video.close": "Close video",
"video.download": "Download file",
"video.exit_fullscreen": "Exit full screen",
"video.expand": "Expand video",
"video.fullscreen": "Full screen",
"video.hide": "Hide video",
"video.mute": "Mute sound",
"video.pause": "Pause",
"video.play": "Play",

Wyświetl plik

@ -67,7 +67,7 @@ import {
} from '../actions/scheduled-statuses';
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities';
export const StatusListRecord = ImmutableRecord({
next: null as string | null,
@ -94,7 +94,8 @@ const getStatusIds = (statuses: APIEntity[] = []) => (
ImmutableOrderedSet(statuses.map(getStatusId))
);
const setLoading = (state: State, listType: string, loading: boolean) => state.setIn([listType, 'isLoading'], loading);
const setLoading = (state: State, listType: string, loading: boolean) =>
state.update(listType, StatusListRecord(), listMap => listMap.set('isLoading', loading));
const normalizeList = (state: State, listType: string, statuses: APIEntity[], next: string | null) => {
return state.update(listType, StatusListRecord(), listMap => listMap.withMutations(map => {
@ -117,14 +118,14 @@ const appendToList = (state: State, listType: string, statuses: APIEntity[], nex
const prependOneToList = (state: State, listType: string, status: APIEntity) => {
const statusId = getStatusId(status);
return state.updateIn([listType, 'items'], ImmutableOrderedSet(), items => {
return state.update(listType, StatusListRecord(), listMap => listMap.update('items', items => {
return ImmutableOrderedSet([statusId]).union(items as ImmutableOrderedSet<string>);
});
}));
};
const removeOneFromList = (state: State, listType: string, status: APIEntity) => {
const statusId = getStatusId(status);
return state.updateIn([listType, 'items'], ImmutableOrderedSet(), items => (items as ImmutableOrderedSet<string>).delete(statusId));
return state.update(listType, StatusListRecord(), listMap => listMap.update('items', items => items.delete(statusId)));
};
const maybeAppendScheduledStatus = (state: State, status: APIEntity) => {
@ -132,6 +133,24 @@ const maybeAppendScheduledStatus = (state: State, status: APIEntity) => {
return prependOneToList(state, 'scheduled_statuses', getStatusId(status));
};
const addBookmarkToLists = (state: State, status: APIEntity) => {
state = prependOneToList(state, 'bookmarks', status);
const folderId = status.pleroma.bookmark_folder;
if (folderId) {
return prependOneToList(state, `bookmarks:${folderId}`, status);
}
return state;
};
const removeBookmarkFromLists = (state: State, status: StatusEntity) => {
state = removeOneFromList(state, 'bookmarks', status);
const folderId = status.pleroma.get('bookmark_folder');
if (folderId) {
return removeOneFromList(state, `bookmarks:${folderId}`, status);
}
return state;
};
export default function statusLists(state = initialState, action: AnyAction) {
switch (action.type) {
case FAVOURITED_STATUSES_FETCH_REQUEST:
@ -156,22 +175,22 @@ export default function statusLists(state = initialState, action: AnyAction) {
return appendToList(state, `favourites:${action.accountId}`, action.statuses, action.next);
case BOOKMARKED_STATUSES_FETCH_REQUEST:
case BOOKMARKED_STATUSES_EXPAND_REQUEST:
return setLoading(state, 'bookmarks', true);
return setLoading(state, action.folderId ? `bookmarks:${action.folderId}` : 'bookmarks', true);
case BOOKMARKED_STATUSES_FETCH_FAIL:
case BOOKMARKED_STATUSES_EXPAND_FAIL:
return setLoading(state, 'bookmarks', false);
return setLoading(state, action.folderId ? `bookmarks:${action.folderId}` : 'bookmarks', false);
case BOOKMARKED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, 'bookmarks', action.statuses, action.next);
return normalizeList(state, action.folderId ? `bookmarks:${action.folderId}` : 'bookmarks', action.statuses, action.next);
case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
return appendToList(state, 'bookmarks', action.statuses, action.next);
return appendToList(state, action.folderId ? `bookmarks:${action.folderId}` : 'bookmarks', action.statuses, action.next);
case FAVOURITE_SUCCESS:
return prependOneToList(state, 'favourites', action.status);
case UNFAVOURITE_SUCCESS:
return removeOneFromList(state, 'favourites', action.status);
case BOOKMARK_SUCCESS:
return prependOneToList(state, 'bookmarks', action.status);
return addBookmarkToLists(state, action.response);
case UNBOOKMARK_SUCCESS:
return removeOneFromList(state, 'bookmarks', action.status);
return removeBookmarkFromLists(state, action.status);
case PINNED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, 'pins', action.statuses, action.next);
case PIN_SUCCESS:

Wyświetl plik

@ -0,0 +1,13 @@
import { z } from 'zod';
/** Pleroma bookmark folder. */
const bookmarkFolderSchema = z.object({
emoji: z.string().optional().catch(undefined),
emoji_url: z.string().optional().catch(undefined),
name: z.string().catch(''),
id: z.string(),
});
type BookmarkFolder = z.infer<typeof bookmarkFolderSchema>;
export { bookmarkFolderSchema, type BookmarkFolder };

Wyświetl plik

@ -1,5 +1,6 @@
export { accountSchema, type Account } from './account';
export { attachmentSchema, type Attachment } from './attachment';
export { bookmarkFolderSchema, type BookmarkFolder } from './bookmark-folder';
export { cardSchema, type Card } from './card';
export { chatMessageSchema, type ChatMessage } from './chat-message';
export { customEmojiSchema, type CustomEmoji } from './custom-emoji';

Wyświetl plik

@ -30,6 +30,7 @@ const baseStatusSchema = z.object({
name: z.string(),
website: z.string().url().nullable().catch(null),
}).nullable().catch(null),
bookmark_folder: z.string().nullable().catch(null),
bookmarked: z.coerce.boolean(),
card: cardSchema.nullable().catch(null),
content: contentSchema,

Wyświetl plik

@ -9,7 +9,7 @@ import { httpErrorMessages } from './utils/errors';
export type ToastText = string | MessageDescriptor
export type ToastType = 'success' | 'error' | 'info'
interface IToastOptions {
export interface IToastOptions {
action?(): void;
actionLink?: string;
actionLabel?: ToastText;

Wyświetl plik

@ -231,6 +231,15 @@ const getInstanceFeatures = (instance: Instance) => {
/** Whether people who blocked you are visible through the API. */
blockersVisible: features.includes('blockers_visible'),
/**
* Can group bookmarks in folders.
* @see GET /api/v1/pleroma/bookmark_folders
* @see POST /api/v1/pleroma/bookmark_folders
* @see PATCH /api/v1/pleroma/bookmark_folders/:id
* @see DELETE /api/v1/pleroma/bookmark_folders/:id
*/
bookmarkFolders: features.includes('pleroma:bookmark_folders'),
/**
* Can bookmark statuses.
* @see POST /api/v1/statuses/:id/bookmark