Merge remote-tracking branch 'origin/develop' into nostr-ws
This commit is contained in:
commit
a5c616312f
|
@ -1,4 +1,4 @@
|
|||
image: node:18
|
||||
image: node:20
|
||||
|
||||
variables:
|
||||
NODE_ENV: test
|
||||
|
|
|
@ -1 +1 @@
|
|||
nodejs 18.14.0
|
||||
nodejs 20.0.0
|
||||
|
|
|
@ -7,16 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Hashtags: let users follow hashtags (Mastodon, Akkoma).
|
||||
- Posts: Support posts filtering on recent Mastodon versions
|
||||
- Reactions: Support custom emoji reactions
|
||||
- Compatbility: Support Mastodon v2 timeline filters.
|
||||
- Compatbility: Preliminary support for Ditto backend.
|
||||
- Posts: Support dislikes on Friendica.
|
||||
- UI: added a character counter to some textareas.
|
||||
|
||||
### Changed
|
||||
- Posts: truncate Nostr pubkeys in reply mentions.
|
||||
- Posts: upgraded emoji picker component.
|
||||
- Posts: improved design of threads.
|
||||
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
|
||||
- UI: added sticky column header.
|
||||
- UI: add specific zones the user can drag-and-drop files.
|
||||
|
||||
### Fixed
|
||||
- Posts: fixed emojis being cut off in reactions modal.
|
||||
|
@ -27,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- 18n: fixed Chinese language being detected from the browser.
|
||||
- Conversations: fixed pagination (Mastodon).
|
||||
- Compatibility: fix version parsing for Friendica.
|
||||
- UI: fixed various overflow issues related to long usernames.
|
||||
- UI: fixed display of Markdown code blocks in the reply indicator.
|
||||
|
||||
## [3.2.0] - 2023-02-15
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:18 as build
|
||||
FROM node:20 as build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY yarn.lock .
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:18
|
||||
FROM node:20
|
||||
|
||||
RUN apt-get update &&\
|
||||
apt-get install -y inotify-tools &&\
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { buildRelationship } from 'soapbox/jest/factory';
|
||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||
import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes';
|
||||
|
||||
import { normalizeAccount, normalizeRelationship } from '../../normalizers';
|
||||
import { normalizeAccount } from '../../normalizers';
|
||||
import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';
|
||||
|
||||
import type { Account } from 'soapbox/types/entities';
|
||||
|
@ -66,7 +67,7 @@ describe('initAccountNoteModal()', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
const state = rootState
|
||||
.set('relationships', ImmutableMap({ '1': normalizeRelationship({ note: 'hello' }) }));
|
||||
.set('relationships', ImmutableMap({ '1': buildRelationship({ note: 'hello' }) }));
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { buildRelationship } from 'soapbox/jest/factory';
|
||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists';
|
||||
|
||||
import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers';
|
||||
import { normalizeAccount, normalizeInstance } from '../../normalizers';
|
||||
import {
|
||||
authorizeFollowRequest,
|
||||
blockAccount,
|
||||
|
@ -1340,7 +1341,7 @@ describe('fetchRelationships()', () => {
|
|||
describe('without newAccountIds', () => {
|
||||
beforeEach(() => {
|
||||
const state = rootState
|
||||
.set('relationships', ImmutableMap({ [id]: normalizeRelationship({}) }))
|
||||
.set('relationships', ImmutableMap({ [id]: buildRelationship() }))
|
||||
.set('me', '123');
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
|
|
@ -242,7 +242,8 @@ export const fetchOwnAccounts = () =>
|
|||
return state.auth.users.forEach((user) => {
|
||||
const account = state.accounts.get(user.id);
|
||||
if (!account) {
|
||||
dispatch(verifyCredentials(user.access_token, user.url));
|
||||
dispatch(verifyCredentials(user.access_token, user.url))
|
||||
.catch(() => console.warn(`Failed to load account: ${user.url}`));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -22,6 +22,7 @@ import { createStatus } from './statuses';
|
|||
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import type { Emoji } from 'soapbox/features/emoji';
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
|
||||
import type { History } from 'soapbox/types/history';
|
||||
|
@ -168,6 +169,14 @@ const cancelQuoteCompose = () => ({
|
|||
id: 'compose-modal',
|
||||
});
|
||||
|
||||
const groupComposeModal = (group: Group) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const composeId = `group:${group.id}`;
|
||||
|
||||
dispatch(groupCompose(composeId, group.id));
|
||||
dispatch(openModal('COMPOSE', { composeId }));
|
||||
};
|
||||
|
||||
const resetCompose = (composeId = 'compose-modal') => ({
|
||||
type: COMPOSE_RESET,
|
||||
id: composeId,
|
||||
|
@ -829,6 +838,7 @@ export {
|
|||
uploadComposeFail,
|
||||
undoUploadCompose,
|
||||
groupCompose,
|
||||
groupComposeModal,
|
||||
setGroupTimelineVisible,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
|
|
|
@ -1,21 +1,14 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { deleteEntities } from 'soapbox/entity-store/actions';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedGroups, importFetchedAccounts } from './importer';
|
||||
import { closeModal, openModal } from './modals';
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { GroupRole } from 'soapbox/reducers/group-memberships';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity, Group } from 'soapbox/types/entities';
|
||||
|
||||
const GROUP_EDITOR_SET = 'GROUP_EDITOR_SET';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
|
||||
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
|
||||
|
@ -41,10 +34,6 @@ const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST';
|
|||
const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS';
|
||||
const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
|
||||
|
||||
const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST';
|
||||
const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS';
|
||||
const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL';
|
||||
|
||||
const GROUP_KICK_REQUEST = 'GROUP_KICK_REQUEST';
|
||||
const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS';
|
||||
const GROUP_KICK_FAIL = 'GROUP_KICK_FAIL';
|
||||
|
@ -97,100 +86,6 @@ const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT
|
|||
const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS';
|
||||
const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL';
|
||||
|
||||
const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE';
|
||||
const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE';
|
||||
const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE';
|
||||
const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE';
|
||||
|
||||
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
|
||||
|
||||
const messages = defineMessages({
|
||||
success: { id: 'manage_group.submit_success', defaultMessage: 'The group was created' },
|
||||
editSuccess: { id: 'manage_group.edit_success', defaultMessage: 'The group was edited' },
|
||||
joinSuccess: { id: 'group.join.success', defaultMessage: 'Joined the group' },
|
||||
joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' },
|
||||
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
|
||||
view: { id: 'toast.view', defaultMessage: 'View' },
|
||||
});
|
||||
|
||||
const editGroup = (group: Group) => (dispatch: AppDispatch) => {
|
||||
dispatch({
|
||||
type: GROUP_EDITOR_SET,
|
||||
group,
|
||||
});
|
||||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(createGroupRequest());
|
||||
|
||||
return api(getState).post('/api/v1/groups', params, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedGroups([data]));
|
||||
dispatch(createGroupSuccess(data));
|
||||
toast.success(messages.success, {
|
||||
actionLabel: messages.view,
|
||||
actionLink: `/groups/${data.id}`,
|
||||
});
|
||||
|
||||
if (shouldReset) {
|
||||
dispatch(resetGroupEditor());
|
||||
}
|
||||
|
||||
return data;
|
||||
}).catch(err => dispatch(createGroupFail(err)));
|
||||
};
|
||||
|
||||
const createGroupRequest = () => ({
|
||||
type: GROUP_CREATE_REQUEST,
|
||||
});
|
||||
|
||||
const createGroupSuccess = (group: APIEntity) => ({
|
||||
type: GROUP_CREATE_SUCCESS,
|
||||
group,
|
||||
});
|
||||
|
||||
const createGroupFail = (error: AxiosError) => ({
|
||||
type: GROUP_CREATE_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const updateGroup = (id: string, params: Record<string, any>, shouldReset?: boolean) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(updateGroupRequest());
|
||||
|
||||
return api(getState).put(`/api/v1/groups/${id}`, params)
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedGroups([data]));
|
||||
dispatch(updateGroupSuccess(data));
|
||||
toast.success(messages.editSuccess);
|
||||
|
||||
if (shouldReset) {
|
||||
dispatch(resetGroupEditor());
|
||||
}
|
||||
dispatch(closeModal('MANAGE_GROUP'));
|
||||
}).catch(err => dispatch(updateGroupFail(err)));
|
||||
};
|
||||
|
||||
const updateGroupRequest = () => ({
|
||||
type: GROUP_UPDATE_REQUEST,
|
||||
});
|
||||
|
||||
const updateGroupSuccess = (group: APIEntity) => ({
|
||||
type: GROUP_UPDATE_SUCCESS,
|
||||
group,
|
||||
});
|
||||
|
||||
const updateGroupFail = (error: AxiosError) => ({
|
||||
type: GROUP_UPDATE_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(deleteEntities([id], 'Group'));
|
||||
|
||||
|
@ -306,36 +201,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({
|
|||
skipNotFound: true,
|
||||
});
|
||||
|
||||
const groupDeleteStatus = (groupId: string, statusId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupDeleteStatusRequest(groupId, statusId));
|
||||
|
||||
return api(getState).delete(`/api/v1/groups/${groupId}/statuses/${statusId}`)
|
||||
.then(() => {
|
||||
dispatch(deleteFromTimelines(statusId));
|
||||
dispatch(groupDeleteStatusSuccess(groupId, statusId));
|
||||
}).catch(err => dispatch(groupDeleteStatusFail(groupId, statusId, err)));
|
||||
};
|
||||
|
||||
const groupDeleteStatusRequest = (groupId: string, statusId: string) => ({
|
||||
type: GROUP_DELETE_STATUS_REQUEST,
|
||||
groupId,
|
||||
statusId,
|
||||
});
|
||||
|
||||
const groupDeleteStatusSuccess = (groupId: string, statusId: string) => ({
|
||||
type: GROUP_DELETE_STATUS_SUCCESS,
|
||||
groupId,
|
||||
statusId,
|
||||
});
|
||||
|
||||
const groupDeleteStatusFail = (groupId: string, statusId: string, error: AxiosError) => ({
|
||||
type: GROUP_DELETE_STATUS_SUCCESS,
|
||||
groupId,
|
||||
statusId,
|
||||
error,
|
||||
});
|
||||
|
||||
const groupKick = (groupId: string, accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupKickRequest(groupId, accountId));
|
||||
|
@ -758,57 +623,7 @@ const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, er
|
|||
error,
|
||||
});
|
||||
|
||||
const changeGroupEditorTitle = (value: string) => ({
|
||||
type: GROUP_EDITOR_TITLE_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeGroupEditorDescription = (value: string) => ({
|
||||
type: GROUP_EDITOR_DESCRIPTION_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeGroupEditorPrivacy = (value: boolean) => ({
|
||||
type: GROUP_EDITOR_PRIVACY_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
const changeGroupEditorMedia = (mediaType: 'header' | 'avatar', file: File) => ({
|
||||
type: GROUP_EDITOR_MEDIA_CHANGE,
|
||||
mediaType,
|
||||
value: file,
|
||||
});
|
||||
|
||||
const resetGroupEditor = () => ({
|
||||
type: GROUP_EDITOR_RESET,
|
||||
});
|
||||
|
||||
const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const groupId = getState().group_editor.groupId;
|
||||
const displayName = getState().group_editor.displayName;
|
||||
const note = getState().group_editor.note;
|
||||
const avatar = getState().group_editor.avatar;
|
||||
const header = getState().group_editor.header;
|
||||
const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social
|
||||
|
||||
const params: Record<string, any> = {
|
||||
display_name: displayName,
|
||||
group_visibility: visibility,
|
||||
note,
|
||||
};
|
||||
|
||||
if (avatar) params.avatar = avatar;
|
||||
if (header) params.header = header;
|
||||
|
||||
if (groupId === null) {
|
||||
return dispatch(createGroup(params, shouldReset));
|
||||
} else {
|
||||
return dispatch(updateGroup(groupId, params, shouldReset));
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
GROUP_EDITOR_SET,
|
||||
GROUP_CREATE_REQUEST,
|
||||
GROUP_CREATE_SUCCESS,
|
||||
GROUP_CREATE_FAIL,
|
||||
|
@ -827,9 +642,6 @@ export {
|
|||
GROUP_RELATIONSHIPS_FETCH_REQUEST,
|
||||
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||
GROUP_RELATIONSHIPS_FETCH_FAIL,
|
||||
GROUP_DELETE_STATUS_REQUEST,
|
||||
GROUP_DELETE_STATUS_SUCCESS,
|
||||
GROUP_DELETE_STATUS_FAIL,
|
||||
GROUP_KICK_REQUEST,
|
||||
GROUP_KICK_SUCCESS,
|
||||
GROUP_KICK_FAIL,
|
||||
|
@ -869,20 +681,6 @@ export {
|
|||
GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
|
||||
GROUP_EDITOR_TITLE_CHANGE,
|
||||
GROUP_EDITOR_DESCRIPTION_CHANGE,
|
||||
GROUP_EDITOR_PRIVACY_CHANGE,
|
||||
GROUP_EDITOR_MEDIA_CHANGE,
|
||||
GROUP_EDITOR_RESET,
|
||||
editGroup,
|
||||
createGroup,
|
||||
createGroupRequest,
|
||||
createGroupSuccess,
|
||||
createGroupFail,
|
||||
updateGroup,
|
||||
updateGroupRequest,
|
||||
updateGroupSuccess,
|
||||
updateGroupFail,
|
||||
deleteGroup,
|
||||
deleteGroupRequest,
|
||||
deleteGroupSuccess,
|
||||
|
@ -899,10 +697,6 @@ export {
|
|||
fetchGroupRelationshipsRequest,
|
||||
fetchGroupRelationshipsSuccess,
|
||||
fetchGroupRelationshipsFail,
|
||||
groupDeleteStatus,
|
||||
groupDeleteStatusRequest,
|
||||
groupDeleteStatusSuccess,
|
||||
groupDeleteStatusFail,
|
||||
groupKick,
|
||||
groupKickRequest,
|
||||
groupKickSuccess,
|
||||
|
@ -955,10 +749,4 @@ export {
|
|||
rejectGroupMembershipRequestRequest,
|
||||
rejectGroupMembershipRequestSuccess,
|
||||
rejectGroupMembershipRequestFail,
|
||||
changeGroupEditorTitle,
|
||||
changeGroupEditorDescription,
|
||||
changeGroupEditorPrivacy,
|
||||
changeGroupEditorMedia,
|
||||
resetGroupEditor,
|
||||
submitGroupEditor,
|
||||
};
|
||||
|
|
|
@ -74,7 +74,7 @@ const importFetchedGroup = (group: APIEntity) =>
|
|||
importFetchedGroups([group]);
|
||||
|
||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||
const entities = filteredArray(groupSchema).catch([]).parse(groups);
|
||||
const entities = filteredArray(groupSchema).parse(groups);
|
||||
return importGroups(entities);
|
||||
};
|
||||
|
||||
|
|
|
@ -142,7 +142,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = ()
|
|||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: intl.formatMessage(messages.deleteStatusHeading),
|
||||
message: intl.formatMessage(messages.deleteStatusPrompt, { acct }),
|
||||
message: intl.formatMessage(messages.deleteStatusPrompt, { acct: <strong className='break-words'>{acct}</strong> }),
|
||||
confirm: intl.formatMessage(messages.deleteStatusConfirm),
|
||||
onConfirm: () => {
|
||||
dispatch(deleteStatus(statusId)).then(() => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||
|
@ -83,10 +83,6 @@ const submitSearch = (filter?: SearchFilter) =>
|
|||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
}
|
||||
|
||||
if (response.data.groups) {
|
||||
dispatch(importFetchedGroups(response.data.groups));
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
|
@ -143,10 +139,6 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
|
|||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
if (data.groups) {
|
||||
dispatch(importFetchedGroups(data.groups));
|
||||
}
|
||||
|
||||
dispatch(expandSearchSuccess(data, value, type));
|
||||
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { shouldHaveCard } from 'soapbox/utils/status';
|
|||
import api, { getNextLink } from '../api';
|
||||
|
||||
import { setComposeToStatus } from './compose';
|
||||
import { fetchGroupRelationships } from './groups';
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modals';
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
@ -124,6 +125,9 @@ const fetchStatus = (id: string) => {
|
|||
|
||||
return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => {
|
||||
dispatch(importFetchedStatus(status));
|
||||
if (status.group) {
|
||||
dispatch(fetchGroupRelationships([status.group.id]));
|
||||
}
|
||||
dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading });
|
||||
return status;
|
||||
}).catch(error => {
|
||||
|
|
|
@ -0,0 +1,201 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||
const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||
const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||
|
||||
const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||
const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||
const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||
|
||||
const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
|
||||
const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
|
||||
const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
|
||||
|
||||
const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchHashtagRequest());
|
||||
|
||||
api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => {
|
||||
dispatch(fetchHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(fetchHashtagFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchHashtagRequest = () => ({
|
||||
type: HASHTAG_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_FETCH_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const fetchHashtagFail = (error: AxiosError) => ({
|
||||
type: HASHTAG_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(followHashtagRequest(name));
|
||||
|
||||
api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
|
||||
dispatch(followHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(followHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
const followHashtagRequest = (name: string) => ({
|
||||
type: HASHTAG_FOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
const followHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_FOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const followHashtagFail = (name: string, error: AxiosError) => ({
|
||||
type: HASHTAG_FOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(unfollowHashtagRequest(name));
|
||||
|
||||
api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
|
||||
dispatch(unfollowHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(unfollowHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
const unfollowHashtagRequest = (name: string) => ({
|
||||
type: HASHTAG_UNFOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_UNFOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const unfollowHashtagFail = (name: string, error: AxiosError) => ({
|
||||
type: HASHTAG_UNFOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get('/api/v1/followed_tags').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchFollowedHashtagsFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchFollowedHashtagsRequest = () => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtagsFail = (error: AxiosError) => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().followed_tags.next;
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowedHashtagsFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandFollowedHashtagsRequest = () => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
});
|
||||
|
||||
const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandFollowedHashtagsFail = (error: AxiosError) => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
|
||||
export {
|
||||
HASHTAG_FETCH_REQUEST,
|
||||
HASHTAG_FETCH_SUCCESS,
|
||||
HASHTAG_FETCH_FAIL,
|
||||
HASHTAG_FOLLOW_REQUEST,
|
||||
HASHTAG_FOLLOW_SUCCESS,
|
||||
HASHTAG_FOLLOW_FAIL,
|
||||
HASHTAG_UNFOLLOW_REQUEST,
|
||||
HASHTAG_UNFOLLOW_SUCCESS,
|
||||
HASHTAG_UNFOLLOW_FAIL,
|
||||
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
fetchHashtag,
|
||||
fetchHashtagRequest,
|
||||
fetchHashtagSuccess,
|
||||
fetchHashtagFail,
|
||||
followHashtag,
|
||||
followHashtagRequest,
|
||||
followHashtagSuccess,
|
||||
followHashtagFail,
|
||||
unfollowHashtag,
|
||||
unfollowHashtagRequest,
|
||||
unfollowHashtagSuccess,
|
||||
unfollowHashtagFail,
|
||||
fetchFollowedHashtags,
|
||||
fetchFollowedHashtagsRequest,
|
||||
fetchFollowedHashtagsSuccess,
|
||||
fetchFollowedHashtagsFail,
|
||||
expandFollowedHashtags,
|
||||
expandFollowedHashtagsRequest,
|
||||
expandFollowedHashtagsSuccess,
|
||||
expandFollowedHashtagsFail,
|
||||
};
|
|
@ -4,7 +4,7 @@ import { getSettings } from 'soapbox/actions/settings';
|
|||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { shouldFilter } from 'soapbox/utils/timelines';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
import api, { getNextLink, getPrevLink } from '../api';
|
||||
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
|
||||
|
@ -139,7 +139,7 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
|
|||
};
|
||||
|
||||
const replaceHomeTimeline = (
|
||||
accountId: string | null,
|
||||
accountId: string | undefined,
|
||||
{ maxId }: Record<string, any> = {},
|
||||
done?: () => void,
|
||||
) => (dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
|
@ -162,7 +162,12 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
|||
return dispatch(noOpAsync());
|
||||
}
|
||||
|
||||
if (!params.max_id && !params.pinned && (timeline.items || ImmutableOrderedSet()).size > 0) {
|
||||
if (
|
||||
!params.max_id &&
|
||||
!params.pinned &&
|
||||
(timeline.items || ImmutableOrderedSet()).size > 0 &&
|
||||
!path.includes('max_id=')
|
||||
) {
|
||||
params.since_id = timeline.getIn(['items', 0]);
|
||||
}
|
||||
|
||||
|
@ -171,9 +176,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
|||
dispatch(expandTimelineRequest(timelineId, isLoadingMore));
|
||||
|
||||
return api(getState).get(path, { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore));
|
||||
dispatch(expandTimelineSuccess(
|
||||
timelineId,
|
||||
response.data,
|
||||
getNextLink(response),
|
||||
getPrevLink(response),
|
||||
response.status === 206,
|
||||
isLoadingRecent,
|
||||
isLoadingMore,
|
||||
));
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
|
@ -181,9 +193,26 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
|||
});
|
||||
};
|
||||
|
||||
const expandHomeTimeline = ({ accountId, maxId }: Record<string, any> = {}, done = noOp) => {
|
||||
const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home';
|
||||
const params: any = { max_id: maxId };
|
||||
interface ExpandHomeTimelineOpts {
|
||||
accountId?: string
|
||||
maxId?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface HomeTimelineParams {
|
||||
max_id?: string
|
||||
exclude_replies?: boolean
|
||||
with_muted?: boolean
|
||||
}
|
||||
|
||||
const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => {
|
||||
const endpoint = url || (accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home');
|
||||
const params: HomeTimelineParams = {};
|
||||
|
||||
if (!url && maxId) {
|
||||
params.max_id = maxId;
|
||||
}
|
||||
|
||||
if (accountId) {
|
||||
params.exclude_replies = true;
|
||||
params.with_muted = true;
|
||||
|
@ -219,6 +248,9 @@ const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, don
|
|||
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
||||
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
|
||||
|
||||
const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
||||
expandTimeline(`group:tags:${id}:${tagName}`, `/api/v1/timelines/group/${id}/tags/${tagName}`, { max_id: maxId }, done);
|
||||
|
||||
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
|
||||
expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
|
||||
|
||||
|
@ -237,11 +269,20 @@ const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({
|
|||
skipLoading: !isLoadingMore,
|
||||
});
|
||||
|
||||
const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({
|
||||
const expandTimelineSuccess = (
|
||||
timeline: string,
|
||||
statuses: APIEntity[],
|
||||
next: string | undefined,
|
||||
prev: string | undefined,
|
||||
partial: boolean,
|
||||
isLoadingRecent: boolean,
|
||||
isLoadingMore: boolean,
|
||||
) => ({
|
||||
type: TIMELINE_EXPAND_SUCCESS,
|
||||
timeline,
|
||||
statuses,
|
||||
next,
|
||||
prev,
|
||||
partial,
|
||||
isLoadingRecent,
|
||||
skipLoading: !isLoadingMore,
|
||||
|
@ -312,6 +353,7 @@ export {
|
|||
expandAccountMediaTimeline,
|
||||
expandListTimeline,
|
||||
expandGroupTimeline,
|
||||
expandGroupTimelineFromTag,
|
||||
expandGroupMediaTimeline,
|
||||
expandHashtagTimeline,
|
||||
expandTimelineRequest,
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { type Account, accountSchema } from 'soapbox/schemas';
|
||||
|
||||
|
||||
import { useRelationships } from './useRelationships';
|
||||
|
||||
function useAccount(id: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { entity: account, ...result } = useEntity<Account>(
|
||||
[Entities.ACCOUNTS, id],
|
||||
() => api.get(`/api/v1/accounts/${id}`),
|
||||
{ schema: accountSchema },
|
||||
);
|
||||
const { relationships, isLoading } = useRelationships([account?.id as string]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
isLoading: result.isLoading || isLoading,
|
||||
account: account ? { ...account, relationship: relationships[0] || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useAccount };
|
|
@ -0,0 +1,21 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { type Relationship, relationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
function useRelationships(ids: string[]) {
|
||||
const api = useApi();
|
||||
|
||||
const { entities: relationships, ...result } = useEntities<Relationship>(
|
||||
[Entities.RELATIONSHIPS],
|
||||
() => api.get(`/api/v1/accounts/relationships?${ids.map(id => `id[]=${id}`).join('&')}`),
|
||||
{ schema: relationshipSchema, enabled: ids.filter(Boolean).length > 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
relationships,
|
||||
};
|
||||
}
|
||||
|
||||
export { useRelationships };
|
|
@ -0,0 +1,41 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroup } from '../useGroup';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
|
||||
describe('useGroup hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroup(group.id));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.group?.id).toBe(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${group.id}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroup(group.id));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.group).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroupLookup } from '../useGroupLookup';
|
||||
|
||||
const group = buildGroup({ id: '1', slug: 'soapbox' });
|
||||
|
||||
describe('useGroupLookup hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupLookup(group.slug));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entity?.id).toBe(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupLookup(group.slug));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildStatus } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroupMedia } from '../useGroupMedia';
|
||||
|
||||
const status = buildStatus();
|
||||
const groupId = '1';
|
||||
|
||||
describe('useGroupMedia hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupMedia(groupId));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entities.length).toBe(1);
|
||||
expect(result.current.entities[0].id).toBe(status.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupMedia(groupId));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entities.length).toBe(0);
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroupMember } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useGroupMembers } from '../useGroupMembers';
|
||||
|
||||
const groupMember = buildGroupMember();
|
||||
const groupId = '1';
|
||||
|
||||
describe('useGroupMembers hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groupMembers.length).toBe(1);
|
||||
expect(result.current.groupMembers[0].id).toBe(groupMember.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groupMembers.length).toBe(0);
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import { useGroups } from '../useGroups';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
const store = {
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
};
|
||||
|
||||
describe('useGroups hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [group]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(useGroups, undefined, store);
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(useGroups, undefined, store);
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useCreateEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useCancelMembershipRequest(group: Group) {
|
||||
const api = useApi();
|
||||
const me = useOwnAccount();
|
||||
|
||||
const { createEntity, isSubmitting } = useCreateEntity(
|
||||
[Entities.GROUP_RELATIONSHIPS],
|
||||
() => api.post(`/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject`),
|
||||
);
|
||||
|
||||
return {
|
||||
mutate: createEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useCancelMembershipRequest };
|
|
@ -0,0 +1,33 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useCreateEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
interface CreateGroupParams {
|
||||
display_name?: string
|
||||
note?: string
|
||||
avatar?: File
|
||||
header?: File
|
||||
group_visibility?: 'members_only' | 'everyone'
|
||||
discoverable?: boolean
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
function useCreateGroup() {
|
||||
const api = useApi();
|
||||
|
||||
const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS, 'search', ''], (params: CreateGroupParams) => {
|
||||
return api.post('/api/v1/groups', params, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}, { schema: groupSchema });
|
||||
|
||||
return {
|
||||
createGroup: createEntity,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export { useCreateGroup, type CreateGroupParams };
|
|
@ -4,14 +4,14 @@ import { useEntityActions } from 'soapbox/entity-store/hooks';
|
|||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useDeleteGroup() {
|
||||
const { deleteEntity, isLoading } = useEntityActions<Group>(
|
||||
const { deleteEntity, isSubmitting } = useEntityActions<Group>(
|
||||
[Entities.GROUPS],
|
||||
{ delete: '/api/v1/groups/:id' },
|
||||
);
|
||||
|
||||
return {
|
||||
mutate: deleteEntity,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useDeleteEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useDeleteGroupStatus(group: Group, statusId: string) {
|
||||
const api = useApi();
|
||||
const { deleteEntity, isSubmitting } = useDeleteEntity(
|
||||
Entities.STATUSES,
|
||||
() => api.delete(`/api/v1/groups/${group.id}/statuses/${statusId}`),
|
||||
);
|
||||
|
||||
return {
|
||||
mutate: deleteEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useDeleteGroupStatus };
|
|
@ -0,0 +1,24 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { type Group, groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationship } from './useGroupRelationship';
|
||||
|
||||
function useGroup(groupId: string, refetch = true) {
|
||||
const api = useApi();
|
||||
|
||||
const { entity: group, ...result } = useEntity<Group>(
|
||||
[Entities.GROUPS, groupId],
|
||||
() => api.get(`/api/v1/groups/${groupId}`),
|
||||
{ schema: groupSchema, refetch },
|
||||
);
|
||||
const { entity: relationship } = useGroupRelationship(groupId);
|
||||
|
||||
return {
|
||||
...result,
|
||||
group: group ? { ...group, relationship: relationship || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroup };
|
|
@ -0,0 +1,26 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityLookup } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationship } from './useGroupRelationship';
|
||||
|
||||
function useGroupLookup(slug: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { entity: group, ...result } = useEntityLookup(
|
||||
Entities.GROUPS,
|
||||
(group) => group.slug === slug,
|
||||
() => api.get(`/api/v1/groups/lookup?name=${slug}`),
|
||||
{ schema: groupSchema },
|
||||
);
|
||||
|
||||
const { entity: relationship } = useGroupRelationship(group?.id);
|
||||
|
||||
return {
|
||||
...result,
|
||||
entity: group ? { ...group, relationship: relationship || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupLookup };
|
|
@ -0,0 +1,17 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { toSchema } from 'soapbox/utils/normalizers';
|
||||
|
||||
const statusSchema = toSchema(normalizeStatus);
|
||||
|
||||
function useGroupMedia(groupId: string) {
|
||||
const api = useApi();
|
||||
|
||||
return useEntities([Entities.STATUSES, 'groupMedia', groupId], () => {
|
||||
return api.get(`/api/v1/timelines/group/${groupId}?only_media=true`);
|
||||
}, { schema: statusSchema });
|
||||
}
|
||||
|
||||
export { useGroupMedia };
|
|
@ -1,10 +1,11 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useApi } from '../useApi';
|
||||
import { useApi } from '../../../hooks/useApi';
|
||||
|
||||
function useGroupMembers(groupId: string, role: string) {
|
||||
function useGroupMembers(groupId: string, role: GroupRoles) {
|
||||
const api = useApi();
|
||||
|
||||
const { entities, ...result } = useEntities<GroupMember>(
|
|
@ -1,7 +1,10 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities, useIncrementEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useDismissEntity, useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { accountSchema } from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useGroupRelationship } from './useGroupRelationship';
|
||||
|
||||
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
|
||||
|
||||
|
@ -9,19 +12,24 @@ function useGroupMembershipRequests(groupId: string) {
|
|||
const api = useApi();
|
||||
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
|
||||
|
||||
const { entities, invalidate, ...rest } = useEntities(
|
||||
const { entity: relationship } = useGroupRelationship(groupId);
|
||||
|
||||
const { entities, invalidate, fetchEntities, ...rest } = useEntities(
|
||||
path,
|
||||
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
|
||||
{ schema: accountSchema },
|
||||
{
|
||||
schema: accountSchema,
|
||||
enabled: relationship?.role === GroupRoles.OWNER || relationship?.role === GroupRoles.ADMIN,
|
||||
},
|
||||
);
|
||||
|
||||
const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => {
|
||||
const { dismissEntity: authorize } = useDismissEntity(path, async (accountId: string) => {
|
||||
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`);
|
||||
invalidate();
|
||||
return response;
|
||||
});
|
||||
|
||||
const { incrementEntity: reject } = useIncrementEntity(path, -1, async (accountId: string) => {
|
||||
const { dismissEntity: reject } = useDismissEntity(path, async (accountId: string) => {
|
||||
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`);
|
||||
invalidate();
|
||||
return response;
|
||||
|
@ -29,6 +37,7 @@ function useGroupMembershipRequests(groupId: string) {
|
|||
|
||||
return {
|
||||
accounts: entities,
|
||||
refetch: fetchEntities,
|
||||
authorize,
|
||||
reject,
|
||||
...rest,
|
|
@ -0,0 +1,35 @@
|
|||
import { useEffect } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useAppDispatch } from 'soapbox/hooks';
|
||||
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupRelationship(groupId: string | undefined) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, groupId as string],
|
||||
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
|
||||
{
|
||||
enabled: !!groupId,
|
||||
schema: z.array(groupRelationshipSchema).transform(arr => arr[0]),
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupRelationship?.id) {
|
||||
dispatch(fetchGroupRelationshipsSuccess([groupRelationship]));
|
||||
}
|
||||
}, [groupRelationship?.id]);
|
||||
|
||||
return {
|
||||
entity: groupRelationship,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupRelationship };
|
|
@ -0,0 +1,27 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupRelationships(groupIds: string[]) {
|
||||
const api = useApi();
|
||||
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
||||
|
||||
const { entities, ...result } = useEntities<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
|
||||
() => api.get(`/api/v1/groups/relationships?${q}`),
|
||||
{ schema: groupRelationshipSchema, enabled: groupIds.length > 0 },
|
||||
);
|
||||
|
||||
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
|
||||
map[relationship.id] = relationship;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...result,
|
||||
relationships,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupRelationships };
|
|
@ -1,11 +1,9 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useApi } from '../../useApi';
|
||||
import { useFeatures } from '../../useFeatures';
|
||||
|
||||
import { useGroupRelationships } from './useGroups';
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupTag(tagId: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { entity: tag, ...result } = useEntity<GroupTag>(
|
||||
[Entities.GROUP_TAGS, tagId],
|
||||
() => api.get(`/api/v1/tags/${tagId }`),
|
||||
{ schema: groupTagSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupTag };
|
|
@ -0,0 +1,23 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupTagSchema } from 'soapbox/schemas';
|
||||
|
||||
import type { GroupTag } from 'soapbox/schemas';
|
||||
|
||||
function useGroupTags(groupId: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { entities, ...result } = useEntities<GroupTag>(
|
||||
[Entities.GROUP_TAGS, groupId],
|
||||
() => api.get(`/api/v1/truth/trends/groups/${groupId}/tags`),
|
||||
{ schema: groupTagSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
tags: entities,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupTags };
|
|
@ -0,0 +1,31 @@
|
|||
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 { groupSchema, type Group } from 'soapbox/schemas/group';
|
||||
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
function useGroups(q: string = '') {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, 'search', q],
|
||||
() => api.get('/api/v1/groups', { params: { q } }),
|
||||
{ enabled: features.groups, schema: groupSchema },
|
||||
);
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroups };
|
|
@ -0,0 +1,35 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useGroupsFromTag(tagId: string) {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, 'tags', tagId],
|
||||
() => api.get(`/api/v1/tags/${tagId}/groups`),
|
||||
{
|
||||
schema: groupSchema,
|
||||
enabled: features.groupsDiscovery,
|
||||
},
|
||||
);
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupsFromTag };
|
|
@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas';
|
|||
function useJoinGroup(group: Group) {
|
||||
const { invalidate } = useGroups();
|
||||
|
||||
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
|
||||
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, group.id],
|
||||
{ post: `/api/v1/groups/${group.id}/join` },
|
||||
{ schema: groupRelationshipSchema },
|
||||
|
@ -17,7 +17,7 @@ function useJoinGroup(group: Group) {
|
|||
|
||||
return {
|
||||
mutate: createEntity,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
invalidate,
|
||||
};
|
||||
}
|
|
@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas';
|
|||
function useLeaveGroup(group: Group) {
|
||||
const { invalidate } = useGroups();
|
||||
|
||||
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
|
||||
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, group.id],
|
||||
{ post: `/api/v1/groups/${group.id}/leave` },
|
||||
{ schema: groupRelationshipSchema },
|
||||
|
@ -17,7 +17,7 @@ function useLeaveGroup(group: Group) {
|
|||
|
||||
return {
|
||||
mutate: createEntity,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
invalidate,
|
||||
};
|
||||
}
|
|
@ -2,9 +2,10 @@ import { Entities } from 'soapbox/entity-store/entities';
|
|||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { Group, groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationships } from '../api/groups/useGroups';
|
||||
import { useApi } from '../useApi';
|
||||
import { useFeatures } from '../useFeatures';
|
||||
import { useApi } from '../../../hooks/useApi';
|
||||
import { useFeatures } from '../../../hooks/useFeatures';
|
||||
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
function usePopularGroups() {
|
||||
const api = useApi();
|
|
@ -0,0 +1,25 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
|
||||
|
||||
function usePopularTags() {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<GroupTag>(
|
||||
[Entities.GROUP_TAGS],
|
||||
() => api.get('/api/v1/groups/tags'),
|
||||
{
|
||||
schema: groupTagSchema,
|
||||
enabled: features.groupsDiscovery,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
tags: entities,
|
||||
};
|
||||
}
|
||||
|
||||
export { usePopularTags };
|
|
@ -1,10 +1,9 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { Group, groupSchema } from 'soapbox/schemas';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { type Group, groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationships } from '../api/groups/useGroups';
|
||||
import { useApi } from '../useApi';
|
||||
import { useFeatures } from '../useFeatures';
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
function useSuggestedGroups() {
|
||||
const api = useApi();
|
|
@ -0,0 +1,18 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
|
||||
import type { GroupTag } from 'soapbox/schemas';
|
||||
|
||||
function useUpdateGroupTag(groupId: string, tagId: string) {
|
||||
const { updateEntity, ...rest } = useEntityActions<GroupTag>(
|
||||
[Entities.GROUP_TAGS, groupId, tagId],
|
||||
{ patch: `/api/v1/groups/${groupId}/tags/${tagId}` },
|
||||
);
|
||||
|
||||
return {
|
||||
updateGroupTag: updateEntity,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export { useUpdateGroupTag };
|
|
@ -0,0 +1,40 @@
|
|||
|
||||
/**
|
||||
* Accounts
|
||||
*/
|
||||
export { useAccount } from './accounts/useAccount';
|
||||
|
||||
/**
|
||||
* Groups
|
||||
*/
|
||||
export { useBlockGroupMember } from './groups/useBlockGroupMember';
|
||||
export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest';
|
||||
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
|
||||
export { useDeleteGroup } from './groups/useDeleteGroup';
|
||||
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
|
||||
export { useGroup } from './groups/useGroup';
|
||||
export { useGroupLookup } from './groups/useGroupLookup';
|
||||
export { useGroupMedia } from './groups/useGroupMedia';
|
||||
export { useGroupMembers } from './groups/useGroupMembers';
|
||||
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
|
||||
export { useGroupRelationship } from './groups/useGroupRelationship';
|
||||
export { useGroupRelationships } from './groups/useGroupRelationships';
|
||||
export { useGroupSearch } from './groups/useGroupSearch';
|
||||
export { useGroupTag } from './groups/useGroupTag';
|
||||
export { useGroupTags } from './groups/useGroupTags';
|
||||
export { useGroupValidation } from './groups/useGroupValidation';
|
||||
export { useGroups } from './groups/useGroups';
|
||||
export { useGroupsFromTag } from './groups/useGroupsFromTag';
|
||||
export { useJoinGroup } from './groups/useJoinGroup';
|
||||
export { useLeaveGroup } from './groups/useLeaveGroup';
|
||||
export { usePopularGroups } from './groups/usePopularGroups';
|
||||
export { usePopularTags } from './groups/usePopularTags';
|
||||
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
|
||||
export { useSuggestedGroups } from './groups/useSuggestedGroups';
|
||||
export { useUpdateGroup } from './groups/useUpdateGroup';
|
||||
export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
|
||||
|
||||
/**
|
||||
* Relationships
|
||||
*/
|
||||
export { useRelationships } from './accounts/useRelationships';
|
|
@ -14,6 +14,23 @@ interface IAuthorizeRejectButtons {
|
|||
const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize, onReject, countdown }) => {
|
||||
const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending');
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
const interval = useRef<ReturnType<typeof setInterval>>();
|
||||
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
|
||||
const startProgressInterval = () => {
|
||||
let startValue = 1;
|
||||
interval.current = setInterval(() => {
|
||||
startValue++;
|
||||
const newValue = startValue * 3.6; // get to 360 (deg)
|
||||
setProgress(newValue);
|
||||
|
||||
if (newValue >= 360) {
|
||||
clearInterval(interval.current as NodeJS.Timeout);
|
||||
setProgress(0);
|
||||
}
|
||||
}, (countdown as number) / 100);
|
||||
};
|
||||
|
||||
function handleAction(
|
||||
present: 'authorizing' | 'rejecting',
|
||||
|
@ -21,6 +38,9 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
|||
action: () => Promise<unknown> | unknown,
|
||||
): void {
|
||||
if (state === present) {
|
||||
if (interval.current) {
|
||||
clearInterval(interval.current);
|
||||
}
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
@ -31,12 +51,13 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
|||
await action();
|
||||
setState(past);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e) console.error(e);
|
||||
}
|
||||
};
|
||||
if (typeof countdown === 'number') {
|
||||
setState(present);
|
||||
timeout.current = setTimeout(doAction, countdown);
|
||||
startProgressInterval();
|
||||
} else {
|
||||
doAction();
|
||||
}
|
||||
|
@ -46,11 +67,28 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
|||
const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize);
|
||||
const handleReject = async () => handleAction('rejecting', 'rejected', onReject);
|
||||
|
||||
const renderStyle = (selectedState: typeof state) => {
|
||||
if (state === 'authorizing' && selectedState === 'authorizing') {
|
||||
return {
|
||||
background: `conic-gradient(rgb(var(--color-primary-500)) ${progress}deg, rgb(var(--color-primary-500) / 0.1) 0deg)`,
|
||||
};
|
||||
} else if (state === 'rejecting' && selectedState === 'rejecting') {
|
||||
return {
|
||||
background: `conic-gradient(rgb(var(--color-danger-600)) ${progress}deg, rgb(var(--color-danger-600) / 0.1) 0deg)`,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
if (interval.current) {
|
||||
clearInterval(interval.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -72,6 +110,7 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
|||
action={handleReject}
|
||||
isLoading={state === 'rejecting'}
|
||||
disabled={state === 'authorizing'}
|
||||
style={renderStyle('rejecting')}
|
||||
/>
|
||||
<AuthorizeRejectButton
|
||||
theme='primary'
|
||||
|
@ -79,6 +118,7 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
|||
action={handleAuthorize}
|
||||
isLoading={state === 'authorizing'}
|
||||
disabled={state === 'rejecting'}
|
||||
style={renderStyle('authorizing')}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
|
@ -105,33 +145,34 @@ interface IAuthorizeRejectButton {
|
|||
action(): void
|
||||
isLoading?: boolean
|
||||
disabled?: boolean
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
const AuthorizeRejectButton: React.FC<IAuthorizeRejectButton> = ({ theme, icon, action, isLoading, disabled }) => {
|
||||
const AuthorizeRejectButton: React.FC<IAuthorizeRejectButton> = ({ theme, icon, action, isLoading, style, disabled }) => {
|
||||
return (
|
||||
<div className='relative'>
|
||||
<IconButton
|
||||
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
|
||||
onClick={action}
|
||||
theme='seamless'
|
||||
className={clsx('h-10 w-10 items-center justify-center border-2', {
|
||||
'border-primary-500/10 hover:border-primary-500': theme === 'primary',
|
||||
'border-danger-600/10 hover:border-danger-600': theme === 'danger',
|
||||
})}
|
||||
iconClassName={clsx('h-6 w-6', {
|
||||
'text-primary-500': theme === 'primary',
|
||||
'text-danger-600': theme === 'danger',
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{(isLoading) && (
|
||||
<div
|
||||
className={clsx('pointer-events-none absolute inset-0 h-10 w-10 animate-spin rounded-full border-2 border-transparent', {
|
||||
'border-t-primary-500': theme === 'primary',
|
||||
'border-t-danger-600': theme === 'danger',
|
||||
<div
|
||||
style={style}
|
||||
className={
|
||||
clsx({
|
||||
'flex h-11 w-11 items-center justify-center rounded-full': true,
|
||||
'bg-danger-600/10': theme === 'danger',
|
||||
'bg-primary-500/10': theme === 'primary',
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
|
||||
onClick={action}
|
||||
theme='seamless'
|
||||
className='h-10 w-10 items-center justify-center bg-white focus:!ring-0 dark:!bg-gray-900'
|
||||
iconClassName={clsx('h-6 w-6', {
|
||||
'text-primary-500': theme === 'primary',
|
||||
'text-danger-600': theme === 'danger',
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -58,6 +58,7 @@ const BirthdayPanel = ({ limit }: IBirthdayPanel) => {
|
|||
key={accountId}
|
||||
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
|
||||
id={accountId}
|
||||
withRelationship={false}
|
||||
/>
|
||||
))}
|
||||
</Widget>
|
||||
|
|
|
@ -94,7 +94,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
|
|||
>
|
||||
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
|
||||
|
||||
<span className='truncate'>{item.text}</span>
|
||||
<span className='truncate font-medium'>{item.text}</span>
|
||||
|
||||
{item.count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import GroupHeaderImage from 'soapbox/features/group/components/group-header-image';
|
||||
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
|
||||
import GroupRelationship from 'soapbox/features/group/components/group-relationship';
|
||||
|
@ -10,17 +10,11 @@ import { HStack, Stack, Text } from './ui';
|
|||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
});
|
||||
|
||||
interface IGroupCard {
|
||||
group: GroupEntity
|
||||
}
|
||||
|
||||
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'
|
||||
|
@ -28,12 +22,10 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
|||
>
|
||||
{/* Group Cover Image */}
|
||||
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||
{group.header && (
|
||||
<img
|
||||
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
||||
src={group.header} alt={intl.formatMessage(messages.groupHeader)}
|
||||
/>
|
||||
)}
|
||||
<GroupHeaderImage
|
||||
group={group}
|
||||
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Group Avatar */}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, matchPath, useHistory } from 'react-router-dom';
|
||||
|
||||
import { Button, Divider, HStack, Popover, Stack, Text } from 'soapbox/components/ui';
|
||||
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||
|
@ -26,6 +26,13 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
|
|||
const { children, group, isEnabled } = props;
|
||||
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const path = history.location.pathname;
|
||||
const shouldHideAction = matchPath(path, {
|
||||
path: ['/group/:groupSlug'],
|
||||
exact: true,
|
||||
});
|
||||
|
||||
if (!isEnabled) {
|
||||
return children;
|
||||
|
@ -36,7 +43,7 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
|
|||
interaction='click'
|
||||
referenceElementClassName='cursor-pointer'
|
||||
content={
|
||||
<Stack space={4} className='w-80'>
|
||||
<Stack space={4} className='w-80 pb-4'>
|
||||
<Stack
|
||||
className='relative h-60 rounded-lg bg-white dark:border-primary-800 dark:bg-primary-900'
|
||||
data-testid='group-card'
|
||||
|
@ -79,13 +86,15 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
|
|||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className='px-4 pb-4'>
|
||||
<Link to={`/groups/${group.id}`}>
|
||||
<Button type='button' theme='secondary' block>
|
||||
{intl.formatMessage(messages.action)}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{!shouldHideAction && (
|
||||
<div className='px-4'>
|
||||
<Link to={`/group/${group.slug}`}>
|
||||
<Button type='button' theme='secondary' block>
|
||||
{intl.formatMessage(messages.action)}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
isFlush
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
|
||||
import { useGroupLookup } from 'soapbox/api/hooks';
|
||||
import ColumnLoading from 'soapbox/features/ui/components/column-loading';
|
||||
|
||||
import { Layout } from '../ui';
|
||||
|
||||
interface IGroupLookup {
|
||||
params: {
|
||||
groupSlug: string
|
||||
}
|
||||
}
|
||||
|
||||
interface IMaybeGroupLookup {
|
||||
params?: {
|
||||
groupSlug?: string
|
||||
groupId?: string
|
||||
}
|
||||
}
|
||||
|
||||
function GroupLookupHoc(Component: React.ComponentType<{ params: { groupId: string } }>) {
|
||||
const GroupLookup: React.FC<IGroupLookup> = (props) => {
|
||||
const { entity: group } = useGroupLookup(props.params.groupSlug);
|
||||
|
||||
if (!group) return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<ColumnLoading />
|
||||
</Layout.Main>
|
||||
|
||||
<Layout.Aside />
|
||||
</>
|
||||
);
|
||||
|
||||
const newProps = {
|
||||
...props,
|
||||
params: {
|
||||
...props.params,
|
||||
id: group.id,
|
||||
groupId: group.id,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Component {...newProps} />
|
||||
);
|
||||
};
|
||||
|
||||
const MaybeGroupLookup: React.FC<IMaybeGroupLookup> = (props) => {
|
||||
const { params } = props;
|
||||
|
||||
if (params?.groupId) {
|
||||
return <Component {...props} params={{ ...params, groupId: params.groupId }} />;
|
||||
} else {
|
||||
return <GroupLookup {...props} params={{ ...params, groupSlug: params?.groupSlug || '' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
return MaybeGroupLookup;
|
||||
}
|
||||
|
||||
export default GroupLookupHoc;
|
|
@ -0,0 +1,11 @@
|
|||
type HOC<P, R> = (Component: React.ComponentType<P>) => React.ComponentType<R>
|
||||
type AsyncComponent<P> = () => Promise<{ default: React.ComponentType<P> }>
|
||||
|
||||
const withHoc = <P, R>(asyncComponent: AsyncComponent<P>, hoc: HOC<P, R>) => {
|
||||
return async () => {
|
||||
const { default: component } = await asyncComponent();
|
||||
return { default: hoc(component) };
|
||||
};
|
||||
};
|
||||
|
||||
export default withHoc;
|
|
@ -56,14 +56,13 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
|
|||
|
||||
return (
|
||||
<Comp
|
||||
className={clsx({
|
||||
'flex items-center justify-between px-4 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 dark:from-gradient-start/10 dark:to-gradient-end/10': true,
|
||||
className={clsx('flex items-center justify-between overflow-hidden bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 px-4 py-2 first:rounded-t-lg last:rounded-b-lg dark:from-gradient-start/10 dark:to-gradient-end/10', {
|
||||
'cursor-pointer hover:from-gradient-start/30 hover:to-gradient-end/30 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
|
||||
})}
|
||||
{...linkProps}
|
||||
>
|
||||
<div className='flex flex-col py-1.5 pr-4 rtl:pl-4 rtl:pr-0'>
|
||||
<LabelComp className='font-medium text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp>
|
||||
<LabelComp className='text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp>
|
||||
|
||||
{hint ? (
|
||||
<span className='text-sm text-gray-700 dark:text-gray-600'>{hint}</span>
|
||||
|
@ -71,7 +70,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
|
|||
</div>
|
||||
|
||||
{onClick ? (
|
||||
<HStack space={1} alignItems='center' className='text-gray-700 dark:text-gray-600'>
|
||||
<HStack space={1} alignItems='center' className='overflow-hidden text-gray-700 dark:text-gray-600'>
|
||||
{children}
|
||||
|
||||
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1 rtl:rotate-180' />
|
||||
|
|
|
@ -181,7 +181,9 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
};
|
||||
|
||||
const getSiblings = () => {
|
||||
return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])).filter(node => node !== ref.current);
|
||||
return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[]))
|
||||
.filter(node => (node as HTMLDivElement).id !== 'toaster')
|
||||
.filter(node => node !== ref.current);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -4,14 +4,22 @@ import { IntlProvider } from 'react-intl';
|
|||
import { Provider } from 'react-redux';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
||||
import { mockStore, render, screen, rootState } from 'soapbox/jest/test-helpers';
|
||||
import { type Poll } from 'soapbox/schemas';
|
||||
|
||||
import { mockStore, render, screen, rootState } from '../../../jest/test-helpers';
|
||||
import PollFooter from '../poll-footer';
|
||||
|
||||
let poll = normalizePoll({
|
||||
id: 1,
|
||||
options: [{ title: 'Apples', votes_count: 0 }],
|
||||
let poll: Poll = {
|
||||
id: '1',
|
||||
options: [{
|
||||
title: 'Apples',
|
||||
votes_count: 0,
|
||||
title_emojified: 'Apples',
|
||||
}, {
|
||||
title: 'Oranges',
|
||||
votes_count: 0,
|
||||
title_emojified: 'Oranges',
|
||||
}],
|
||||
emojis: [],
|
||||
expired: false,
|
||||
expires_at: '2020-03-24T19:33:06.000Z',
|
||||
|
@ -20,7 +28,7 @@ let poll = normalizePoll({
|
|||
votes_count: 0,
|
||||
own_votes: null,
|
||||
voted: false,
|
||||
});
|
||||
};
|
||||
|
||||
describe('<PollFooter />', () => {
|
||||
describe('with "showResults" enabled', () => {
|
||||
|
@ -62,10 +70,10 @@ describe('<PollFooter />', () => {
|
|||
|
||||
describe('when the Poll has not expired', () => {
|
||||
beforeEach(() => {
|
||||
poll = normalizePoll({
|
||||
...poll.toJS(),
|
||||
poll = {
|
||||
...poll,
|
||||
expired: false,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
it('renders time remaining', () => {
|
||||
|
@ -77,10 +85,10 @@ describe('<PollFooter />', () => {
|
|||
|
||||
describe('when the Poll has expired', () => {
|
||||
beforeEach(() => {
|
||||
poll = normalizePoll({
|
||||
...poll.toJS(),
|
||||
poll = {
|
||||
...poll,
|
||||
expired: true,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
it('renders closed', () => {
|
||||
|
@ -100,10 +108,10 @@ describe('<PollFooter />', () => {
|
|||
|
||||
describe('when the Poll is multiple', () => {
|
||||
beforeEach(() => {
|
||||
poll = normalizePoll({
|
||||
...poll.toJS(),
|
||||
poll = {
|
||||
...poll,
|
||||
multiple: true,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
it('renders the Vote button', () => {
|
||||
|
@ -115,10 +123,10 @@ describe('<PollFooter />', () => {
|
|||
|
||||
describe('when the Poll is not multiple', () => {
|
||||
beforeEach(() => {
|
||||
poll = normalizePoll({
|
||||
...poll.toJS(),
|
||||
poll = {
|
||||
...poll,
|
||||
multiple: false,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
it('does not render the Vote button', () => {
|
||||
|
|
|
@ -40,21 +40,21 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
|
|||
let votesCount = null;
|
||||
|
||||
if (poll.voters_count !== null && poll.voters_count !== undefined) {
|
||||
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
|
||||
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.voters_count }} />;
|
||||
} else {
|
||||
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
|
||||
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.votes_count }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={4} data-testid='poll-footer'>
|
||||
{(!showResults && poll?.multiple) && (
|
||||
{(!showResults && poll.multiple) && (
|
||||
<Button onClick={handleVote} theme='primary' block>
|
||||
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<HStack space={1.5} alignItems='center' wrap>
|
||||
{poll.pleroma.get('non_anonymous') && (
|
||||
{poll.pleroma?.non_anonymous && (
|
||||
<>
|
||||
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
|
||||
<Text theme='muted' weight='medium'>
|
||||
|
|
|
@ -112,10 +112,13 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
|
|||
|
||||
const pollVotesCount = poll.voters_count || poll.votes_count;
|
||||
const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100;
|
||||
const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count);
|
||||
const voted = poll.own_votes?.includes(index);
|
||||
const message = intl.formatMessage(messages.votes, { votes: option.votes_count });
|
||||
|
||||
const leading = poll.options
|
||||
.filter(other => other.title !== option.title)
|
||||
.every(other => option.votes_count >= other.votes_count);
|
||||
|
||||
return (
|
||||
<div key={option.title}>
|
||||
{showResults ? (
|
||||
|
|
|
@ -106,7 +106,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
|||
onMouseEnter={handleMouseEnter(dispatch)}
|
||||
onMouseLeave={handleMouseLeave(dispatch)}
|
||||
>
|
||||
<Card variant='rounded' className='relative isolate'>
|
||||
<Card variant='rounded' className='relative isolate overflow-hidden'>
|
||||
<CardBody>
|
||||
<Stack space={2}>
|
||||
<BundleContainer fetchComponent={UserPanel}>
|
||||
|
|
|
@ -7,18 +7,20 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
|||
import { launchChat } from 'soapbox/actions/chats';
|
||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { editEvent } from 'soapbox/actions/events';
|
||||
import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups';
|
||||
import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
|
||||
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
|
||||
import { deleteFromTimelines } from 'soapbox/actions/timelines';
|
||||
import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import StatusActionButton from 'soapbox/components/status-action-button';
|
||||
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
import toast from 'soapbox/toast';
|
||||
import { isLocal, isRemote } from 'soapbox/utils/accounts';
|
||||
import copy from 'soapbox/utils/copy';
|
||||
|
@ -87,16 +89,7 @@ const messages = defineMessages({
|
|||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||
replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' },
|
||||
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
|
||||
groupModKick: { id: 'status.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModBlock: { id: 'status.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||
deleteFromGroupHeading: { id: 'confirmations.delete_from_group.heading', defaultMessage: 'Delete from group' },
|
||||
deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' },
|
||||
kickFromGroupHeading: { id: 'confirmations.kick_from_group.heading', defaultMessage: 'Kick group member' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kickFromGroupConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Block group member' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||
blockFromGroupConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
});
|
||||
|
||||
interface IStatusActionBar {
|
||||
|
@ -121,6 +114,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const deleteGroupStatus = useDeleteGroupStatus(status?.group as Group, status.id);
|
||||
|
||||
const { allowedEmoji } = soapboxConfig;
|
||||
|
||||
|
@ -258,8 +252,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.get('acct') }} />,
|
||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
|
||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(blockAccount(account.id)),
|
||||
secondary: intl.formatMessage(messages.blockAndReport),
|
||||
|
@ -313,31 +307,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }),
|
||||
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: <strong className='break-words'>{account.username}</strong> }),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKickFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.kickFromGroupHeading),
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickFromGroupConfirm),
|
||||
onConfirm: () => dispatch(groupKick((status.group as Group).id, account.id)),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||
const account = status.account as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.blockFromGroupHeading),
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockFromGroupConfirm),
|
||||
onConfirm: () => dispatch(groupBlock((status.group as Group).id, account.id)),
|
||||
onConfirm: () => {
|
||||
deleteGroupStatus.mutate(status.id, {
|
||||
onSuccess() {
|
||||
dispatch(deleteFromTimelines(status.id));
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
|
@ -362,7 +340,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
menu.push({
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: handleCopy,
|
||||
icon: require('@tabler/icons/link.svg'),
|
||||
icon: require('@tabler/icons/clipboard-copy.svg'),
|
||||
});
|
||||
|
||||
if (features.embeds && isLocal(account)) {
|
||||
|
@ -466,7 +444,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
menu.push({
|
||||
text: intl.formatMessage(messages.mute, { name: username }),
|
||||
action: handleMuteClick,
|
||||
icon: require('@tabler/icons/circle-x.svg'),
|
||||
icon: require('@tabler/icons/volume-3.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.block, { name: username }),
|
||||
|
@ -480,23 +458,17 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
});
|
||||
}
|
||||
|
||||
if (status.group && groupRelationship?.role && ['admin', 'moderator'].includes(groupRelationship.role)) {
|
||||
if (status.group &&
|
||||
groupRelationship?.role &&
|
||||
[GroupRoles.OWNER].includes(groupRelationship.role) &&
|
||||
!ownAccount
|
||||
) {
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModDelete),
|
||||
action: handleDeleteFromGroup,
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
});
|
||||
// TODO: figure out when an account is not in the group anymore
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.get('username') }),
|
||||
action: handleKickFromGroup,
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.get('username') }),
|
||||
action: handleBlockFromGroup,
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -536,7 +508,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
return menu;
|
||||
};
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.visibility);
|
||||
const publicStatus = ['public', 'unlisted', 'group'].includes(status.visibility);
|
||||
|
||||
const replyCount = status.replies_count;
|
||||
const reblogCount = status.reblogs_count;
|
||||
|
@ -609,7 +581,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
const canShare = ('share' in navigator) && status.visibility === 'public';
|
||||
const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group');
|
||||
|
||||
return (
|
||||
<HStack data-testid='status-action-bar'>
|
||||
|
|
|
@ -53,7 +53,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
|||
src={icon}
|
||||
className={clsx(
|
||||
{
|
||||
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
|
||||
'fill-accent-300 text-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
|
||||
},
|
||||
iconClassName,
|
||||
)}
|
||||
|
|
|
@ -139,6 +139,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
onMoveDown={handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showGroup={showGroup}
|
||||
variant={divideType === 'border' ? 'slim' : 'rounded'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -172,6 +173,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
onMoveDown={handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showGroup={showGroup}
|
||||
variant={divideType === 'border' ? 'slim' : 'default'}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
@ -245,7 +247,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
isLoading={isLoading}
|
||||
showLoading={isLoading && statusIds.size === 0}
|
||||
onLoadMore={handleLoadOlder}
|
||||
placeholderComponent={PlaceholderStatus}
|
||||
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
|
||||
placeholderCount={20}
|
||||
ref={node}
|
||||
className={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
|
||||
|
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||
import { GroupLinkPreview } from 'soapbox/features/groups/components/group-link-preview';
|
||||
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
|
||||
import Card from 'soapbox/features/status/components/card';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
|
@ -153,6 +154,10 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
|||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (status.spoiler_text.length === 0 && !status.quote && status.card?.group) {
|
||||
media = (
|
||||
<GroupLinkPreview card={status.card} />
|
||||
);
|
||||
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
||||
media = (
|
||||
<Card
|
||||
|
|
|
@ -54,7 +54,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
|
|||
<Link
|
||||
key={account.id}
|
||||
to={`/@${account.acct}`}
|
||||
className='reply-mentions__account'
|
||||
className='reply-mentions__account max-w-[200px] truncate align-bottom'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{isPubkey(account.username) ? account.username.slice(0, 8) : account.username}
|
||||
|
|
|
@ -2,13 +2,12 @@ import clsx from 'clsx';
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import TranslateButton from 'soapbox/components/translate-button';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
|
||||
|
@ -22,7 +21,8 @@ import StatusMedia from './status-media';
|
|||
import StatusReplyMentions from './status-reply-mentions';
|
||||
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
|
||||
import StatusInfo from './statuses/status-info';
|
||||
import { Card, Stack, Text } from './ui';
|
||||
import Tombstone from './tombstone';
|
||||
import { Card, Icon, Stack, Text } from './ui';
|
||||
|
||||
import type {
|
||||
Account as AccountEntity,
|
||||
|
@ -51,7 +51,7 @@ export interface IStatus {
|
|||
featured?: boolean
|
||||
hideActionBar?: boolean
|
||||
hoverable?: boolean
|
||||
variant?: 'default' | 'rounded'
|
||||
variant?: 'default' | 'rounded' | 'slim'
|
||||
showGroup?: boolean
|
||||
accountAction?: React.ReactElement
|
||||
}
|
||||
|
@ -212,26 +212,67 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
};
|
||||
|
||||
const renderStatusInfo = () => {
|
||||
if (isReblog) {
|
||||
if (isReblog && showGroup && group) {
|
||||
return (
|
||||
<StatusInfo
|
||||
avatarSize={avatarSize}
|
||||
to={`/@${status.getIn(['account', 'acct'])}`}
|
||||
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />}
|
||||
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='h-4 w-4 text-green-600' />}
|
||||
text={
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by_with_group'
|
||||
defaultMessage='{name} reposted from {group}'
|
||||
values={{
|
||||
name: (
|
||||
<Link
|
||||
to={`/@${status.getIn(['account', 'acct'])}`}
|
||||
className='hover:underline'
|
||||
>
|
||||
<bdi className='truncate'>
|
||||
<strong
|
||||
className='text-gray-800 dark:text-gray-200'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: String(status.getIn(['account', 'display_name_html'])),
|
||||
}}
|
||||
/>
|
||||
</bdi>
|
||||
</Link>
|
||||
),
|
||||
group: (
|
||||
<Link to={`/group/${(status.group as GroupEntity).slug}`} className='hover:underline'>
|
||||
<strong
|
||||
className='text-gray-800 dark:text-gray-200'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: (status.group as GroupEntity).display_name_html,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (isReblog) {
|
||||
return (
|
||||
<StatusInfo
|
||||
avatarSize={avatarSize}
|
||||
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='h-4 w-4 text-green-600' />}
|
||||
text={
|
||||
<FormattedMessage
|
||||
id='status.reblogged_by'
|
||||
defaultMessage='{name} reposted'
|
||||
values={{
|
||||
name: (
|
||||
<bdi className='truncate pr-1 rtl:pl-1'>
|
||||
<strong
|
||||
className='text-gray-800 dark:text-gray-200'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: String(status.getIn(['account', 'display_name_html'])),
|
||||
}}
|
||||
/>
|
||||
</bdi>
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}`} className='hover:underline'>
|
||||
<bdi className='truncate'>
|
||||
<strong
|
||||
className='text-gray-800 dark:text-gray-200'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: String(status.getIn(['account', 'display_name_html'])),
|
||||
}}
|
||||
/>
|
||||
</bdi>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
@ -242,11 +283,9 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
return (
|
||||
<StatusInfo
|
||||
avatarSize={avatarSize}
|
||||
icon={<Icon src={require('@tabler/icons/pinned.svg')} className='text-gray-600 dark:text-gray-400' />}
|
||||
icon={<Icon src={require('@tabler/icons/pinned.svg')} className='h-4 w-4 text-gray-600 dark:text-gray-400' />}
|
||||
text={
|
||||
<Text size='xs' theme='muted' weight='medium'>
|
||||
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
||||
</Text>
|
||||
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
@ -254,18 +293,23 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
return (
|
||||
<StatusInfo
|
||||
avatarSize={avatarSize}
|
||||
to={`/groups/${group.id}`}
|
||||
icon={<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />}
|
||||
icon={<Icon src={require('@tabler/icons/circles.svg')} className='h-4 w-4 text-primary-600 dark:text-accent-blue' />}
|
||||
text={
|
||||
<Text size='xs' theme='muted' weight='medium'>
|
||||
<FormattedMessage
|
||||
id='status.group'
|
||||
defaultMessage='Posted in {group}'
|
||||
values={{ group: (
|
||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
) }}
|
||||
/>
|
||||
</Text>
|
||||
<FormattedMessage
|
||||
id='status.group'
|
||||
defaultMessage='Posted in {group}'
|
||||
values={{
|
||||
group: (
|
||||
<Link to={`/group/${group.slug}`} className='hover:underline'>
|
||||
<bdi className='truncate'>
|
||||
<strong className='text-gray-800 dark:text-gray-200'>
|
||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
</strong>
|
||||
</bdi>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
@ -345,6 +389,17 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
|
||||
const isUnderReview = actualStatus.visibility === 'self';
|
||||
const isSensitive = actualStatus.hidden;
|
||||
const isSoftDeleted = status.tombstone?.reason === 'deleted';
|
||||
|
||||
if (isSoftDeleted) {
|
||||
return (
|
||||
<Tombstone
|
||||
id={status.id}
|
||||
onMoveUp={(id) => onMoveUp ? onMoveUp(id) : null}
|
||||
onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} data-testid='status'>
|
||||
|
|
|
@ -1,37 +1,44 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { HStack, Text } from '../ui';
|
||||
|
||||
interface IStatusInfo {
|
||||
avatarSize: number
|
||||
to?: string
|
||||
icon: React.ReactNode
|
||||
text: React.ReactNode
|
||||
}
|
||||
|
||||
const StatusInfo = (props: IStatusInfo) => {
|
||||
const { avatarSize, to, icon, text } = props;
|
||||
const { avatarSize, icon, text } = props;
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||
const onClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const Container = to ? Link : 'div';
|
||||
const containerProps: any = to ? { onClick, to } : {};
|
||||
|
||||
return (
|
||||
<Container
|
||||
{...containerProps}
|
||||
className='flex items-center space-x-3 text-xs font-medium text-gray-700 hover:underline rtl:space-x-reverse dark:text-gray-600'
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/aria-role
|
||||
role='status-info'
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className='flex justify-end'
|
||||
style={{ width: avatarSize }}
|
||||
<HStack
|
||||
space={3}
|
||||
alignItems='center'
|
||||
className='cursor-default text-xs font-medium text-gray-700 rtl:space-x-reverse dark:text-gray-600'
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div
|
||||
className='flex justify-end'
|
||||
style={{ width: avatarSize }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{text}
|
||||
</Container>
|
||||
<Text size='xs' theme='muted' weight='medium'>
|
||||
{text}
|
||||
</Text>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import React, { useRef } from 'react';
|
|||
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
|
||||
interface IStillImage {
|
||||
export interface IStillImage {
|
||||
/** Image alt text. */
|
||||
alt?: string
|
||||
/** Extra class names for the outer <div> container. */
|
||||
|
@ -16,10 +16,12 @@ interface IStillImage {
|
|||
letterboxed?: boolean
|
||||
/** Whether to show the file extension in the corner. */
|
||||
showExt?: boolean
|
||||
/** Callback function if the image fails to load */
|
||||
onError?(): void
|
||||
}
|
||||
|
||||
/** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
|
||||
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterboxed = false, showExt = false }) => {
|
||||
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterboxed = false, showExt = false, onError }) => {
|
||||
const settings = useSettings();
|
||||
const autoPlayGif = settings.get('autoPlayGif');
|
||||
|
||||
|
@ -55,6 +57,7 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterb
|
|||
alt={alt}
|
||||
ref={img}
|
||||
onLoad={handleImageLoad}
|
||||
onError={onError}
|
||||
className={clsx(baseClassName, {
|
||||
'invisible group-hover:visible': hoverToPlay,
|
||||
})}
|
||||
|
|
|
@ -19,10 +19,17 @@ const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown }) => {
|
|||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className='focusable flex items-center justify-center border border-solid border-gray-200 bg-gray-100 p-9 dark:border-gray-800 dark:bg-gray-900 sm:rounded-xl' tabIndex={0}>
|
||||
<Text>
|
||||
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />
|
||||
</Text>
|
||||
<div className='h-16'>
|
||||
<div
|
||||
className='focusable flex h-[42px] items-center justify-center rounded-lg border-2 border-gray-200 text-center'
|
||||
>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='statuses.tombstone'
|
||||
defaultMessage='One or more posts are unavailable.'
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
|
|
|
@ -21,13 +21,16 @@ interface IAccordion {
|
|||
menu?: Menu
|
||||
expanded?: boolean
|
||||
onToggle?: (value: boolean) => void
|
||||
action?: () => void
|
||||
actionIcon?: string
|
||||
actionLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Accordion
|
||||
* An accordion is a vertically stacked group of collapsible sections.
|
||||
*/
|
||||
const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded = false, onToggle = () => {} }) => {
|
||||
const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded = false, onToggle = () => {}, action, actionIcon, actionLabel }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
@ -35,6 +38,13 @@ const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded =
|
|||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleAction = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!action) return;
|
||||
|
||||
action();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='rounded-lg bg-white text-gray-900 shadow dark:bg-primary-800 dark:text-gray-100 dark:shadow-none'>
|
||||
<button
|
||||
|
@ -53,6 +63,14 @@ const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded =
|
|||
src={require('@tabler/icons/dots-vertical.svg')}
|
||||
/>
|
||||
)}
|
||||
{action && actionIcon && (
|
||||
<button onClick={handleAction} title={actionLabel}>
|
||||
<Icon
|
||||
src={actionIcon}
|
||||
className='h-5 w-5 text-gray-700 dark:text-gray-600'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<Icon
|
||||
src={expanded ? require('@tabler/icons/chevron-up.svg') : require('@tabler/icons/chevron-down.svg')}
|
||||
className='h-5 w-5 text-gray-700 dark:text-gray-600'
|
||||
|
|
|
@ -1,34 +1,54 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import StillImage, { IStillImage } from 'soapbox/components/still-image';
|
||||
|
||||
import Icon from '../icon/icon';
|
||||
|
||||
const AVATAR_SIZE = 42;
|
||||
|
||||
interface IAvatar {
|
||||
/** URL to the avatar image. */
|
||||
src: string
|
||||
interface IAvatar extends Pick<IStillImage, 'src' | 'onError' | 'className'> {
|
||||
/** Width and height of the avatar in pixels. */
|
||||
size?: number
|
||||
/** Extra class names for the div surrounding the avatar image. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Round profile avatar for accounts. */
|
||||
const Avatar = (props: IAvatar) => {
|
||||
const { src, size = AVATAR_SIZE, className } = props;
|
||||
|
||||
const [isAvatarMissing, setIsAvatarMissing] = useState<boolean>(false);
|
||||
|
||||
const handleLoadFailure = () => setIsAvatarMissing(true);
|
||||
|
||||
const style: React.CSSProperties = React.useMemo(() => ({
|
||||
width: size,
|
||||
height: size,
|
||||
}), [size]);
|
||||
|
||||
if (isAvatarMissing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={clsx('flex items-center justify-center rounded-full bg-gray-200 dark:bg-gray-900', className)}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/photo-off.svg')}
|
||||
className='h-4 w-4 text-gray-500 dark:text-gray-700'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StillImage
|
||||
className={clsx('rounded-full', className)}
|
||||
style={style}
|
||||
src={src}
|
||||
alt='Avatar'
|
||||
onError={handleLoadFailure}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,11 +16,13 @@ const messages = defineMessages({
|
|||
back: { id: 'card.back.label', defaultMessage: 'Back' },
|
||||
});
|
||||
|
||||
export type CardSizes = keyof typeof sizes
|
||||
|
||||
interface ICard {
|
||||
/** The type of card. */
|
||||
variant?: 'default' | 'rounded'
|
||||
variant?: 'default' | 'rounded' | 'slim'
|
||||
/** Card size preset. */
|
||||
size?: keyof typeof sizes
|
||||
size?: CardSizes
|
||||
/** Extra classnames for the <div> element. */
|
||||
className?: string
|
||||
/** Elements inside the card. */
|
||||
|
@ -33,8 +35,9 @@ const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant = 'def
|
|||
ref={ref}
|
||||
{...filteredProps}
|
||||
className={clsx({
|
||||
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded',
|
||||
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none': variant === 'rounded',
|
||||
[sizes[size]]: variant === 'rounded',
|
||||
'py-4': variant === 'slim',
|
||||
}, className)}
|
||||
>
|
||||
{children}
|
||||
|
@ -72,7 +75,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
|||
};
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' space={2} className={clsx('mb-4', className)}>
|
||||
<HStack alignItems='center' space={2} className={className}>
|
||||
{renderBackButton()}
|
||||
|
||||
{children}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Helmet from 'soapbox/components/helmet';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||
import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from '../card/card';
|
||||
|
||||
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'action'>;
|
||||
|
||||
|
@ -50,17 +51,35 @@ export interface IColumn {
|
|||
withHeader?: boolean
|
||||
/** Extra class name for top <div> element. */
|
||||
className?: string
|
||||
/** Extra class name for the <CardBody> element. */
|
||||
bodyClassName?: string
|
||||
/** Ref forwarded to column. */
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
/** Children to display in the column. */
|
||||
children?: React.ReactNode
|
||||
/** Action for the ColumnHeader, displayed at the end. */
|
||||
action?: React.ReactNode
|
||||
/** Column size, inherited from Card. */
|
||||
size?: CardSizes
|
||||
}
|
||||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, action } = props;
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props;
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const handleScroll = useCallback(throttle(() => {
|
||||
setIsScrolled(window.pageYOffset > 32);
|
||||
}, 50), []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
|
||||
|
@ -76,17 +95,23 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
)}
|
||||
</Helmet>
|
||||
|
||||
<Card variant={transparent ? undefined : 'rounded'} className={className}>
|
||||
<Card size={size} variant={transparent ? undefined : 'rounded'} className={className}>
|
||||
{withHeader && (
|
||||
<ColumnHeader
|
||||
label={label}
|
||||
backHref={backHref}
|
||||
className={clsx({ 'px-4 pt-4 sm:p-0': transparent })}
|
||||
className={clsx({
|
||||
'rounded-t-3xl': !isScrolled && !transparent,
|
||||
'sticky top-12 z-10 bg-white/90 dark:bg-primary-900/90 backdrop-blur lg:top-16': !transparent,
|
||||
'p-4 sm:p-0 sm:pb-4': transparent,
|
||||
'-mt-4 -mx-4 p-4': size !== 'lg' && !transparent,
|
||||
'-mt-4 -mx-4 p-4 sm:-mt-6 sm:-mx-6 sm:p-6': size === 'lg' && !transparent,
|
||||
})}
|
||||
action={action}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardBody>
|
||||
<CardBody className={bodyClassName}>
|
||||
{children}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
|
|
@ -11,24 +11,22 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||
src: string
|
||||
/** Text to display next ot the button. */
|
||||
text?: string
|
||||
/** Don't render a background behind the icon. */
|
||||
transparent?: boolean
|
||||
/** Predefined styles to display for the button. */
|
||||
theme?: 'seamless' | 'outlined' | 'secondary'
|
||||
theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent'
|
||||
/** Override the data-testid */
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
/** A clickable icon. */
|
||||
const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
|
||||
const { src, className, iconClassName, text, transparent = false, theme = 'seamless', ...filteredProps } = props;
|
||||
const { src, className, iconClassName, text, theme = 'seamless', ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type='button'
|
||||
className={clsx('flex items-center space-x-2 rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-offset-0', {
|
||||
'bg-white dark:bg-transparent': !transparent,
|
||||
'bg-white dark:bg-transparent': theme === 'seamless',
|
||||
'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined',
|
||||
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200': theme === 'secondary',
|
||||
'opacity-50': filteredProps.disabled,
|
||||
|
|
|
@ -17,11 +17,16 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
|||
src: string
|
||||
/** Width and height of the icon in pixels. */
|
||||
size?: number
|
||||
/** Override the data-testid */
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
/** Renders and SVG icon with optional counter. */
|
||||
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
|
||||
<div className='relative flex shrink-0 flex-col' data-testid='icon'>
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col'
|
||||
data-testid={filteredProps['data-testid'] || 'icon'}
|
||||
>
|
||||
{count ? (
|
||||
<span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'>
|
||||
<Counter count={count} countMax={countMax} />
|
||||
|
|
|
@ -55,10 +55,11 @@ interface IModal {
|
|||
title?: React.ReactNode
|
||||
width?: keyof typeof widths
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Displays a modal dialog box. */
|
||||
const Modal: React.FC<IModal> = ({
|
||||
const Modal = React.forwardRef<HTMLDivElement, IModal>(({
|
||||
cancelAction,
|
||||
cancelText,
|
||||
children,
|
||||
|
@ -76,7 +77,8 @@ const Modal: React.FC<IModal> = ({
|
|||
skipFocus = false,
|
||||
title,
|
||||
width = 'xl',
|
||||
}) => {
|
||||
className,
|
||||
}, ref) => {
|
||||
const intl = useIntl();
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
|
@ -87,7 +89,11 @@ const Modal: React.FC<IModal> = ({
|
|||
}, [skipFocus, buttonRef]);
|
||||
|
||||
return (
|
||||
<div data-testid='modal' className={clsx('pointer-events-auto mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all dark:bg-primary-900 dark:text-gray-100', widths[width])}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid='modal'
|
||||
className={clsx(className, 'pointer-events-auto mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all dark:bg-primary-900 dark:text-gray-100', widths[width])}
|
||||
>
|
||||
<div className='w-full justify-between sm:flex sm:items-start'>
|
||||
<div className='w-full'>
|
||||
{title && (
|
||||
|
@ -96,7 +102,7 @@ const Modal: React.FC<IModal> = ({
|
|||
'flex-row-reverse': closePosition === 'left',
|
||||
})}
|
||||
>
|
||||
<h3 className='grow text-lg font-bold leading-6 text-gray-900 dark:text-white'>
|
||||
<h3 className='grow truncate text-lg font-bold leading-6 text-gray-900 dark:text-white'>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
|
@ -157,6 +163,6 @@ const Modal: React.FC<IModal> = ({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default Modal;
|
||||
|
|
|
@ -16,6 +16,7 @@ const messages = defineMessages({
|
|||
export type StreamfieldComponent<T> = React.ComponentType<{
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
autoFocus: boolean
|
||||
}>;
|
||||
|
||||
interface IStreamfield {
|
||||
|
@ -69,14 +70,19 @@ const Streamfield: React.FC<IStreamfield> = ({
|
|||
</Stack>
|
||||
|
||||
{(values.length > 0) && (
|
||||
<Stack>
|
||||
<Stack space={1}>
|
||||
{values.map((value, i) => value?._destroy ? null : (
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Component key={i} onChange={handleChange(i)} value={value} />
|
||||
<Component
|
||||
key={i}
|
||||
onChange={handleChange(i)}
|
||||
value={value}
|
||||
autoFocus={i > 0}
|
||||
/>
|
||||
{values.length > minItems && onRemoveItem && (
|
||||
<IconButton
|
||||
iconClassName='h-4 w-4'
|
||||
className='bg-transparent text-gray-400 hover:text-gray-600'
|
||||
className='bg-transparent text-gray-600 hover:text-gray-600'
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={() => onRemoveItem(i)}
|
||||
title={intl.formatMessage(messages.remove)}
|
||||
|
@ -87,11 +93,9 @@ const Streamfield: React.FC<IStreamfield> = ({
|
|||
</Stack>
|
||||
)}
|
||||
|
||||
{onAddItem && (
|
||||
{(onAddItem && (values.length < maxItems)) && (
|
||||
<Button
|
||||
icon={require('@tabler/icons/plus.svg')}
|
||||
onClick={onAddItem}
|
||||
disabled={values.length >= maxItems}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
|
|
|
@ -20,7 +20,6 @@ const Tag: React.FC<ITag> = ({ tag, onDelete }) => {
|
|||
iconClassName='h-4 w-4'
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={() => onDelete(tag)}
|
||||
transparent
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -8,6 +8,8 @@ import { ToastText, ToastType } from 'soapbox/toast';
|
|||
|
||||
import HStack from '../hstack/hstack';
|
||||
import Icon from '../icon/icon';
|
||||
import Stack from '../stack/stack';
|
||||
import Text from '../text/text';
|
||||
|
||||
const renderText = (text: ToastText) => {
|
||||
if (typeof text === 'string') {
|
||||
|
@ -24,13 +26,14 @@ interface IToast {
|
|||
action?(): void
|
||||
actionLink?: string
|
||||
actionLabel?: ToastText
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizable Toasts for in-app notifications.
|
||||
*/
|
||||
const Toast = (props: IToast) => {
|
||||
const { t, message, type, action, actionLink, actionLabel } = props;
|
||||
const { t, message, type, action, actionLink, actionLabel, summary } = props;
|
||||
|
||||
const dismissToast = () => toast.dismiss(t.id);
|
||||
|
||||
|
@ -109,35 +112,46 @@ const Toast = (props: IToast) => {
|
|||
})
|
||||
}
|
||||
>
|
||||
<HStack space={4} alignItems='start'>
|
||||
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
|
||||
<HStack space={3} alignItems='start' className='w-0 flex-1'>
|
||||
<div className='shrink-0'>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
<Stack space={2}>
|
||||
<HStack space={4} alignItems='start'>
|
||||
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
|
||||
<HStack space={3} alignItems='start' className='w-0 flex-1'>
|
||||
<div className='shrink-0'>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
|
||||
<p className='pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'>
|
||||
{renderText(message)}
|
||||
</p>
|
||||
<Text
|
||||
size='sm'
|
||||
data-testid='toast-message'
|
||||
className='pt-0.5'
|
||||
weight={typeof summary === 'undefined' ? 'normal' : 'medium'}
|
||||
>
|
||||
{renderText(message)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* Action */}
|
||||
{renderAction()}
|
||||
</HStack>
|
||||
|
||||
{/* Action */}
|
||||
{renderAction()}
|
||||
{/* Dismiss Button */}
|
||||
<div className='flex shrink-0 pt-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex rounded-md text-gray-600 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:text-gray-600 dark:hover:text-gray-500'
|
||||
onClick={dismissToast}
|
||||
data-testid='toast-dismiss'
|
||||
>
|
||||
<span className='sr-only'>Close</span>
|
||||
<Icon src={require('@tabler/icons/x.svg')} className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
{/* Dismiss Button */}
|
||||
<div className='flex shrink-0 pt-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex rounded-md text-gray-600 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:text-gray-600 dark:hover:text-gray-500'
|
||||
onClick={dismissToast}
|
||||
data-testid='toast-dismiss'
|
||||
>
|
||||
<span className='sr-only'>Close</span>
|
||||
<Icon src={require('@tabler/icons/x.svg')} className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
</HStack>
|
||||
{summary ? (
|
||||
<Text theme='muted' size='sm'>{summary}</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -26,6 +26,7 @@ const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked, onChange, r
|
|||
'cursor-default': disabled,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
type='button'
|
||||
>
|
||||
<div className={clsx('rounded-full bg-white transition-transform', {
|
||||
'h-4.5 w-4.5': size === 'sm',
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
:root {
|
||||
--reach-tooltip: 1;
|
||||
}
|
||||
|
||||
[data-reach-tooltip] {
|
||||
@apply pointer-events-none absolute px-2.5 py-1.5 rounded shadow whitespace-nowrap text-xs font-medium bg-gray-800 text-gray-100 dark:bg-gray-100 dark:text-gray-900;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
[data-reach-tooltip-arrow] {
|
||||
@apply absolute z-50 w-0 h-0 border-l-8 border-solid border-l-transparent border-r-8 border-r-transparent border-b-8 border-b-gray-800 dark:border-b-gray-100;
|
||||
}
|
|
@ -1,67 +1,88 @@
|
|||
import { TooltipPopup, useTooltip } from '@reach/tooltip';
|
||||
import React from 'react';
|
||||
|
||||
import Portal from '../portal/portal';
|
||||
|
||||
import './tooltip.css';
|
||||
import {
|
||||
arrow,
|
||||
FloatingArrow,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
useTransitionStyles,
|
||||
} from '@floating-ui/react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
interface ITooltip {
|
||||
/** Element to display the tooltip around. */
|
||||
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||
/** Text to display in the tooltip. */
|
||||
text: string
|
||||
/** Element to display the tooltip around. */
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const centered = (triggerRect: any, tooltipRect: any) => {
|
||||
const triggerCenter = triggerRect.left + triggerRect.width / 2;
|
||||
const left = triggerCenter - tooltipRect.width / 2;
|
||||
const maxLeft = window.innerWidth - tooltipRect.width - 2;
|
||||
return {
|
||||
left: Math.min(Math.max(2, left), maxLeft) + window.scrollX,
|
||||
top: triggerRect.bottom + 8 + window.scrollY,
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Tooltip
|
||||
*/
|
||||
const Tooltip: React.FC<ITooltip> = (props) => {
|
||||
const { children, text } = props;
|
||||
|
||||
/** Hoverable tooltip element. */
|
||||
const Tooltip: React.FC<ITooltip> = ({
|
||||
children,
|
||||
text,
|
||||
}) => {
|
||||
// get the props from useTooltip
|
||||
const [trigger, tooltip] = useTooltip();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
// destructure off what we need to position the triangle
|
||||
const { isVisible, triggerRect } = tooltip;
|
||||
const arrowRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const { x, y, strategy, refs, context } = useFloating({
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
placement: 'top',
|
||||
middleware: [
|
||||
offset(6),
|
||||
arrow({
|
||||
element: arrowRef,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const hover = useHover(context);
|
||||
const { isMounted, styles } = useTransitionStyles(context, {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
duration: {
|
||||
open: 200,
|
||||
close: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
hover,
|
||||
]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{React.cloneElement(children as any, trigger)}
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
ref: refs.setReference,
|
||||
...getReferenceProps(),
|
||||
})}
|
||||
|
||||
{isVisible && (
|
||||
// The Triangle. We position it relative to the trigger, not the popup
|
||||
// so that collisions don't have a triangle pointing off to nowhere.
|
||||
// Using a Portal may seem a little extreme, but we can keep the
|
||||
// positioning logic simpler here instead of needing to consider
|
||||
// the popup's position relative to the trigger and collisions
|
||||
<Portal>
|
||||
{(isMounted) && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
data-reach-tooltip-arrow='true'
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
left:
|
||||
triggerRect && triggerRect.left - 10 + triggerRect.width / 2 as any,
|
||||
top: triggerRect && triggerRect.bottom + window.scrollY as any,
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
...styles,
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
className='pointer-events-none z-[100] whitespace-nowrap rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900'
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{text}
|
||||
|
||||
<FloatingArrow ref={arrowRef} context={context} className='fill-gray-800 dark:fill-gray-100' />
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
<TooltipPopup
|
||||
{...tooltip}
|
||||
label={text}
|
||||
aria-label={text}
|
||||
position={centered}
|
||||
/>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
export default Tooltip;
|
|
@ -1,24 +0,0 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
|
||||
interface IGroupContainer {
|
||||
id: string
|
||||
}
|
||||
|
||||
const GroupContainer: React.FC<IGroupContainer> = (props) => {
|
||||
const { id, ...rest } = props;
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
|
||||
if (group) {
|
||||
return <GroupCard group={group} {...rest} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default GroupContainer;
|
|
@ -191,7 +191,14 @@ const SoapboxMount = () => {
|
|||
</BundleContainer>
|
||||
|
||||
<GdprBanner />
|
||||
<Toaster position='top-right' containerClassName='top-10' containerStyle={{ top: 75 }} />
|
||||
|
||||
<div id='toaster'>
|
||||
<Toaster
|
||||
position='top-right'
|
||||
containerClassName='top-10'
|
||||
containerStyle={{ top: 75 }}
|
||||
/>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</ScrollContext>
|
||||
|
|
|
@ -110,7 +110,7 @@ test('import entities with override', () => {
|
|||
|
||||
const now = new Date();
|
||||
|
||||
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', {
|
||||
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', 'end', {
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
totalCount: 2,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Entity, EntityListState } from './types';
|
||||
import type { Entity, EntityListState, ImportPosition } from './types';
|
||||
|
||||
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
|
||||
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
|
||||
|
@ -10,12 +10,13 @@ const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
|
|||
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
|
||||
|
||||
/** Action to import entities into the cache. */
|
||||
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
|
||||
function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) {
|
||||
return {
|
||||
type: ENTITIES_IMPORT,
|
||||
entityType,
|
||||
entities,
|
||||
listKey,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -62,6 +63,7 @@ function entitiesFetchSuccess(
|
|||
entities: Entity[],
|
||||
entityType: string,
|
||||
listKey?: string,
|
||||
pos?: ImportPosition,
|
||||
newState?: EntityListState,
|
||||
overwrite = false,
|
||||
) {
|
||||
|
@ -70,6 +72,7 @@ function entitiesFetchSuccess(
|
|||
entityType,
|
||||
entities,
|
||||
listKey,
|
||||
pos,
|
||||
newState,
|
||||
overwrite,
|
||||
};
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
export enum Entities {
|
||||
ACCOUNTS = 'Accounts',
|
||||
GROUPS = 'Groups',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_TAGS = 'GroupTags',
|
||||
RELATIONSHIPS = 'Relationships',
|
||||
STATUSES = 'Statuses'
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
export { useEntities } from './useEntities';
|
||||
export { useEntity } from './useEntity';
|
||||
export { useEntityActions } from './useEntityActions';
|
||||
export { useEntityLookup } from './useEntityLookup';
|
||||
export { useCreateEntity } from './useCreateEntity';
|
||||
export { useDeleteEntity } from './useDeleteEntity';
|
||||
export { useDismissEntity } from './useDismissEntity';
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppDispatch, useLoading } from 'soapbox/hooks';
|
||||
|
@ -20,31 +21,35 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
|
|||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isLoading, setPromise] = useLoading();
|
||||
const [isSubmitting, setPromise] = useLoading();
|
||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||
|
||||
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
|
||||
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity, AxiosError> = {}): Promise<void> {
|
||||
try {
|
||||
const result = await setPromise(entityFn(data));
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entity = schema.parse(result.data);
|
||||
|
||||
// TODO: optimistic updating
|
||||
dispatch(importEntities([entity], entityType, listKey));
|
||||
dispatch(importEntities([entity], entityType, listKey, 'start'));
|
||||
|
||||
if (callbacks.onSuccess) {
|
||||
callbacks.onSuccess(entity);
|
||||
}
|
||||
} catch (error) {
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(error);
|
||||
if (error instanceof AxiosError) {
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(error);
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createEntity,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ function useDeleteEntity(
|
|||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
const getState = useGetState();
|
||||
const [isLoading, setPromise] = useLoading();
|
||||
const [isSubmitting, setPromise] = useLoading();
|
||||
|
||||
async function deleteEntity(entityId: string, callbacks: EntityCallbacks<string> = {}): Promise<void> {
|
||||
// Get the entity before deleting, so we can reverse the action if the API request fails.
|
||||
|
@ -47,7 +47,7 @@ function useDeleteEntity(
|
|||
|
||||
return {
|
||||
deleteEntity,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ function useEntities<TEntity extends Entity>(
|
|||
const next = useListState(path, 'next');
|
||||
const prev = useListState(path, 'prev');
|
||||
|
||||
const fetchPage = async(req: EntityFn<void>, overwrite = false): Promise<void> => {
|
||||
const fetchPage = async(req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => {
|
||||
// Get `isFetching` state from the store again to prevent race conditions.
|
||||
const isFetching = selectListState(getState(), path, 'fetching');
|
||||
if (isFetching) return;
|
||||
|
@ -65,11 +65,12 @@ function useEntities<TEntity extends Entity>(
|
|||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entities = filteredArray(schema).parse(response.data);
|
||||
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
|
||||
const totalCount = parsedCount.success ? parsedCount.data : undefined;
|
||||
|
||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, {
|
||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, pos, {
|
||||
next: getNextLink(response),
|
||||
prev: getPrevLink(response),
|
||||
totalCount: parsedCount.success ? parsedCount.data : undefined,
|
||||
totalCount: Number(totalCount) >= entities.length ? totalCount : undefined,
|
||||
fetching: false,
|
||||
fetched: true,
|
||||
error: null,
|
||||
|
@ -82,18 +83,18 @@ function useEntities<TEntity extends Entity>(
|
|||
};
|
||||
|
||||
const fetchEntities = async(): Promise<void> => {
|
||||
await fetchPage(entityFn, true);
|
||||
await fetchPage(entityFn, 'end', true);
|
||||
};
|
||||
|
||||
const fetchNextPage = async(): Promise<void> => {
|
||||
if (next) {
|
||||
await fetchPage(() => api.get(next));
|
||||
await fetchPage(() => api.get(next), 'end');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPreviousPage = async(): Promise<void> => {
|
||||
if (prev) {
|
||||
await fetchPage(() => api.get(prev));
|
||||
await fetchPage(() => api.get(prev), 'start');
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -112,7 +113,7 @@ function useEntities<TEntity extends Entity>(
|
|||
if (isInvalid || isUnset || isStale) {
|
||||
fetchEntities();
|
||||
}
|
||||
}, [isEnabled]);
|
||||
}, [isEnabled, ...path]);
|
||||
|
||||
return {
|
||||
entities,
|
||||
|
|
|
@ -14,6 +14,8 @@ interface UseEntityOpts<TEntity extends Entity> {
|
|||
schema?: EntitySchema<TEntity>
|
||||
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
||||
refetch?: boolean
|
||||
/** A flag to potentially disable sending requests to the API. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function useEntity<TEntity extends Entity>(
|
||||
|
@ -21,7 +23,7 @@ function useEntity<TEntity extends Entity>(
|
|||
entityFn: EntityFn<void>,
|
||||
opts: UseEntityOpts<TEntity> = {},
|
||||
) {
|
||||
const [isFetching, setPromise] = useLoading();
|
||||
const [isFetching, setPromise] = useLoading(true);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [entityType, entityId] = path;
|
||||
|
@ -31,6 +33,7 @@ function useEntity<TEntity extends Entity>(
|
|||
|
||||
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
|
||||
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isLoading = isFetching && !entity;
|
||||
|
||||
const fetchEntity = async () => {
|
||||
|
@ -44,10 +47,11 @@ function useEntity<TEntity extends Entity>(
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (!entity || opts.refetch) {
|
||||
fetchEntity();
|
||||
}
|
||||
}, []);
|
||||
}, [isEnabled]);
|
||||
|
||||
return {
|
||||
entity,
|
||||
|
@ -59,4 +63,5 @@ function useEntity<TEntity extends Entity>(
|
|||
|
||||
export {
|
||||
useEntity,
|
||||
type UseEntityOpts,
|
||||
};
|
|
@ -12,8 +12,9 @@ interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
|||
}
|
||||
|
||||
interface EntityActionEndpoints {
|
||||
post?: string
|
||||
delete?: string
|
||||
patch?: string
|
||||
post?: string
|
||||
}
|
||||
|
||||
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||
|
@ -24,16 +25,20 @@ function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
|||
const api = useApi();
|
||||
const { entityType, path } = parseEntitiesPath(expandedPath);
|
||||
|
||||
const { deleteEntity, isLoading: deleteLoading } =
|
||||
const { deleteEntity, isSubmitting: deleteSubmitting } =
|
||||
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
|
||||
|
||||
const { createEntity, isLoading: createLoading } =
|
||||
const { createEntity, isSubmitting: createSubmitting } =
|
||||
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
|
||||
|
||||
const { createEntity: updateEntity, isSubmitting: updateSubmitting } =
|
||||
useCreateEntity<TEntity, Data>(path, (data) => api.patch(endpoints.patch!, data), opts);
|
||||
|
||||
return {
|
||||
createEntity,
|
||||
deleteEntity,
|
||||
isLoading: createLoading || deleteLoading,
|
||||
updateEntity,
|
||||
isSubmitting: createSubmitting || deleteSubmitting || updateSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { useEffect } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks';
|
||||
import { type RootState } from 'soapbox/store';
|
||||
|
||||
import { importEntities } from '../actions';
|
||||
import { Entity } from '../types';
|
||||
|
||||
import { EntityFn } from './types';
|
||||
import { type UseEntityOpts } from './useEntity';
|
||||
|
||||
/** Entities will be filtered through this function until it returns true. */
|
||||
type LookupFn<TEntity extends Entity> = (entity: TEntity) => boolean
|
||||
|
||||
function useEntityLookup<TEntity extends Entity>(
|
||||
entityType: string,
|
||||
lookupFn: LookupFn<TEntity>,
|
||||
entityFn: EntityFn<void>,
|
||||
opts: UseEntityOpts<TEntity> = {},
|
||||
) {
|
||||
const { schema = z.custom<TEntity>() } = opts;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const [isFetching, setPromise] = useLoading(true);
|
||||
|
||||
const entity = useAppSelector(state => findEntity(state, entityType, lookupFn));
|
||||
const isLoading = isFetching && !entity;
|
||||
|
||||
const fetchEntity = async () => {
|
||||
try {
|
||||
const response = await setPromise(entityFn());
|
||||
const entity = schema.parse(response.data);
|
||||
dispatch(importEntities([entity], entityType));
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!entity || opts.refetch) {
|
||||
fetchEntity();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
entity,
|
||||
fetchEntity,
|
||||
isFetching,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
function findEntity<TEntity extends Entity>(
|
||||
state: RootState,
|
||||
entityType: string,
|
||||
lookupFn: LookupFn<TEntity>,
|
||||
) {
|
||||
const cache = state.entities[entityType];
|
||||
|
||||
if (cache) {
|
||||
return (Object.values(cache.store) as TEntity[]).find(lookupFn);
|
||||
}
|
||||
}
|
||||
|
||||
export { useEntityLookup };
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { createCache, createList, updateStore, updateList } from './utils';
|
||||
|
||||
import type { DeleteEntitiesOpts } from './actions';
|
||||
import type { Entity, EntityCache, EntityListState } from './types';
|
||||
import type { Entity, EntityCache, EntityListState, ImportPosition } from './types';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
|
@ -29,6 +29,7 @@ const importEntities = (
|
|||
entityType: string,
|
||||
entities: Entity[],
|
||||
listKey?: string,
|
||||
pos?: ImportPosition,
|
||||
newState?: EntityListState,
|
||||
overwrite = false,
|
||||
): State => {
|
||||
|
@ -43,7 +44,7 @@ const importEntities = (
|
|||
list.ids = new Set();
|
||||
}
|
||||
|
||||
list = updateList(list, entities);
|
||||
list = updateList(list, entities, pos);
|
||||
|
||||
if (newState) {
|
||||
list.state = newState;
|
||||
|
@ -159,7 +160,7 @@ const invalidateEntityList = (state: State, entityType: string, listKey: string)
|
|||
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
||||
switch (action.type) {
|
||||
case ENTITIES_IMPORT:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey);
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos);
|
||||
case ENTITIES_DELETE:
|
||||
return deleteEntities(state, action.entityType, action.ids, action.opts);
|
||||
case ENTITIES_DISMISS:
|
||||
|
@ -167,7 +168,7 @@ function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
|||
case ENTITIES_INCREMENT:
|
||||
return incrementEntities(state, action.entityType, action.listKey, action.diff);
|
||||
case ENTITIES_FETCH_SUCCESS:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite);
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos, action.newState, action.overwrite);
|
||||
case ENTITIES_FETCH_REQUEST:
|
||||
return setFetching(state, action.entityType, action.listKey, true);
|
||||
case ENTITIES_FETCH_FAIL:
|
||||
|
|
|
@ -47,10 +47,14 @@ interface EntityCache<TEntity extends Entity = Entity> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Whether to import items at the start or end of the list. */
|
||||
type ImportPosition = 'start' | 'end'
|
||||
|
||||
export {
|
||||
Entity,
|
||||
EntityStore,
|
||||
EntityList,
|
||||
EntityListState,
|
||||
EntityCache,
|
||||
ImportPosition,
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue