Merge branch 'main' into 'black-mode'
# Conflicts: # src/features/bookmarks/index.tsx
This commit is contained in:
commit
4871c30b8c
33 changed files with 789 additions and 60 deletions
|
@ -15,70 +15,77 @@ const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL';
|
||||||
|
|
||||||
const noOp = () => new Promise(f => f(undefined));
|
const noOp = () => new Promise(f => f(undefined));
|
||||||
|
|
||||||
const fetchBookmarkedStatuses = () =>
|
const fetchBookmarkedStatuses = (folderId?: string) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
if (getState().status_lists.get('bookmarks')?.isLoading) {
|
if (getState().status_lists.get(folderId ? `bookmarks:${folderId}` : 'bookmarks')?.isLoading) {
|
||||||
return dispatch(noOp);
|
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');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
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 => {
|
}).catch(error => {
|
||||||
dispatch(fetchBookmarkedStatusesFail(error));
|
dispatch(fetchBookmarkedStatusesFail(error, folderId));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchBookmarkedStatusesRequest = () => ({
|
const fetchBookmarkedStatusesRequest = (folderId?: string) => ({
|
||||||
type: BOOKMARKED_STATUSES_FETCH_REQUEST,
|
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,
|
type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
|
||||||
statuses,
|
statuses,
|
||||||
next,
|
next,
|
||||||
|
folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchBookmarkedStatusesFail = (error: unknown) => ({
|
const fetchBookmarkedStatusesFail = (error: unknown, folderId?: string) => ({
|
||||||
type: BOOKMARKED_STATUSES_FETCH_FAIL,
|
type: BOOKMARKED_STATUSES_FETCH_FAIL,
|
||||||
error,
|
error,
|
||||||
|
folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expandBookmarkedStatuses = () =>
|
const expandBookmarkedStatuses = (folderId?: string) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(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);
|
return dispatch(noOp);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(expandBookmarkedStatusesRequest());
|
dispatch(expandBookmarkedStatusesRequest(folderId));
|
||||||
|
|
||||||
return api(getState).get(url).then(response => {
|
return api(getState).get(url).then(response => {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
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 => {
|
}).catch(error => {
|
||||||
dispatch(expandBookmarkedStatusesFail(error));
|
dispatch(expandBookmarkedStatusesFail(error, folderId));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const expandBookmarkedStatusesRequest = () => ({
|
const expandBookmarkedStatusesRequest = (folderId?: string) => ({
|
||||||
type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
|
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,
|
type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
|
||||||
statuses,
|
statuses,
|
||||||
next,
|
next,
|
||||||
|
folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expandBookmarkedStatusesFail = (error: unknown) => ({
|
const expandBookmarkedStatusesFail = (error: unknown, folderId?: string) => ({
|
||||||
type: BOOKMARKED_STATUSES_EXPAND_FAIL,
|
type: BOOKMARKED_STATUSES_EXPAND_FAIL,
|
||||||
error,
|
error,
|
||||||
|
folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import toast from 'soapbox/toast';
|
import toast, { type IToastOptions } from 'soapbox/toast';
|
||||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||||
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
|
||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts';
|
import { fetchRelationships } from './accounts';
|
||||||
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
||||||
|
import { openModal } from './modals';
|
||||||
import { expandGroupFeaturedTimeline } from './timelines';
|
import { expandGroupFeaturedTimeline } from './timelines';
|
||||||
|
|
||||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||||
|
@ -85,7 +87,9 @@ const ZAP_FAIL = 'ZAP_FAIL';
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' },
|
bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' },
|
||||||
bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' },
|
bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' },
|
||||||
|
folderChanged: { id: 'status.bookmark_folder_changed', defaultMessage: 'Changed folder' },
|
||||||
view: { id: 'toast.view', defaultMessage: 'View' },
|
view: { id: 'toast.view', defaultMessage: 'View' },
|
||||||
|
selectFolder: { id: 'status.bookmark.select_folder', defaultMessage: 'Select folder' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const reblog = (status: StatusEntity) =>
|
const reblog = (status: StatusEntity) =>
|
||||||
|
@ -342,17 +346,35 @@ const zapFail = (status: StatusEntity, error: unknown) => ({
|
||||||
skipLoading: true,
|
skipLoading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookmark = (status: StatusEntity) =>
|
const bookmark = (status: StatusEntity, folderId?: string) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
|
const instance = state.instance;
|
||||||
|
const features = getFeatures(instance);
|
||||||
|
|
||||||
dispatch(bookmarkRequest(status));
|
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(importFetchedStatus(response.data));
|
||||||
dispatch(bookmarkSuccess(status, response.data));
|
dispatch(bookmarkSuccess(status, response.data));
|
||||||
toast.success(messages.bookmarkAdded, {
|
|
||||||
|
let opts: IToastOptions = {
|
||||||
actionLabel: messages.view,
|
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) {
|
}).catch(function(error) {
|
||||||
dispatch(bookmarkFail(status, error));
|
dispatch(bookmarkFail(status, error));
|
||||||
});
|
});
|
||||||
|
|
|
@ -44,6 +44,13 @@ export { useUnmuteGroup } from './groups/useUnmuteGroup';
|
||||||
export { useUpdateGroup } from './groups/useUpdateGroup';
|
export { useUpdateGroup } from './groups/useUpdateGroup';
|
||||||
export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
|
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
|
// Streaming
|
||||||
export { useUserStream } from './streaming/useUserStream';
|
export { useUserStream } from './streaming/useUserStream';
|
||||||
export { useCommunityStream } from './streaming/useCommunityStream';
|
export { useCommunityStream } from './streaming/useCommunityStream';
|
||||||
|
|
31
src/api/hooks/statuses/useBookmarkFolder.ts
Normal file
31
src/api/hooks/statuses/useBookmarkFolder.ts
Normal file
|
@ -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 };
|
25
src/api/hooks/statuses/useBookmarkFolders.ts
Normal file
25
src/api/hooks/statuses/useBookmarkFolders.ts
Normal file
|
@ -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 };
|
31
src/api/hooks/statuses/useCreateBookmarkFolder.ts
Normal file
31
src/api/hooks/statuses/useCreateBookmarkFolder.ts
Normal file
|
@ -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 };
|
16
src/api/hooks/statuses/useDeleteBookmarkFolder.ts
Normal file
16
src/api/hooks/statuses/useDeleteBookmarkFolder.ts
Normal file
|
@ -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 };
|
31
src/api/hooks/statuses/useUpdateBookmarkFolder.ts
Normal file
31
src/api/hooks/statuses/useUpdateBookmarkFolder.ts
Normal file
|
@ -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 };
|
|
@ -2,6 +2,7 @@ import type * as Schemas from 'soapbox/schemas';
|
||||||
|
|
||||||
enum Entities {
|
enum Entities {
|
||||||
ACCOUNTS = 'Accounts',
|
ACCOUNTS = 'Accounts',
|
||||||
|
BOOKMARK_FOLDERS = 'BookmarkFolders',
|
||||||
GROUPS = 'Groups',
|
GROUPS = 'Groups',
|
||||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||||
GROUP_MUTES = 'GroupMutes',
|
GROUP_MUTES = 'GroupMutes',
|
||||||
|
@ -14,6 +15,7 @@ enum Entities {
|
||||||
|
|
||||||
interface EntityTypes {
|
interface EntityTypes {
|
||||||
[Entities.ACCOUNTS]: Schemas.Account;
|
[Entities.ACCOUNTS]: Schemas.Account;
|
||||||
|
[Entities.BOOKMARK_FOLDERS]: Schemas.BookmarkFolder;
|
||||||
[Entities.GROUPS]: Schemas.Group;
|
[Entities.GROUPS]: Schemas.Group;
|
||||||
[Entities.GROUP_MEMBERSHIPS]: Schemas.GroupMember;
|
[Entities.GROUP_MEMBERSHIPS]: Schemas.GroupMember;
|
||||||
[Entities.GROUP_RELATIONSHIPS]: Schemas.GroupRelationship;
|
[Entities.GROUP_RELATIONSHIPS]: Schemas.GroupRelationship;
|
||||||
|
|
64
src/features/bookmark-folders/components/new-folder-form.tsx
Normal file
64
src/features/bookmark-folders/components/new-folder-form.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
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,
|
||||||
|
}, {
|
||||||
|
onSuccess() {
|
||||||
|
toast.success(messages.createSuccess);
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
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;
|
72
src/features/bookmark-folders/index.tsx
Normal file
72
src/features/bookmark-folders/index.tsx
Normal file
|
@ -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;
|
|
@ -1,42 +1,111 @@
|
||||||
|
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks';
|
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 PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||||
import StatusList from 'soapbox/components/status-list';
|
import StatusList from 'soapbox/components/status-list';
|
||||||
import { Column } from 'soapbox/components/ui';
|
import { Column } from 'soapbox/components/ui';
|
||||||
import { useAppSelector, useAppDispatch, useTheme } from 'soapbox/hooks';
|
import { useAppSelector, useAppDispatch, useTheme } from 'soapbox/hooks';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
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) => {
|
const handleLoadMore = debounce((dispatch, folderId) => {
|
||||||
dispatch(expandBookmarkedStatuses());
|
dispatch(expandBookmarkedStatuses(folderId));
|
||||||
}, 300, { leading: true });
|
}, 300, { leading: true });
|
||||||
|
|
||||||
const Bookmarks: React.FC = () => {
|
interface IBookmarks {
|
||||||
|
params?: {
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Bookmarks: React.FC<IBookmarks> = ({ params }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const history = useHistory();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const statusIds = useAppSelector((state) => state.status_lists.get('bookmarks')!.items);
|
const folderId = params?.id;
|
||||||
const isLoading = useAppSelector((state) => state.status_lists.get('bookmarks')!.isLoading);
|
|
||||||
const hasMore = useAppSelector((state) => !!state.status_lists.get('bookmarks')!.next);
|
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(() => {
|
React.useEffect(() => {
|
||||||
dispatch(fetchBookmarkedStatuses());
|
dispatch(fetchBookmarkedStatuses(folderId));
|
||||||
}, []);
|
}, [folderId]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
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!, {
|
||||||
|
onSuccess() {
|
||||||
|
toast.success(messages.deleteFolderSuccess);
|
||||||
|
history.push('/bookmarks');
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
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 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 (
|
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}>
|
<PullToRefresh onRefresh={handleRefresh}>
|
||||||
<StatusList
|
<StatusList
|
||||||
className='black:p-4 black:sm:p-5'
|
className='black:p-4 black:sm:p-5'
|
||||||
|
@ -44,7 +113,7 @@ const Bookmarks: React.FC = () => {
|
||||||
scrollKey='bookmarked_statuses'
|
scrollKey='bookmarked_statuses'
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||||
onLoadMore={() => handleLoadMore(dispatch)}
|
onLoadMore={() => handleLoadMore(dispatch, folderId)}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
divideType={theme === 'black' ? 'border' : 'space'}
|
divideType={theme === 'black' ? 'border' : 'space'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -55,6 +55,7 @@ const messages = defineMessages({
|
||||||
displayNamePlaceholder: { id: 'edit_profile.fields.display_name_placeholder', defaultMessage: 'Name' },
|
displayNamePlaceholder: { id: 'edit_profile.fields.display_name_placeholder', defaultMessage: 'Name' },
|
||||||
websitePlaceholder: { id: 'edit_profile.fields.website_placeholder', defaultMessage: 'Display a Link' },
|
websitePlaceholder: { id: 'edit_profile.fields.website_placeholder', defaultMessage: 'Display a Link' },
|
||||||
locationPlaceholder: { id: 'edit_profile.fields.location_placeholder', defaultMessage: 'Location' },
|
locationPlaceholder: { id: 'edit_profile.fields.location_placeholder', defaultMessage: 'Location' },
|
||||||
|
nip05Placeholder: { id: 'edit_profile.fields.nip05_placeholder', defaultMessage: 'user@{domain}' },
|
||||||
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
|
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -75,6 +76,11 @@ interface AccountCredentialsSource {
|
||||||
sensitive?: boolean;
|
sensitive?: boolean;
|
||||||
/** Default language to use for authored statuses. (ISO 6391) */
|
/** Default language to use for authored statuses. (ISO 6391) */
|
||||||
language?: string;
|
language?: string;
|
||||||
|
/** Nostr metadata. */
|
||||||
|
nostr?: {
|
||||||
|
/** Nostr NIP-05 identifier. */
|
||||||
|
nip05?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -120,6 +126,8 @@ interface AccountCredentials {
|
||||||
location?: string;
|
location?: string;
|
||||||
/** User's birthday. */
|
/** User's birthday. */
|
||||||
birthday?: string;
|
birthday?: string;
|
||||||
|
/** Nostr NIP-05 identifier. */
|
||||||
|
nip05?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert an account into an update_credentials request object. */
|
/** Convert an account into an update_credentials request object. */
|
||||||
|
@ -142,6 +150,7 @@ const accountToCredentials = (account: Account): AccountCredentials => {
|
||||||
website: account.website,
|
website: account.website,
|
||||||
location: account.location,
|
location: account.location,
|
||||||
birthday: account.pleroma?.birthday ?? undefined,
|
birthday: account.pleroma?.birthday ?? undefined,
|
||||||
|
nip05: account.source?.nostr?.nip05 ?? '',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -308,6 +317,19 @@ const EditProfile: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
{features.nip05 && (
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.nip05_label' defaultMessage='Username' />}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={data.nip05}
|
||||||
|
onChange={handleTextChange('nip05')}
|
||||||
|
placeholder={intl.formatMessage(messages.nip05Placeholder, { domain: location.host })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{features.birthdays && (
|
{features.birthdays && (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
|
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
|
||||||
|
|
|
@ -16,7 +16,6 @@ import type { RootState } from 'soapbox/store';
|
||||||
import type { List as ListEntity } from 'soapbox/types/entities';
|
import type { List as ListEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
|
||||||
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
|
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
|
||||||
add: { id: 'lists.new.create', defaultMessage: 'Add list' },
|
add: { id: 'lists.new.create', defaultMessage: 'Add list' },
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,7 +10,6 @@ import EditListForm from './components/edit-list-form';
|
||||||
import Search from './components/search';
|
import Search from './components/search';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
|
||||||
changeTitle: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
|
changeTitle: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
|
||||||
addToList: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
addToList: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||||
removeFromList: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
removeFromList: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
CryptoDonateModal,
|
CryptoDonateModal,
|
||||||
DislikesModal,
|
DislikesModal,
|
||||||
EditAnnouncementModal,
|
EditAnnouncementModal,
|
||||||
|
EditBookmarkFolderModal,
|
||||||
EditFederationModal,
|
EditFederationModal,
|
||||||
EmbedModal,
|
EmbedModal,
|
||||||
EventMapModal,
|
EventMapModal,
|
||||||
|
@ -36,6 +37,7 @@ import {
|
||||||
ReblogsModal,
|
ReblogsModal,
|
||||||
ReplyMentionsModal,
|
ReplyMentionsModal,
|
||||||
ReportModal,
|
ReportModal,
|
||||||
|
SelectBookmarkFolderModal,
|
||||||
UnauthorizedModal,
|
UnauthorizedModal,
|
||||||
VideoModal,
|
VideoModal,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
@ -57,6 +59,7 @@ const MODAL_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
|
||||||
'CRYPTO_DONATE': CryptoDonateModal,
|
'CRYPTO_DONATE': CryptoDonateModal,
|
||||||
'DISLIKES': DislikesModal,
|
'DISLIKES': DislikesModal,
|
||||||
'EDIT_ANNOUNCEMENT': EditAnnouncementModal,
|
'EDIT_ANNOUNCEMENT': EditAnnouncementModal,
|
||||||
|
'EDIT_BOOKMARK_FOLDER': EditBookmarkFolderModal,
|
||||||
'EDIT_FEDERATION': EditFederationModal,
|
'EDIT_FEDERATION': EditFederationModal,
|
||||||
'EMBED': EmbedModal,
|
'EMBED': EmbedModal,
|
||||||
'EVENT_MAP': EventMapModal,
|
'EVENT_MAP': EventMapModal,
|
||||||
|
@ -78,6 +81,7 @@ const MODAL_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
|
||||||
'REBLOGS': ReblogsModal,
|
'REBLOGS': ReblogsModal,
|
||||||
'REPLY_MENTIONS': ReplyMentionsModal,
|
'REPLY_MENTIONS': ReplyMentionsModal,
|
||||||
'REPORT': ReportModal,
|
'REPORT': ReportModal,
|
||||||
|
'SELECT_BOOKMARK_FOLDER': SelectBookmarkFolderModal,
|
||||||
'UNAUTHORIZED': UnauthorizedModal,
|
'UNAUTHORIZED': UnauthorizedModal,
|
||||||
'VIDEO': VideoModal,
|
'VIDEO': VideoModal,
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,7 +12,6 @@ import { useAppDispatch, useAppSelector, useCompose, useDraggedFiles } from 'soa
|
||||||
import ComposeForm from '../../../compose/components/compose-form';
|
import ComposeForm from '../../../compose/components/compose-form';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
|
||||||
confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' },
|
confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' },
|
||||||
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
|
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
|
||||||
});
|
});
|
||||||
|
|
162
src/features/ui/components/modals/edit-bookmark-folder-modal.tsx
Normal file
162
src/features/ui/components/modals/edit-bookmark-folder-modal.tsx
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
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,
|
||||||
|
}, {
|
||||||
|
onSuccess() {
|
||||||
|
toast.success(intl.formatMessage(messages.editSuccess));
|
||||||
|
dispatch(closeModal('EDIT_BOOKMARK_FOLDER'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
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;
|
|
@ -13,7 +13,6 @@ import { ReactionRecord } from 'soapbox/reducers/user-lists';
|
||||||
import type { Item } from 'soapbox/components/ui/tabs/tabs';
|
import type { Item } from 'soapbox/components/ui/tabs/tabs';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
|
||||||
all: { id: 'reactions.all', defaultMessage: 'All' },
|
all: { id: 'reactions.all', defaultMessage: 'All' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -9,7 +9,6 @@ import { selectAccount } from 'soapbox/selectors';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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' },
|
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' },
|
userNotFoundError: { id: 'remote_interaction.user_not_found_error', defaultMessage: 'Couldn\'t find given user' },
|
||||||
});
|
});
|
||||||
|
|
|
@ -136,6 +136,7 @@ import {
|
||||||
RegisterInvite,
|
RegisterInvite,
|
||||||
ExternalLogin,
|
ExternalLogin,
|
||||||
LandingTimeline,
|
LandingTimeline,
|
||||||
|
BookmarkFolders,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import GlobalHotkeys from './util/global-hotkeys';
|
import GlobalHotkeys from './util/global-hotkeys';
|
||||||
import { WrappedRoute } from './util/react-router-helpers';
|
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='/lists' page={DefaultPage} component={Lists} content={children} />}
|
||||||
{features.lists && <WrappedRoute path='/list/:id' page={DefaultPage} component={ListTimeline} 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} />}
|
||||||
|
<WrappedRoute path='/bookmarks' page={DefaultPage} component={BookmarkFolders} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/notifications' page={DefaultPage} component={Notifications} content={children} />
|
<WrappedRoute path='/notifications' page={DefaultPage} component={Notifications} content={children} />
|
||||||
|
|
||||||
|
|
|
@ -164,3 +164,6 @@ export const AccountNotePanel = lazy(() => import('soapbox/features/ui/component
|
||||||
export const ComposeEditor = lazy(() => import('soapbox/features/compose/editor'));
|
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 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 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'));
|
||||||
|
|
|
@ -19,9 +19,6 @@ const messages = defineMessages({
|
||||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||||
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
||||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute 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' },
|
fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
|
||||||
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
|
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
||||||
* Returns props for `<input type="text">`.
|
* Returns props for `<input type="text">`.
|
||||||
* If `initialValue` changes from undefined to a string, it will set the value.
|
* 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 [value, setValue] = useState(initialValue);
|
||||||
const hasInitialValue = typeof initialValue === 'string';
|
const hasInitialValue = typeof initialValue === 'string';
|
||||||
|
|
||||||
|
|
|
@ -197,6 +197,17 @@
|
||||||
"badge_input.placeholder": "Enter a badge…",
|
"badge_input.placeholder": "Enter a badge…",
|
||||||
"birthday_panel.title": "Birthdays",
|
"birthday_panel.title": "Birthdays",
|
||||||
"birthdays_modal.empty": "None of your friends have birthday today.",
|
"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.combo": "You can press {combo} to skip this next time",
|
||||||
"boost_modal.title": "Repost?",
|
"boost_modal.title": "Repost?",
|
||||||
"bundle_column_error.body": "Something went wrong while loading this page.",
|
"bundle_column_error.body": "Something went wrong while loading this page.",
|
||||||
|
@ -484,6 +495,9 @@
|
||||||
"confirmations.delete.confirm": "Delete",
|
"confirmations.delete.confirm": "Delete",
|
||||||
"confirmations.delete.heading": "Delete post",
|
"confirmations.delete.heading": "Delete post",
|
||||||
"confirmations.delete.message": "Are you sure you want to delete this 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.confirm": "Delete",
|
||||||
"confirmations.delete_event.heading": "Delete event",
|
"confirmations.delete_event.heading": "Delete event",
|
||||||
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
|
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
|
||||||
|
@ -565,6 +579,8 @@
|
||||||
"directory.local": "From {domain} only",
|
"directory.local": "From {domain} only",
|
||||||
"directory.new_arrivals": "New arrivals",
|
"directory.new_arrivals": "New arrivals",
|
||||||
"directory.recently_active": "Recently active",
|
"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.header": "Change Email",
|
||||||
"edit_email.placeholder": "me@example.com",
|
"edit_email.placeholder": "me@example.com",
|
||||||
"edit_federation.followers_only": "Hide posts except to followers",
|
"edit_federation.followers_only": "Hide posts except to followers",
|
||||||
|
@ -592,6 +608,8 @@
|
||||||
"edit_profile.fields.meta_fields.content_placeholder": "Content",
|
"edit_profile.fields.meta_fields.content_placeholder": "Content",
|
||||||
"edit_profile.fields.meta_fields.label_placeholder": "Label",
|
"edit_profile.fields.meta_fields.label_placeholder": "Label",
|
||||||
"edit_profile.fields.meta_fields_label": "Profile fields",
|
"edit_profile.fields.meta_fields_label": "Profile fields",
|
||||||
|
"edit_profile.fields.nip05_label": "Username",
|
||||||
|
"edit_profile.fields.nip05_placeholder": "user@{domain}",
|
||||||
"edit_profile.fields.stranger_notifications_label": "Block notifications from strangers",
|
"edit_profile.fields.stranger_notifications_label": "Block notifications from strangers",
|
||||||
"edit_profile.fields.website_label": "Website",
|
"edit_profile.fields.website_label": "Website",
|
||||||
"edit_profile.fields.website_placeholder": "Display a Link",
|
"edit_profile.fields.website_placeholder": "Display a Link",
|
||||||
|
@ -1322,6 +1340,7 @@
|
||||||
"security.update_email.success": "Email successfully updated.",
|
"security.update_email.success": "Email successfully updated.",
|
||||||
"security.update_password.fail": "Update password failed.",
|
"security.update_password.fail": "Update password failed.",
|
||||||
"security.update_password.success": "Password successfully updated.",
|
"security.update_password.success": "Password successfully updated.",
|
||||||
|
"select_bookmark_folder_modal.header_title": "Select folder",
|
||||||
"settings.account_migration": "Move Account",
|
"settings.account_migration": "Move Account",
|
||||||
"settings.blocks": "Blocks",
|
"settings.blocks": "Blocks",
|
||||||
"settings.change_email": "Change Email",
|
"settings.change_email": "Change Email",
|
||||||
|
@ -1397,6 +1416,8 @@
|
||||||
"status.approval.pending": "Pending approval",
|
"status.approval.pending": "Pending approval",
|
||||||
"status.approval.rejected": "Rejected",
|
"status.approval.rejected": "Rejected",
|
||||||
"status.bookmark": "Bookmark",
|
"status.bookmark": "Bookmark",
|
||||||
|
"status.bookmark.select_folder": "Select folder",
|
||||||
|
"status.bookmark_folder_changed": "Changed folder",
|
||||||
"status.bookmarked": "Bookmark added.",
|
"status.bookmarked": "Bookmark added.",
|
||||||
"status.cancel_reblog_private": "Un-repost",
|
"status.cancel_reblog_private": "Un-repost",
|
||||||
"status.cannot_reblog": "This post cannot be reposted",
|
"status.cannot_reblog": "This post cannot be reposted",
|
||||||
|
@ -1526,12 +1547,9 @@
|
||||||
"upload_form.preview": "Preview",
|
"upload_form.preview": "Preview",
|
||||||
"upload_form.undo": "Delete",
|
"upload_form.undo": "Delete",
|
||||||
"upload_progress.label": "Uploading…",
|
"upload_progress.label": "Uploading…",
|
||||||
"video.close": "Close video",
|
|
||||||
"video.download": "Download file",
|
"video.download": "Download file",
|
||||||
"video.exit_fullscreen": "Exit full screen",
|
"video.exit_fullscreen": "Exit full screen",
|
||||||
"video.expand": "Expand video",
|
|
||||||
"video.fullscreen": "Full screen",
|
"video.fullscreen": "Full screen",
|
||||||
"video.hide": "Hide video",
|
|
||||||
"video.mute": "Mute sound",
|
"video.mute": "Mute sound",
|
||||||
"video.pause": "Pause",
|
"video.pause": "Pause",
|
||||||
"video.play": "Play",
|
"video.play": "Play",
|
||||||
|
|
|
@ -67,7 +67,7 @@ import {
|
||||||
} from '../actions/scheduled-statuses';
|
} from '../actions/scheduled-statuses';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
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({
|
export const StatusListRecord = ImmutableRecord({
|
||||||
next: null as string | null,
|
next: null as string | null,
|
||||||
|
@ -94,7 +94,8 @@ const getStatusIds = (statuses: APIEntity[] = []) => (
|
||||||
ImmutableOrderedSet(statuses.map(getStatusId))
|
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) => {
|
const normalizeList = (state: State, listType: string, statuses: APIEntity[], next: string | null) => {
|
||||||
return state.update(listType, StatusListRecord(), listMap => listMap.withMutations(map => {
|
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 prependOneToList = (state: State, listType: string, status: APIEntity) => {
|
||||||
const statusId = getStatusId(status);
|
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>);
|
return ImmutableOrderedSet([statusId]).union(items as ImmutableOrderedSet<string>);
|
||||||
});
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeOneFromList = (state: State, listType: string, status: APIEntity) => {
|
const removeOneFromList = (state: State, listType: string, status: APIEntity) => {
|
||||||
const statusId = getStatusId(status);
|
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) => {
|
const maybeAppendScheduledStatus = (state: State, status: APIEntity) => {
|
||||||
|
@ -132,6 +133,24 @@ const maybeAppendScheduledStatus = (state: State, status: APIEntity) => {
|
||||||
return prependOneToList(state, 'scheduled_statuses', getStatusId(status));
|
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) {
|
export default function statusLists(state = initialState, action: AnyAction) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case FAVOURITED_STATUSES_FETCH_REQUEST:
|
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);
|
return appendToList(state, `favourites:${action.accountId}`, action.statuses, action.next);
|
||||||
case BOOKMARKED_STATUSES_FETCH_REQUEST:
|
case BOOKMARKED_STATUSES_FETCH_REQUEST:
|
||||||
case BOOKMARKED_STATUSES_EXPAND_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_FETCH_FAIL:
|
||||||
case BOOKMARKED_STATUSES_EXPAND_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:
|
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:
|
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:
|
case FAVOURITE_SUCCESS:
|
||||||
return prependOneToList(state, 'favourites', action.status);
|
return prependOneToList(state, 'favourites', action.status);
|
||||||
case UNFAVOURITE_SUCCESS:
|
case UNFAVOURITE_SUCCESS:
|
||||||
return removeOneFromList(state, 'favourites', action.status);
|
return removeOneFromList(state, 'favourites', action.status);
|
||||||
case BOOKMARK_SUCCESS:
|
case BOOKMARK_SUCCESS:
|
||||||
return prependOneToList(state, 'bookmarks', action.status);
|
return addBookmarkToLists(state, action.response);
|
||||||
case UNBOOKMARK_SUCCESS:
|
case UNBOOKMARK_SUCCESS:
|
||||||
return removeOneFromList(state, 'bookmarks', action.status);
|
return removeBookmarkFromLists(state, action.status);
|
||||||
case PINNED_STATUSES_FETCH_SUCCESS:
|
case PINNED_STATUSES_FETCH_SUCCESS:
|
||||||
return normalizeList(state, 'pins', action.statuses, action.next);
|
return normalizeList(state, 'pins', action.statuses, action.next);
|
||||||
case PIN_SUCCESS:
|
case PIN_SUCCESS:
|
||||||
|
|
|
@ -94,6 +94,9 @@ const baseAccountSchema = z.object({
|
||||||
discoverable: z.boolean().catch(true),
|
discoverable: z.boolean().catch(true),
|
||||||
}).optional().catch(undefined),
|
}).optional().catch(undefined),
|
||||||
sms_verified: z.boolean().catch(false),
|
sms_verified: z.boolean().catch(false),
|
||||||
|
nostr: z.object({
|
||||||
|
nip05: z.string().optional().catch(undefined),
|
||||||
|
}).optional().catch(undefined),
|
||||||
}).optional().catch(undefined),
|
}).optional().catch(undefined),
|
||||||
statuses_count: z.number().catch(0),
|
statuses_count: z.number().catch(0),
|
||||||
suspended: z.boolean().catch(false),
|
suspended: z.boolean().catch(false),
|
||||||
|
|
13
src/schemas/bookmark-folder.ts
Normal file
13
src/schemas/bookmark-folder.ts
Normal file
|
@ -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 };
|
|
@ -1,5 +1,6 @@
|
||||||
export { accountSchema, type Account } from './account';
|
export { accountSchema, type Account } from './account';
|
||||||
export { attachmentSchema, type Attachment } from './attachment';
|
export { attachmentSchema, type Attachment } from './attachment';
|
||||||
|
export { bookmarkFolderSchema, type BookmarkFolder } from './bookmark-folder';
|
||||||
export { cardSchema, type Card } from './card';
|
export { cardSchema, type Card } from './card';
|
||||||
export { chatMessageSchema, type ChatMessage } from './chat-message';
|
export { chatMessageSchema, type ChatMessage } from './chat-message';
|
||||||
export { customEmojiSchema, type CustomEmoji } from './custom-emoji';
|
export { customEmojiSchema, type CustomEmoji } from './custom-emoji';
|
||||||
|
|
|
@ -30,6 +30,7 @@ const baseStatusSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
website: z.string().url().nullable().catch(null),
|
website: z.string().url().nullable().catch(null),
|
||||||
}).nullable().catch(null),
|
}).nullable().catch(null),
|
||||||
|
bookmark_folder: z.string().nullable().catch(null),
|
||||||
bookmarked: z.coerce.boolean(),
|
bookmarked: z.coerce.boolean(),
|
||||||
card: cardSchema.nullable().catch(null),
|
card: cardSchema.nullable().catch(null),
|
||||||
content: contentSchema,
|
content: contentSchema,
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { httpErrorMessages } from './utils/errors';
|
||||||
export type ToastText = string | MessageDescriptor
|
export type ToastText = string | MessageDescriptor
|
||||||
export type ToastType = 'success' | 'error' | 'info'
|
export type ToastType = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
interface IToastOptions {
|
export interface IToastOptions {
|
||||||
action?(): void;
|
action?(): void;
|
||||||
actionLink?: string;
|
actionLink?: string;
|
||||||
actionLabel?: ToastText;
|
actionLabel?: ToastText;
|
||||||
|
|
|
@ -231,6 +231,15 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
/** Whether people who blocked you are visible through the API. */
|
/** Whether people who blocked you are visible through the API. */
|
||||||
blockersVisible: features.includes('blockers_visible'),
|
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.
|
* Can bookmark statuses.
|
||||||
* @see POST /api/v1/statuses/:id/bookmark
|
* @see POST /api/v1/statuses/:id/bookmark
|
||||||
|
@ -722,6 +731,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
v.software === TAKAHE,
|
v.software === TAKAHE,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can set a Nostr username.
|
||||||
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
*/
|
||||||
|
nip05: v.software === DITTO,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ability to sign Nostr events over websocket.
|
* Ability to sign Nostr events over websocket.
|
||||||
* @see GET /api/v1/streaming?stream=nostr
|
* @see GET /api/v1/streaming?stream=nostr
|
||||||
|
|
Loading…
Reference in a new issue