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:
|
variables:
|
||||||
NODE_ENV: test
|
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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Hashtags: let users follow hashtags (Mastodon, Akkoma).
|
||||||
- Posts: Support posts filtering on recent Mastodon versions
|
- Posts: Support posts filtering on recent Mastodon versions
|
||||||
- Reactions: Support custom emoji reactions
|
- Reactions: Support custom emoji reactions
|
||||||
- Compatbility: Support Mastodon v2 timeline filters.
|
- Compatbility: Support Mastodon v2 timeline filters.
|
||||||
|
- Compatbility: Preliminary support for Ditto backend.
|
||||||
- Posts: Support dislikes on Friendica.
|
- Posts: Support dislikes on Friendica.
|
||||||
- UI: added a character counter to some textareas.
|
- UI: added a character counter to some textareas.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Posts: truncate Nostr pubkeys in reply mentions.
|
- Posts: truncate Nostr pubkeys in reply mentions.
|
||||||
- Posts: upgraded emoji picker component.
|
- Posts: upgraded emoji picker component.
|
||||||
|
- Posts: improved design of threads.
|
||||||
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
|
- 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
|
### Fixed
|
||||||
- Posts: fixed emojis being cut off in reactions modal.
|
- 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.
|
- 18n: fixed Chinese language being detected from the browser.
|
||||||
- Conversations: fixed pagination (Mastodon).
|
- Conversations: fixed pagination (Mastodon).
|
||||||
- Compatibility: fix version parsing for Friendica.
|
- 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
|
## [3.2.0] - 2023-02-15
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:18 as build
|
FROM node:20 as build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
COPY yarn.lock .
|
COPY yarn.lock .
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:18
|
FROM node:20
|
||||||
|
|
||||||
RUN apt-get update &&\
|
RUN apt-get update &&\
|
||||||
apt-get install -y inotify-tools &&\
|
apt-get install -y inotify-tools &&\
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
import { __stub } from 'soapbox/api';
|
||||||
|
import { buildRelationship } from 'soapbox/jest/factory';
|
||||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||||
import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes';
|
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 { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';
|
||||||
|
|
||||||
import type { Account } from 'soapbox/types/entities';
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
@ -66,7 +67,7 @@ describe('initAccountNoteModal()', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const state = rootState
|
const state = rootState
|
||||||
.set('relationships', ImmutableMap({ '1': normalizeRelationship({ note: 'hello' }) }));
|
.set('relationships', ImmutableMap({ '1': buildRelationship({ note: 'hello' }) }));
|
||||||
store = mockStore(state);
|
store = mockStore(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
import { __stub } from 'soapbox/api';
|
||||||
|
import { buildRelationship } from 'soapbox/jest/factory';
|
||||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||||
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists';
|
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists';
|
||||||
|
|
||||||
import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers';
|
import { normalizeAccount, normalizeInstance } from '../../normalizers';
|
||||||
import {
|
import {
|
||||||
authorizeFollowRequest,
|
authorizeFollowRequest,
|
||||||
blockAccount,
|
blockAccount,
|
||||||
|
@ -1340,7 +1341,7 @@ describe('fetchRelationships()', () => {
|
||||||
describe('without newAccountIds', () => {
|
describe('without newAccountIds', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const state = rootState
|
const state = rootState
|
||||||
.set('relationships', ImmutableMap({ [id]: normalizeRelationship({}) }))
|
.set('relationships', ImmutableMap({ [id]: buildRelationship() }))
|
||||||
.set('me', '123');
|
.set('me', '123');
|
||||||
store = mockStore(state);
|
store = mockStore(state);
|
||||||
});
|
});
|
||||||
|
|
|
@ -242,7 +242,8 @@ export const fetchOwnAccounts = () =>
|
||||||
return state.auth.users.forEach((user) => {
|
return state.auth.users.forEach((user) => {
|
||||||
const account = state.accounts.get(user.id);
|
const account = state.accounts.get(user.id);
|
||||||
if (!account) {
|
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 { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||||
import type { Emoji } from 'soapbox/features/emoji';
|
import type { Emoji } from 'soapbox/features/emoji';
|
||||||
|
import type { Group } from 'soapbox/schemas';
|
||||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||||
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
|
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
|
||||||
import type { History } from 'soapbox/types/history';
|
import type { History } from 'soapbox/types/history';
|
||||||
|
@ -168,6 +169,14 @@ const cancelQuoteCompose = () => ({
|
||||||
id: 'compose-modal',
|
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') => ({
|
const resetCompose = (composeId = 'compose-modal') => ({
|
||||||
type: COMPOSE_RESET,
|
type: COMPOSE_RESET,
|
||||||
id: composeId,
|
id: composeId,
|
||||||
|
@ -829,6 +838,7 @@ export {
|
||||||
uploadComposeFail,
|
uploadComposeFail,
|
||||||
undoUploadCompose,
|
undoUploadCompose,
|
||||||
groupCompose,
|
groupCompose,
|
||||||
|
groupComposeModal,
|
||||||
setGroupTimelineVisible,
|
setGroupTimelineVisible,
|
||||||
clearComposeSuggestions,
|
clearComposeSuggestions,
|
||||||
fetchComposeSuggestions,
|
fetchComposeSuggestions,
|
||||||
|
|
|
@ -1,21 +1,14 @@
|
||||||
import { defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import { deleteEntities } from 'soapbox/entity-store/actions';
|
import { deleteEntities } from 'soapbox/entity-store/actions';
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
|
|
||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts';
|
import { fetchRelationships } from './accounts';
|
||||||
import { importFetchedGroups, importFetchedAccounts } from './importer';
|
import { importFetchedGroups, importFetchedAccounts } from './importer';
|
||||||
import { closeModal, openModal } from './modals';
|
|
||||||
import { deleteFromTimelines } from './timelines';
|
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
import type { GroupRole } from 'soapbox/reducers/group-memberships';
|
import type { GroupRole } from 'soapbox/reducers/group-memberships';
|
||||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||||
import type { APIEntity, Group } from 'soapbox/types/entities';
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const GROUP_EDITOR_SET = 'GROUP_EDITOR_SET';
|
|
||||||
|
|
||||||
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
|
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
|
||||||
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
|
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_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS';
|
||||||
const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
|
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_REQUEST = 'GROUP_KICK_REQUEST';
|
||||||
const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS';
|
const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS';
|
||||||
const GROUP_KICK_FAIL = 'GROUP_KICK_FAIL';
|
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_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS';
|
||||||
const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL';
|
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) => {
|
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
dispatch(deleteEntities([id], 'Group'));
|
dispatch(deleteEntities([id], 'Group'));
|
||||||
|
|
||||||
|
@ -306,36 +201,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({
|
||||||
skipNotFound: true,
|
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) =>
|
const groupKick = (groupId: string, accountId: string) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
dispatch(groupKickRequest(groupId, accountId));
|
dispatch(groupKickRequest(groupId, accountId));
|
||||||
|
@ -758,57 +623,7 @@ const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, er
|
||||||
error,
|
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 {
|
export {
|
||||||
GROUP_EDITOR_SET,
|
|
||||||
GROUP_CREATE_REQUEST,
|
GROUP_CREATE_REQUEST,
|
||||||
GROUP_CREATE_SUCCESS,
|
GROUP_CREATE_SUCCESS,
|
||||||
GROUP_CREATE_FAIL,
|
GROUP_CREATE_FAIL,
|
||||||
|
@ -827,9 +642,6 @@ export {
|
||||||
GROUP_RELATIONSHIPS_FETCH_REQUEST,
|
GROUP_RELATIONSHIPS_FETCH_REQUEST,
|
||||||
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||||
GROUP_RELATIONSHIPS_FETCH_FAIL,
|
GROUP_RELATIONSHIPS_FETCH_FAIL,
|
||||||
GROUP_DELETE_STATUS_REQUEST,
|
|
||||||
GROUP_DELETE_STATUS_SUCCESS,
|
|
||||||
GROUP_DELETE_STATUS_FAIL,
|
|
||||||
GROUP_KICK_REQUEST,
|
GROUP_KICK_REQUEST,
|
||||||
GROUP_KICK_SUCCESS,
|
GROUP_KICK_SUCCESS,
|
||||||
GROUP_KICK_FAIL,
|
GROUP_KICK_FAIL,
|
||||||
|
@ -869,20 +681,6 @@ export {
|
||||||
GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST,
|
GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST,
|
||||||
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
|
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
|
||||||
GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
|
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,
|
deleteGroup,
|
||||||
deleteGroupRequest,
|
deleteGroupRequest,
|
||||||
deleteGroupSuccess,
|
deleteGroupSuccess,
|
||||||
|
@ -899,10 +697,6 @@ export {
|
||||||
fetchGroupRelationshipsRequest,
|
fetchGroupRelationshipsRequest,
|
||||||
fetchGroupRelationshipsSuccess,
|
fetchGroupRelationshipsSuccess,
|
||||||
fetchGroupRelationshipsFail,
|
fetchGroupRelationshipsFail,
|
||||||
groupDeleteStatus,
|
|
||||||
groupDeleteStatusRequest,
|
|
||||||
groupDeleteStatusSuccess,
|
|
||||||
groupDeleteStatusFail,
|
|
||||||
groupKick,
|
groupKick,
|
||||||
groupKickRequest,
|
groupKickRequest,
|
||||||
groupKickSuccess,
|
groupKickSuccess,
|
||||||
|
@ -955,10 +749,4 @@ export {
|
||||||
rejectGroupMembershipRequestRequest,
|
rejectGroupMembershipRequestRequest,
|
||||||
rejectGroupMembershipRequestSuccess,
|
rejectGroupMembershipRequestSuccess,
|
||||||
rejectGroupMembershipRequestFail,
|
rejectGroupMembershipRequestFail,
|
||||||
changeGroupEditorTitle,
|
|
||||||
changeGroupEditorDescription,
|
|
||||||
changeGroupEditorPrivacy,
|
|
||||||
changeGroupEditorMedia,
|
|
||||||
resetGroupEditor,
|
|
||||||
submitGroupEditor,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -74,7 +74,7 @@ const importFetchedGroup = (group: APIEntity) =>
|
||||||
importFetchedGroups([group]);
|
importFetchedGroups([group]);
|
||||||
|
|
||||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||||
const entities = filteredArray(groupSchema).catch([]).parse(groups);
|
const entities = filteredArray(groupSchema).parse(groups);
|
||||||
return importGroups(entities);
|
return importGroups(entities);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -142,7 +142,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = ()
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
icon: require('@tabler/icons/trash.svg'),
|
icon: require('@tabler/icons/trash.svg'),
|
||||||
heading: intl.formatMessage(messages.deleteStatusHeading),
|
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),
|
confirm: intl.formatMessage(messages.deleteStatusConfirm),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
dispatch(deleteStatus(statusId)).then(() => {
|
dispatch(deleteStatus(statusId)).then(() => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts';
|
import { fetchRelationships } from './accounts';
|
||||||
import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
|
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||||
|
@ -83,10 +83,6 @@ const submitSearch = (filter?: SearchFilter) =>
|
||||||
dispatch(importFetchedStatuses(response.data.statuses));
|
dispatch(importFetchedStatuses(response.data.statuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.data.groups) {
|
|
||||||
dispatch(importFetchedGroups(response.data.groups));
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||||
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
|
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
@ -143,10 +139,6 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
|
||||||
dispatch(importFetchedStatuses(data.statuses));
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.groups) {
|
|
||||||
dispatch(importFetchedGroups(data.groups));
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(expandSearchSuccess(data, value, type));
|
dispatch(expandSearchSuccess(data, value, type));
|
||||||
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
|
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { shouldHaveCard } from 'soapbox/utils/status';
|
||||||
import api, { getNextLink } from '../api';
|
import api, { getNextLink } from '../api';
|
||||||
|
|
||||||
import { setComposeToStatus } from './compose';
|
import { setComposeToStatus } from './compose';
|
||||||
|
import { fetchGroupRelationships } from './groups';
|
||||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
import { openModal } from './modals';
|
import { openModal } from './modals';
|
||||||
import { deleteFromTimelines } from './timelines';
|
import { deleteFromTimelines } from './timelines';
|
||||||
|
@ -124,6 +125,9 @@ const fetchStatus = (id: string) => {
|
||||||
|
|
||||||
return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => {
|
return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => {
|
||||||
dispatch(importFetchedStatus(status));
|
dispatch(importFetchedStatus(status));
|
||||||
|
if (status.group) {
|
||||||
|
dispatch(fetchGroupRelationships([status.group.id]));
|
||||||
|
}
|
||||||
dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading });
|
dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading });
|
||||||
return status;
|
return status;
|
||||||
}).catch(error => {
|
}).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 { normalizeStatus } from 'soapbox/normalizers';
|
||||||
import { shouldFilter } from 'soapbox/utils/timelines';
|
import { shouldFilter } from 'soapbox/utils/timelines';
|
||||||
|
|
||||||
import api, { getLinks } from '../api';
|
import api, { getNextLink, getPrevLink } from '../api';
|
||||||
|
|
||||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
|
@ -139,7 +139,7 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
|
||||||
};
|
};
|
||||||
|
|
||||||
const replaceHomeTimeline = (
|
const replaceHomeTimeline = (
|
||||||
accountId: string | null,
|
accountId: string | undefined,
|
||||||
{ maxId }: Record<string, any> = {},
|
{ maxId }: Record<string, any> = {},
|
||||||
done?: () => void,
|
done?: () => void,
|
||||||
) => (dispatch: AppDispatch, _getState: () => RootState) => {
|
) => (dispatch: AppDispatch, _getState: () => RootState) => {
|
||||||
|
@ -162,7 +162,12 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
||||||
return dispatch(noOpAsync());
|
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]);
|
params.since_id = timeline.getIn(['items', 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,9 +176,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
||||||
dispatch(expandTimelineRequest(timelineId, isLoadingMore));
|
dispatch(expandTimelineRequest(timelineId, isLoadingMore));
|
||||||
|
|
||||||
return api(getState).get(path, { params }).then(response => {
|
return api(getState).get(path, { params }).then(response => {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(importFetchedStatuses(response.data));
|
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();
|
done();
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
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) => {
|
interface ExpandHomeTimelineOpts {
|
||||||
const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home';
|
accountId?: string
|
||||||
const params: any = { max_id: maxId };
|
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) {
|
if (accountId) {
|
||||||
params.exclude_replies = true;
|
params.exclude_replies = true;
|
||||||
params.with_muted = 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) =>
|
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
||||||
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
|
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> = {}) =>
|
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 });
|
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,
|
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,
|
type: TIMELINE_EXPAND_SUCCESS,
|
||||||
timeline,
|
timeline,
|
||||||
statuses,
|
statuses,
|
||||||
next,
|
next,
|
||||||
|
prev,
|
||||||
partial,
|
partial,
|
||||||
isLoadingRecent,
|
isLoadingRecent,
|
||||||
skipLoading: !isLoadingMore,
|
skipLoading: !isLoadingMore,
|
||||||
|
@ -312,6 +353,7 @@ export {
|
||||||
expandAccountMediaTimeline,
|
expandAccountMediaTimeline,
|
||||||
expandListTimeline,
|
expandListTimeline,
|
||||||
expandGroupTimeline,
|
expandGroupTimeline,
|
||||||
|
expandGroupTimelineFromTag,
|
||||||
expandGroupMediaTimeline,
|
expandGroupMediaTimeline,
|
||||||
expandHashtagTimeline,
|
expandHashtagTimeline,
|
||||||
expandTimelineRequest,
|
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';
|
import type { Group } from 'soapbox/schemas';
|
||||||
|
|
||||||
function useDeleteGroup() {
|
function useDeleteGroup() {
|
||||||
const { deleteEntity, isLoading } = useEntityActions<Group>(
|
const { deleteEntity, isSubmitting } = useEntityActions<Group>(
|
||||||
[Entities.GROUPS],
|
[Entities.GROUPS],
|
||||||
{ delete: '/api/v1/groups/:id' },
|
{ delete: '/api/v1/groups/:id' },
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutate: deleteEntity,
|
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 { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
|
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 api = useApi();
|
||||||
|
|
||||||
const { entities, ...result } = useEntities<GroupMember>(
|
const { entities, ...result } = useEntities<GroupMember>(
|
|
@ -1,7 +1,10 @@
|
||||||
import { Entities } from 'soapbox/entity-store/entities';
|
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 { useApi } from 'soapbox/hooks/useApi';
|
||||||
import { accountSchema } from 'soapbox/schemas';
|
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';
|
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
|
||||||
|
|
||||||
|
@ -9,19 +12,24 @@ function useGroupMembershipRequests(groupId: string) {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
|
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,
|
path,
|
||||||
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
|
() => 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`);
|
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`);
|
||||||
invalidate();
|
invalidate();
|
||||||
return response;
|
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`);
|
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`);
|
||||||
invalidate();
|
invalidate();
|
||||||
return response;
|
return response;
|
||||||
|
@ -29,6 +37,7 @@ function useGroupMembershipRequests(groupId: string) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts: entities,
|
accounts: entities,
|
||||||
|
refetch: fetchEntities,
|
||||||
authorize,
|
authorize,
|
||||||
reject,
|
reject,
|
||||||
...rest,
|
...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 { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
|
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||||
import { groupSchema } from 'soapbox/schemas';
|
import { groupSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
import { useApi } from '../../useApi';
|
import { useGroupRelationships } from './useGroupRelationships';
|
||||||
import { useFeatures } from '../../useFeatures';
|
|
||||||
|
|
||||||
import { useGroupRelationships } from './useGroups';
|
|
||||||
|
|
||||||
import type { Group } from 'soapbox/schemas';
|
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) {
|
function useJoinGroup(group: Group) {
|
||||||
const { invalidate } = useGroups();
|
const { invalidate } = useGroups();
|
||||||
|
|
||||||
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
|
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
|
||||||
[Entities.GROUP_RELATIONSHIPS, group.id],
|
[Entities.GROUP_RELATIONSHIPS, group.id],
|
||||||
{ post: `/api/v1/groups/${group.id}/join` },
|
{ post: `/api/v1/groups/${group.id}/join` },
|
||||||
{ schema: groupRelationshipSchema },
|
{ schema: groupRelationshipSchema },
|
||||||
|
@ -17,7 +17,7 @@ function useJoinGroup(group: Group) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutate: createEntity,
|
mutate: createEntity,
|
||||||
isLoading,
|
isSubmitting,
|
||||||
invalidate,
|
invalidate,
|
||||||
};
|
};
|
||||||
}
|
}
|
|
@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas';
|
||||||
function useLeaveGroup(group: Group) {
|
function useLeaveGroup(group: Group) {
|
||||||
const { invalidate } = useGroups();
|
const { invalidate } = useGroups();
|
||||||
|
|
||||||
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
|
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
|
||||||
[Entities.GROUP_RELATIONSHIPS, group.id],
|
[Entities.GROUP_RELATIONSHIPS, group.id],
|
||||||
{ post: `/api/v1/groups/${group.id}/leave` },
|
{ post: `/api/v1/groups/${group.id}/leave` },
|
||||||
{ schema: groupRelationshipSchema },
|
{ schema: groupRelationshipSchema },
|
||||||
|
@ -17,7 +17,7 @@ function useLeaveGroup(group: Group) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutate: createEntity,
|
mutate: createEntity,
|
||||||
isLoading,
|
isSubmitting,
|
||||||
invalidate,
|
invalidate,
|
||||||
};
|
};
|
||||||
}
|
}
|
|
@ -2,9 +2,10 @@ import { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
import { Group, groupSchema } from 'soapbox/schemas';
|
import { Group, groupSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
import { useGroupRelationships } from '../api/groups/useGroups';
|
import { useApi } from '../../../hooks/useApi';
|
||||||
import { useApi } from '../useApi';
|
import { useFeatures } from '../../../hooks/useFeatures';
|
||||||
import { useFeatures } from '../useFeatures';
|
|
||||||
|
import { useGroupRelationships } from './useGroupRelationships';
|
||||||
|
|
||||||
function usePopularGroups() {
|
function usePopularGroups() {
|
||||||
const api = useApi();
|
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 { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
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 { useGroupRelationships } from './useGroupRelationships';
|
||||||
import { useApi } from '../useApi';
|
|
||||||
import { useFeatures } from '../useFeatures';
|
|
||||||
|
|
||||||
function useSuggestedGroups() {
|
function useSuggestedGroups() {
|
||||||
const api = useApi();
|
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 AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize, onReject, countdown }) => {
|
||||||
const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending');
|
const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending');
|
||||||
const timeout = useRef<NodeJS.Timeout>();
|
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(
|
function handleAction(
|
||||||
present: 'authorizing' | 'rejecting',
|
present: 'authorizing' | 'rejecting',
|
||||||
|
@ -21,6 +38,9 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
||||||
action: () => Promise<unknown> | unknown,
|
action: () => Promise<unknown> | unknown,
|
||||||
): void {
|
): void {
|
||||||
if (state === present) {
|
if (state === present) {
|
||||||
|
if (interval.current) {
|
||||||
|
clearInterval(interval.current);
|
||||||
|
}
|
||||||
if (timeout.current) {
|
if (timeout.current) {
|
||||||
clearTimeout(timeout.current);
|
clearTimeout(timeout.current);
|
||||||
}
|
}
|
||||||
|
@ -31,12 +51,13 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
||||||
await action();
|
await action();
|
||||||
setState(past);
|
setState(past);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
if (e) console.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (typeof countdown === 'number') {
|
if (typeof countdown === 'number') {
|
||||||
setState(present);
|
setState(present);
|
||||||
timeout.current = setTimeout(doAction, countdown);
|
timeout.current = setTimeout(doAction, countdown);
|
||||||
|
startProgressInterval();
|
||||||
} else {
|
} else {
|
||||||
doAction();
|
doAction();
|
||||||
}
|
}
|
||||||
|
@ -46,11 +67,28 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
||||||
const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize);
|
const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize);
|
||||||
const handleReject = async () => handleAction('rejecting', 'rejected', onReject);
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (timeout.current) {
|
if (timeout.current) {
|
||||||
clearTimeout(timeout.current);
|
clearTimeout(timeout.current);
|
||||||
}
|
}
|
||||||
|
if (interval.current) {
|
||||||
|
clearInterval(interval.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -72,6 +110,7 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
||||||
action={handleReject}
|
action={handleReject}
|
||||||
isLoading={state === 'rejecting'}
|
isLoading={state === 'rejecting'}
|
||||||
disabled={state === 'authorizing'}
|
disabled={state === 'authorizing'}
|
||||||
|
style={renderStyle('rejecting')}
|
||||||
/>
|
/>
|
||||||
<AuthorizeRejectButton
|
<AuthorizeRejectButton
|
||||||
theme='primary'
|
theme='primary'
|
||||||
|
@ -79,6 +118,7 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
|
||||||
action={handleAuthorize}
|
action={handleAuthorize}
|
||||||
isLoading={state === 'authorizing'}
|
isLoading={state === 'authorizing'}
|
||||||
disabled={state === 'rejecting'}
|
disabled={state === 'rejecting'}
|
||||||
|
style={renderStyle('authorizing')}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
@ -105,33 +145,34 @@ interface IAuthorizeRejectButton {
|
||||||
action(): void
|
action(): void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
disabled?: 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 (
|
return (
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<IconButton
|
<div
|
||||||
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
|
style={style}
|
||||||
onClick={action}
|
className={
|
||||||
theme='seamless'
|
clsx({
|
||||||
className={clsx('h-10 w-10 items-center justify-center border-2', {
|
'flex h-11 w-11 items-center justify-center rounded-full': true,
|
||||||
'border-primary-500/10 hover:border-primary-500': theme === 'primary',
|
'bg-danger-600/10': theme === 'danger',
|
||||||
'border-danger-600/10 hover:border-danger-600': theme === 'danger',
|
'bg-primary-500/10': theme === 'primary',
|
||||||
})}
|
})
|
||||||
iconClassName={clsx('h-6 w-6', {
|
}
|
||||||
'text-primary-500': theme === 'primary',
|
>
|
||||||
'text-danger-600': theme === 'danger',
|
<IconButton
|
||||||
})}
|
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
|
||||||
disabled={disabled}
|
onClick={action}
|
||||||
/>
|
theme='seamless'
|
||||||
{(isLoading) && (
|
className='h-10 w-10 items-center justify-center bg-white focus:!ring-0 dark:!bg-gray-900'
|
||||||
<div
|
iconClassName={clsx('h-6 w-6', {
|
||||||
className={clsx('pointer-events-none absolute inset-0 h-10 w-10 animate-spin rounded-full border-2 border-transparent', {
|
'text-primary-500': theme === 'primary',
|
||||||
'border-t-primary-500': theme === 'primary',
|
'text-danger-600': theme === 'danger',
|
||||||
'border-t-danger-600': theme === 'danger',
|
|
||||||
})}
|
})}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -58,6 +58,7 @@ const BirthdayPanel = ({ limit }: IBirthdayPanel) => {
|
||||||
key={accountId}
|
key={accountId}
|
||||||
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
|
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
|
||||||
id={accountId}
|
id={accountId}
|
||||||
|
withRelationship={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Widget>
|
</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' />}
|
{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 ? (
|
{item.count ? (
|
||||||
<span className='ml-auto h-5 w-5 flex-none'>
|
<span className='ml-auto h-5 w-5 flex-none'>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
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 GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||||
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
|
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
|
||||||
import GroupRelationship from 'soapbox/features/group/components/group-relationship';
|
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';
|
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IGroupCard {
|
interface IGroupCard {
|
||||||
group: GroupEntity
|
group: GroupEntity
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'
|
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 */}
|
{/* Group Cover Image */}
|
||||||
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||||
{group.header && (
|
<GroupHeaderImage
|
||||||
<img
|
group={group}
|
||||||
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
||||||
src={group.header} alt={intl.formatMessage(messages.groupHeader)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Group Avatar */}
|
{/* Group Avatar */}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
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 { Button, Divider, HStack, Popover, Stack, Text } from 'soapbox/components/ui';
|
||||||
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||||
|
@ -26,6 +26,13 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
|
||||||
const { children, group, isEnabled } = props;
|
const { children, group, isEnabled } = props;
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const path = history.location.pathname;
|
||||||
|
const shouldHideAction = matchPath(path, {
|
||||||
|
path: ['/group/:groupSlug'],
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return children;
|
return children;
|
||||||
|
@ -36,7 +43,7 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
|
||||||
interaction='click'
|
interaction='click'
|
||||||
referenceElementClassName='cursor-pointer'
|
referenceElementClassName='cursor-pointer'
|
||||||
content={
|
content={
|
||||||
<Stack space={4} className='w-80'>
|
<Stack space={4} className='w-80 pb-4'>
|
||||||
<Stack
|
<Stack
|
||||||
className='relative h-60 rounded-lg bg-white dark:border-primary-800 dark:bg-primary-900'
|
className='relative h-60 rounded-lg bg-white dark:border-primary-800 dark:bg-primary-900'
|
||||||
data-testid='group-card'
|
data-testid='group-card'
|
||||||
|
@ -79,13 +86,15 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<div className='px-4 pb-4'>
|
{!shouldHideAction && (
|
||||||
<Link to={`/groups/${group.id}`}>
|
<div className='px-4'>
|
||||||
<Button type='button' theme='secondary' block>
|
<Link to={`/group/${group.slug}`}>
|
||||||
{intl.formatMessage(messages.action)}
|
<Button type='button' theme='secondary' block>
|
||||||
</Button>
|
{intl.formatMessage(messages.action)}
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
isFlush
|
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 (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={clsx({
|
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', {
|
||||||
'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,
|
|
||||||
'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',
|
'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}
|
{...linkProps}
|
||||||
>
|
>
|
||||||
<div className='flex flex-col py-1.5 pr-4 rtl:pl-4 rtl:pr-0'>
|
<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 ? (
|
{hint ? (
|
||||||
<span className='text-sm text-gray-700 dark:text-gray-600'>{hint}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{onClick ? (
|
{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}
|
{children}
|
||||||
|
|
||||||
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1 rtl:rotate-180' />
|
<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 = () => {
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -4,14 +4,22 @@ import { IntlProvider } from 'react-intl';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
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';
|
import PollFooter from '../poll-footer';
|
||||||
|
|
||||||
let poll = normalizePoll({
|
let poll: Poll = {
|
||||||
id: 1,
|
id: '1',
|
||||||
options: [{ title: 'Apples', votes_count: 0 }],
|
options: [{
|
||||||
|
title: 'Apples',
|
||||||
|
votes_count: 0,
|
||||||
|
title_emojified: 'Apples',
|
||||||
|
}, {
|
||||||
|
title: 'Oranges',
|
||||||
|
votes_count: 0,
|
||||||
|
title_emojified: 'Oranges',
|
||||||
|
}],
|
||||||
emojis: [],
|
emojis: [],
|
||||||
expired: false,
|
expired: false,
|
||||||
expires_at: '2020-03-24T19:33:06.000Z',
|
expires_at: '2020-03-24T19:33:06.000Z',
|
||||||
|
@ -20,7 +28,7 @@ let poll = normalizePoll({
|
||||||
votes_count: 0,
|
votes_count: 0,
|
||||||
own_votes: null,
|
own_votes: null,
|
||||||
voted: false,
|
voted: false,
|
||||||
});
|
};
|
||||||
|
|
||||||
describe('<PollFooter />', () => {
|
describe('<PollFooter />', () => {
|
||||||
describe('with "showResults" enabled', () => {
|
describe('with "showResults" enabled', () => {
|
||||||
|
@ -62,10 +70,10 @@ describe('<PollFooter />', () => {
|
||||||
|
|
||||||
describe('when the Poll has not expired', () => {
|
describe('when the Poll has not expired', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
poll = normalizePoll({
|
poll = {
|
||||||
...poll.toJS(),
|
...poll,
|
||||||
expired: false,
|
expired: false,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders time remaining', () => {
|
it('renders time remaining', () => {
|
||||||
|
@ -77,10 +85,10 @@ describe('<PollFooter />', () => {
|
||||||
|
|
||||||
describe('when the Poll has expired', () => {
|
describe('when the Poll has expired', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
poll = normalizePoll({
|
poll = {
|
||||||
...poll.toJS(),
|
...poll,
|
||||||
expired: true,
|
expired: true,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders closed', () => {
|
it('renders closed', () => {
|
||||||
|
@ -100,10 +108,10 @@ describe('<PollFooter />', () => {
|
||||||
|
|
||||||
describe('when the Poll is multiple', () => {
|
describe('when the Poll is multiple', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
poll = normalizePoll({
|
poll = {
|
||||||
...poll.toJS(),
|
...poll,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the Vote button', () => {
|
it('renders the Vote button', () => {
|
||||||
|
@ -115,10 +123,10 @@ describe('<PollFooter />', () => {
|
||||||
|
|
||||||
describe('when the Poll is not multiple', () => {
|
describe('when the Poll is not multiple', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
poll = normalizePoll({
|
poll = {
|
||||||
...poll.toJS(),
|
...poll,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render the Vote button', () => {
|
it('does not render the Vote button', () => {
|
||||||
|
|
|
@ -40,21 +40,21 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
|
||||||
let votesCount = null;
|
let votesCount = null;
|
||||||
|
|
||||||
if (poll.voters_count !== null && poll.voters_count !== undefined) {
|
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 {
|
} 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 (
|
return (
|
||||||
<Stack space={4} data-testid='poll-footer'>
|
<Stack space={4} data-testid='poll-footer'>
|
||||||
{(!showResults && poll?.multiple) && (
|
{(!showResults && poll.multiple) && (
|
||||||
<Button onClick={handleVote} theme='primary' block>
|
<Button onClick={handleVote} theme='primary' block>
|
||||||
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
|
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HStack space={1.5} alignItems='center' wrap>
|
<HStack space={1.5} alignItems='center' wrap>
|
||||||
{poll.pleroma.get('non_anonymous') && (
|
{poll.pleroma?.non_anonymous && (
|
||||||
<>
|
<>
|
||||||
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
|
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
|
||||||
<Text theme='muted' weight='medium'>
|
<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 pollVotesCount = poll.voters_count || poll.votes_count;
|
||||||
const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100;
|
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 voted = poll.own_votes?.includes(index);
|
||||||
const message = intl.formatMessage(messages.votes, { votes: option.votes_count });
|
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 (
|
return (
|
||||||
<div key={option.title}>
|
<div key={option.title}>
|
||||||
{showResults ? (
|
{showResults ? (
|
||||||
|
|
|
@ -106,7 +106,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||||
onMouseEnter={handleMouseEnter(dispatch)}
|
onMouseEnter={handleMouseEnter(dispatch)}
|
||||||
onMouseLeave={handleMouseLeave(dispatch)}
|
onMouseLeave={handleMouseLeave(dispatch)}
|
||||||
>
|
>
|
||||||
<Card variant='rounded' className='relative isolate'>
|
<Card variant='rounded' className='relative isolate overflow-hidden'>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
<BundleContainer fetchComponent={UserPanel}>
|
<BundleContainer fetchComponent={UserPanel}>
|
||||||
|
|
|
@ -7,18 +7,20 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
||||||
import { launchChat } from 'soapbox/actions/chats';
|
import { launchChat } from 'soapbox/actions/chats';
|
||||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||||
import { editEvent } from 'soapbox/actions/events';
|
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 { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||||
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
|
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
|
||||||
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
|
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 DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||||
import StatusActionButton from 'soapbox/components/status-action-button';
|
import StatusActionButton from 'soapbox/components/status-action-button';
|
||||||
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
|
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
|
||||||
import { HStack } from 'soapbox/components/ui';
|
import { HStack } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
import { isLocal, isRemote } from 'soapbox/utils/accounts';
|
import { isLocal, isRemote } from 'soapbox/utils/accounts';
|
||||||
import copy from 'soapbox/utils/copy';
|
import copy from 'soapbox/utils/copy';
|
||||||
|
@ -87,16 +89,7 @@ const messages = defineMessages({
|
||||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
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' },
|
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' },
|
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?' },
|
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 {
|
interface IStatusActionBar {
|
||||||
|
@ -121,6 +114,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
const deleteGroupStatus = useDeleteGroupStatus(status?.group as Group, status.id);
|
||||||
|
|
||||||
const { allowedEmoji } = soapboxConfig;
|
const { allowedEmoji } = soapboxConfig;
|
||||||
|
|
||||||
|
@ -258,8 +252,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
|
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
icon: require('@tabler/icons/ban.svg'),
|
icon: require('@tabler/icons/ban.svg'),
|
||||||
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.get('acct') }} />,
|
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>@{account.get('acct')}</strong> }} />,
|
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),
|
confirm: intl.formatMessage(messages.blockConfirm),
|
||||||
onConfirm: () => dispatch(blockAccount(account.id)),
|
onConfirm: () => dispatch(blockAccount(account.id)),
|
||||||
secondary: intl.formatMessage(messages.blockAndReport),
|
secondary: intl.formatMessage(messages.blockAndReport),
|
||||||
|
@ -313,31 +307,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
|
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
heading: intl.formatMessage(messages.deleteHeading),
|
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),
|
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||||
onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)),
|
onConfirm: () => {
|
||||||
}));
|
deleteGroupStatus.mutate(status.id, {
|
||||||
};
|
onSuccess() {
|
||||||
|
dispatch(deleteFromTimelines(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)),
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -362,7 +340,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.copy),
|
text: intl.formatMessage(messages.copy),
|
||||||
action: handleCopy,
|
action: handleCopy,
|
||||||
icon: require('@tabler/icons/link.svg'),
|
icon: require('@tabler/icons/clipboard-copy.svg'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (features.embeds && isLocal(account)) {
|
if (features.embeds && isLocal(account)) {
|
||||||
|
@ -466,7 +444,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.mute, { name: username }),
|
text: intl.formatMessage(messages.mute, { name: username }),
|
||||||
action: handleMuteClick,
|
action: handleMuteClick,
|
||||||
icon: require('@tabler/icons/circle-x.svg'),
|
icon: require('@tabler/icons/volume-3.svg'),
|
||||||
});
|
});
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.block, { name: username }),
|
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(null);
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.groupModDelete),
|
text: intl.formatMessage(messages.groupModDelete),
|
||||||
action: handleDeleteFromGroup,
|
action: handleDeleteFromGroup,
|
||||||
icon: require('@tabler/icons/trash.svg'),
|
icon: require('@tabler/icons/trash.svg'),
|
||||||
});
|
destructive: true,
|
||||||
// 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'),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -536,7 +508,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
return menu;
|
return menu;
|
||||||
};
|
};
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.visibility);
|
const publicStatus = ['public', 'unlisted', 'group'].includes(status.visibility);
|
||||||
|
|
||||||
const replyCount = status.replies_count;
|
const replyCount = status.replies_count;
|
||||||
const reblogCount = status.reblogs_count;
|
const reblogCount = status.reblogs_count;
|
||||||
|
@ -609,7 +581,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
replyTitle = intl.formatMessage(messages.replyAll);
|
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 (
|
return (
|
||||||
<HStack data-testid='status-action-bar'>
|
<HStack data-testid='status-action-bar'>
|
||||||
|
|
|
@ -53,7 +53,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
||||||
src={icon}
|
src={icon}
|
||||||
className={clsx(
|
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,
|
iconClassName,
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -139,6 +139,7 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
onMoveDown={handleMoveDown}
|
onMoveDown={handleMoveDown}
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
showGroup={showGroup}
|
showGroup={showGroup}
|
||||||
|
variant={divideType === 'border' ? 'slim' : 'rounded'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -172,6 +173,7 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
onMoveDown={handleMoveDown}
|
onMoveDown={handleMoveDown}
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
showGroup={showGroup}
|
showGroup={showGroup}
|
||||||
|
variant={divideType === 'border' ? 'slim' : 'default'}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
@ -245,7 +247,7 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={isLoading && statusIds.size === 0}
|
showLoading={isLoading && statusIds.size === 0}
|
||||||
onLoadMore={handleLoadOlder}
|
onLoadMore={handleLoadOlder}
|
||||||
placeholderComponent={PlaceholderStatus}
|
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
|
||||||
placeholderCount={20}
|
placeholderCount={20}
|
||||||
ref={node}
|
ref={node}
|
||||||
className={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
|
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 { openModal } from 'soapbox/actions/modals';
|
||||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
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 PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
|
||||||
import Card from 'soapbox/features/status/components/card';
|
import Card from 'soapbox/features/status/components/card';
|
||||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||||
|
@ -153,6 +154,10 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
||||||
</Bundle>
|
</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) {
|
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
||||||
media = (
|
media = (
|
||||||
<Card
|
<Card
|
||||||
|
|
|
@ -54,7 +54,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
|
||||||
<Link
|
<Link
|
||||||
key={account.id}
|
key={account.id}
|
||||||
to={`/@${account.acct}`}
|
to={`/@${account.acct}`}
|
||||||
className='reply-mentions__account'
|
className='reply-mentions__account max-w-[200px] truncate align-bottom'
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@{isPubkey(account.username) ? account.username.slice(0, 8) : account.username}
|
@{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 React, { useEffect, useRef, useState } from 'react';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
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 { mentionCompose, replyCompose } from 'soapbox/actions/compose';
|
||||||
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
|
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses';
|
import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses';
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import TranslateButton from 'soapbox/components/translate-button';
|
import TranslateButton from 'soapbox/components/translate-button';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
import AccountContainer from 'soapbox/containers/account-container';
|
||||||
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-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 StatusReplyMentions from './status-reply-mentions';
|
||||||
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
|
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
|
||||||
import StatusInfo from './statuses/status-info';
|
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 {
|
import type {
|
||||||
Account as AccountEntity,
|
Account as AccountEntity,
|
||||||
|
@ -51,7 +51,7 @@ export interface IStatus {
|
||||||
featured?: boolean
|
featured?: boolean
|
||||||
hideActionBar?: boolean
|
hideActionBar?: boolean
|
||||||
hoverable?: boolean
|
hoverable?: boolean
|
||||||
variant?: 'default' | 'rounded'
|
variant?: 'default' | 'rounded' | 'slim'
|
||||||
showGroup?: boolean
|
showGroup?: boolean
|
||||||
accountAction?: React.ReactElement
|
accountAction?: React.ReactElement
|
||||||
}
|
}
|
||||||
|
@ -212,26 +212,67 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStatusInfo = () => {
|
const renderStatusInfo = () => {
|
||||||
if (isReblog) {
|
if (isReblog && showGroup && group) {
|
||||||
return (
|
return (
|
||||||
<StatusInfo
|
<StatusInfo
|
||||||
avatarSize={avatarSize}
|
avatarSize={avatarSize}
|
||||||
to={`/@${status.getIn(['account', 'acct'])}`}
|
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='h-4 w-4 text-green-600' />}
|
||||||
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='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={
|
text={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='status.reblogged_by'
|
id='status.reblogged_by'
|
||||||
defaultMessage='{name} reposted'
|
defaultMessage='{name} reposted'
|
||||||
values={{
|
values={{
|
||||||
name: (
|
name: (
|
||||||
<bdi className='truncate pr-1 rtl:pl-1'>
|
<Link to={`/@${status.getIn(['account', 'acct'])}`} className='hover:underline'>
|
||||||
<strong
|
<bdi className='truncate'>
|
||||||
className='text-gray-800 dark:text-gray-200'
|
<strong
|
||||||
dangerouslySetInnerHTML={{
|
className='text-gray-800 dark:text-gray-200'
|
||||||
__html: String(status.getIn(['account', 'display_name_html'])),
|
dangerouslySetInnerHTML={{
|
||||||
}}
|
__html: String(status.getIn(['account', 'display_name_html'])),
|
||||||
/>
|
}}
|
||||||
</bdi>
|
/>
|
||||||
|
</bdi>
|
||||||
|
</Link>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -242,11 +283,9 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
return (
|
return (
|
||||||
<StatusInfo
|
<StatusInfo
|
||||||
avatarSize={avatarSize}
|
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={
|
||||||
<Text size='xs' theme='muted' weight='medium'>
|
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
||||||
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
|
||||||
</Text>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -254,18 +293,23 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
return (
|
return (
|
||||||
<StatusInfo
|
<StatusInfo
|
||||||
avatarSize={avatarSize}
|
avatarSize={avatarSize}
|
||||||
to={`/groups/${group.id}`}
|
icon={<Icon src={require('@tabler/icons/circles.svg')} className='h-4 w-4 text-primary-600 dark:text-accent-blue' />}
|
||||||
icon={<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />}
|
|
||||||
text={
|
text={
|
||||||
<Text size='xs' theme='muted' weight='medium'>
|
<FormattedMessage
|
||||||
<FormattedMessage
|
id='status.group'
|
||||||
id='status.group'
|
defaultMessage='Posted in {group}'
|
||||||
defaultMessage='Posted in {group}'
|
values={{
|
||||||
values={{ group: (
|
group: (
|
||||||
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
<Link to={`/group/${group.slug}`} className='hover:underline'>
|
||||||
) }}
|
<bdi className='truncate'>
|
||||||
/>
|
<strong className='text-gray-800 dark:text-gray-200'>
|
||||||
</Text>
|
<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 isUnderReview = actualStatus.visibility === 'self';
|
||||||
const isSensitive = actualStatus.hidden;
|
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 (
|
return (
|
||||||
<HotKeys handlers={handlers} data-testid='status'>
|
<HotKeys handlers={handlers} data-testid='status'>
|
||||||
|
|
|
@ -1,37 +1,44 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
import { HStack, Text } from '../ui';
|
||||||
|
|
||||||
interface IStatusInfo {
|
interface IStatusInfo {
|
||||||
avatarSize: number
|
avatarSize: number
|
||||||
to?: string
|
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
text: React.ReactNode
|
text: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusInfo = (props: IStatusInfo) => {
|
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();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const Container = to ? Link : 'div';
|
|
||||||
const containerProps: any = to ? { onClick, to } : {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
{...containerProps}
|
<div
|
||||||
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/aria-role
|
||||||
|
role='status-info'
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div
|
<HStack
|
||||||
className='flex justify-end'
|
space={3}
|
||||||
style={{ width: avatarSize }}
|
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}
|
<Text size='xs' theme='muted' weight='medium'>
|
||||||
</Container>
|
{text}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import React, { useRef } from 'react';
|
||||||
|
|
||||||
import { useSettings } from 'soapbox/hooks';
|
import { useSettings } from 'soapbox/hooks';
|
||||||
|
|
||||||
interface IStillImage {
|
export interface IStillImage {
|
||||||
/** Image alt text. */
|
/** Image alt text. */
|
||||||
alt?: string
|
alt?: string
|
||||||
/** Extra class names for the outer <div> container. */
|
/** Extra class names for the outer <div> container. */
|
||||||
|
@ -16,10 +16,12 @@ interface IStillImage {
|
||||||
letterboxed?: boolean
|
letterboxed?: boolean
|
||||||
/** Whether to show the file extension in the corner. */
|
/** Whether to show the file extension in the corner. */
|
||||||
showExt?: boolean
|
showExt?: boolean
|
||||||
|
/** Callback function if the image fails to load */
|
||||||
|
onError?(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
|
/** 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 settings = useSettings();
|
||||||
const autoPlayGif = settings.get('autoPlayGif');
|
const autoPlayGif = settings.get('autoPlayGif');
|
||||||
|
|
||||||
|
@ -55,6 +57,7 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterb
|
||||||
alt={alt}
|
alt={alt}
|
||||||
ref={img}
|
ref={img}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
|
onError={onError}
|
||||||
className={clsx(baseClassName, {
|
className={clsx(baseClassName, {
|
||||||
'invisible group-hover:visible': hoverToPlay,
|
'invisible group-hover:visible': hoverToPlay,
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -19,10 +19,17 @@ const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<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}>
|
<div className='h-16'>
|
||||||
<Text>
|
<div
|
||||||
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />
|
className='focusable flex h-[42px] items-center justify-center rounded-lg border-2 border-gray-200 text-center'
|
||||||
</Text>
|
>
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='statuses.tombstone'
|
||||||
|
defaultMessage='One or more posts are unavailable.'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
|
|
|
@ -21,13 +21,16 @@ interface IAccordion {
|
||||||
menu?: Menu
|
menu?: Menu
|
||||||
expanded?: boolean
|
expanded?: boolean
|
||||||
onToggle?: (value: boolean) => void
|
onToggle?: (value: boolean) => void
|
||||||
|
action?: () => void
|
||||||
|
actionIcon?: string
|
||||||
|
actionLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accordion
|
* Accordion
|
||||||
* An accordion is a vertically stacked group of collapsible sections.
|
* 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 intl = useIntl();
|
||||||
|
|
||||||
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
@ -35,6 +38,13 @@ const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded =
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAction = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
action();
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='rounded-lg bg-white text-gray-900 shadow dark:bg-primary-800 dark:text-gray-100 dark:shadow-none'>
|
<div className='rounded-lg bg-white text-gray-900 shadow dark:bg-primary-800 dark:text-gray-100 dark:shadow-none'>
|
||||||
<button
|
<button
|
||||||
|
@ -53,6 +63,14 @@ const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded =
|
||||||
src={require('@tabler/icons/dots-vertical.svg')}
|
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
|
<Icon
|
||||||
src={expanded ? require('@tabler/icons/chevron-up.svg') : require('@tabler/icons/chevron-down.svg')}
|
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'
|
className='h-5 w-5 text-gray-700 dark:text-gray-600'
|
||||||
|
|
|
@ -1,34 +1,54 @@
|
||||||
import clsx from 'clsx';
|
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;
|
const AVATAR_SIZE = 42;
|
||||||
|
|
||||||
interface IAvatar {
|
interface IAvatar extends Pick<IStillImage, 'src' | 'onError' | 'className'> {
|
||||||
/** URL to the avatar image. */
|
|
||||||
src: string
|
|
||||||
/** Width and height of the avatar in pixels. */
|
/** Width and height of the avatar in pixels. */
|
||||||
size?: number
|
size?: number
|
||||||
/** Extra class names for the div surrounding the avatar image. */
|
|
||||||
className?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Round profile avatar for accounts. */
|
/** Round profile avatar for accounts. */
|
||||||
const Avatar = (props: IAvatar) => {
|
const Avatar = (props: IAvatar) => {
|
||||||
const { src, size = AVATAR_SIZE, className } = props;
|
const { src, size = AVATAR_SIZE, className } = props;
|
||||||
|
|
||||||
|
const [isAvatarMissing, setIsAvatarMissing] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleLoadFailure = () => setIsAvatarMissing(true);
|
||||||
|
|
||||||
const style: React.CSSProperties = React.useMemo(() => ({
|
const style: React.CSSProperties = React.useMemo(() => ({
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
}), [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 (
|
return (
|
||||||
<StillImage
|
<StillImage
|
||||||
className={clsx('rounded-full', className)}
|
className={clsx('rounded-full', className)}
|
||||||
style={style}
|
style={style}
|
||||||
src={src}
|
src={src}
|
||||||
alt='Avatar'
|
alt='Avatar'
|
||||||
|
onError={handleLoadFailure}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -16,11 +16,13 @@ const messages = defineMessages({
|
||||||
back: { id: 'card.back.label', defaultMessage: 'Back' },
|
back: { id: 'card.back.label', defaultMessage: 'Back' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type CardSizes = keyof typeof sizes
|
||||||
|
|
||||||
interface ICard {
|
interface ICard {
|
||||||
/** The type of card. */
|
/** The type of card. */
|
||||||
variant?: 'default' | 'rounded'
|
variant?: 'default' | 'rounded' | 'slim'
|
||||||
/** Card size preset. */
|
/** Card size preset. */
|
||||||
size?: keyof typeof sizes
|
size?: CardSizes
|
||||||
/** Extra classnames for the <div> element. */
|
/** Extra classnames for the <div> element. */
|
||||||
className?: string
|
className?: string
|
||||||
/** Elements inside the card. */
|
/** Elements inside the card. */
|
||||||
|
@ -33,8 +35,9 @@ const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant = 'def
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...filteredProps}
|
{...filteredProps}
|
||||||
className={clsx({
|
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',
|
[sizes[size]]: variant === 'rounded',
|
||||||
|
'py-4': variant === 'slim',
|
||||||
}, className)}
|
}, className)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -72,7 +75,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack alignItems='center' space={2} className={clsx('mb-4', className)}>
|
<HStack alignItems='center' space={2} className={className}>
|
||||||
{renderBackButton()}
|
{renderBackButton()}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import clsx from 'clsx';
|
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 { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import Helmet from 'soapbox/components/helmet';
|
import Helmet from 'soapbox/components/helmet';
|
||||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
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'>;
|
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'action'>;
|
||||||
|
|
||||||
|
@ -50,17 +51,35 @@ export interface IColumn {
|
||||||
withHeader?: boolean
|
withHeader?: boolean
|
||||||
/** Extra class name for top <div> element. */
|
/** Extra class name for top <div> element. */
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Extra class name for the <CardBody> element. */
|
||||||
|
bodyClassName?: string
|
||||||
/** Ref forwarded to column. */
|
/** Ref forwarded to column. */
|
||||||
ref?: React.Ref<HTMLDivElement>
|
ref?: React.Ref<HTMLDivElement>
|
||||||
/** Children to display in the column. */
|
/** Children to display in the column. */
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
/** Action for the ColumnHeader, displayed at the end. */
|
||||||
action?: React.ReactNode
|
action?: React.ReactNode
|
||||||
|
/** Column size, inherited from Card. */
|
||||||
|
size?: CardSizes
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A backdrop for the main section of the UI. */
|
/** A backdrop for the main section of the UI. */
|
||||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
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 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 (
|
return (
|
||||||
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
|
<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>
|
</Helmet>
|
||||||
|
|
||||||
<Card variant={transparent ? undefined : 'rounded'} className={className}>
|
<Card size={size} variant={transparent ? undefined : 'rounded'} className={className}>
|
||||||
{withHeader && (
|
{withHeader && (
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
label={label}
|
label={label}
|
||||||
backHref={backHref}
|
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}
|
action={action}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardBody>
|
<CardBody className={bodyClassName}>
|
||||||
{children}
|
{children}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -11,24 +11,22 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
src: string
|
src: string
|
||||||
/** Text to display next ot the button. */
|
/** Text to display next ot the button. */
|
||||||
text?: string
|
text?: string
|
||||||
/** Don't render a background behind the icon. */
|
|
||||||
transparent?: boolean
|
|
||||||
/** Predefined styles to display for the button. */
|
/** Predefined styles to display for the button. */
|
||||||
theme?: 'seamless' | 'outlined' | 'secondary'
|
theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent'
|
||||||
/** Override the data-testid */
|
/** Override the data-testid */
|
||||||
'data-testid'?: string
|
'data-testid'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A clickable icon. */
|
/** A clickable icon. */
|
||||||
const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type='button'
|
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', {
|
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 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',
|
'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,
|
'opacity-50': filteredProps.disabled,
|
||||||
|
|
|
@ -17,11 +17,16 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
||||||
src: string
|
src: string
|
||||||
/** Width and height of the icon in pixels. */
|
/** Width and height of the icon in pixels. */
|
||||||
size?: number
|
size?: number
|
||||||
|
/** Override the data-testid */
|
||||||
|
'data-testid'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders and SVG icon with optional counter. */
|
/** Renders and SVG icon with optional counter. */
|
||||||
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
|
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 ? (
|
{count ? (
|
||||||
<span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'>
|
<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} />
|
<Counter count={count} countMax={countMax} />
|
||||||
|
|
|
@ -55,10 +55,11 @@ interface IModal {
|
||||||
title?: React.ReactNode
|
title?: React.ReactNode
|
||||||
width?: keyof typeof widths
|
width?: keyof typeof widths
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Displays a modal dialog box. */
|
/** Displays a modal dialog box. */
|
||||||
const Modal: React.FC<IModal> = ({
|
const Modal = React.forwardRef<HTMLDivElement, IModal>(({
|
||||||
cancelAction,
|
cancelAction,
|
||||||
cancelText,
|
cancelText,
|
||||||
children,
|
children,
|
||||||
|
@ -76,7 +77,8 @@ const Modal: React.FC<IModal> = ({
|
||||||
skipFocus = false,
|
skipFocus = false,
|
||||||
title,
|
title,
|
||||||
width = 'xl',
|
width = 'xl',
|
||||||
}) => {
|
className,
|
||||||
|
}, ref) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
@ -87,7 +89,11 @@ const Modal: React.FC<IModal> = ({
|
||||||
}, [skipFocus, buttonRef]);
|
}, [skipFocus, buttonRef]);
|
||||||
|
|
||||||
return (
|
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 justify-between sm:flex sm:items-start'>
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
{title && (
|
{title && (
|
||||||
|
@ -96,7 +102,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
'flex-row-reverse': closePosition === 'left',
|
'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}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
@ -157,6 +163,6 @@ const Modal: React.FC<IModal> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default Modal;
|
export default Modal;
|
||||||
|
|
|
@ -16,6 +16,7 @@ const messages = defineMessages({
|
||||||
export type StreamfieldComponent<T> = React.ComponentType<{
|
export type StreamfieldComponent<T> = React.ComponentType<{
|
||||||
value: T
|
value: T
|
||||||
onChange: (value: T) => void
|
onChange: (value: T) => void
|
||||||
|
autoFocus: boolean
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
interface IStreamfield {
|
interface IStreamfield {
|
||||||
|
@ -69,14 +70,19 @@ const Streamfield: React.FC<IStreamfield> = ({
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{(values.length > 0) && (
|
{(values.length > 0) && (
|
||||||
<Stack>
|
<Stack space={1}>
|
||||||
{values.map((value, i) => value?._destroy ? null : (
|
{values.map((value, i) => value?._destroy ? null : (
|
||||||
<HStack space={2} alignItems='center'>
|
<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 && (
|
{values.length > minItems && onRemoveItem && (
|
||||||
<IconButton
|
<IconButton
|
||||||
iconClassName='h-4 w-4'
|
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')}
|
src={require('@tabler/icons/x.svg')}
|
||||||
onClick={() => onRemoveItem(i)}
|
onClick={() => onRemoveItem(i)}
|
||||||
title={intl.formatMessage(messages.remove)}
|
title={intl.formatMessage(messages.remove)}
|
||||||
|
@ -87,11 +93,9 @@ const Streamfield: React.FC<IStreamfield> = ({
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onAddItem && (
|
{(onAddItem && (values.length < maxItems)) && (
|
||||||
<Button
|
<Button
|
||||||
icon={require('@tabler/icons/plus.svg')}
|
|
||||||
onClick={onAddItem}
|
onClick={onAddItem}
|
||||||
disabled={values.length >= maxItems}
|
|
||||||
theme='secondary'
|
theme='secondary'
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
|
|
|
@ -20,7 +20,6 @@ const Tag: React.FC<ITag> = ({ tag, onDelete }) => {
|
||||||
iconClassName='h-4 w-4'
|
iconClassName='h-4 w-4'
|
||||||
src={require('@tabler/icons/x.svg')}
|
src={require('@tabler/icons/x.svg')}
|
||||||
onClick={() => onDelete(tag)}
|
onClick={() => onDelete(tag)}
|
||||||
transparent
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { ToastText, ToastType } from 'soapbox/toast';
|
||||||
|
|
||||||
import HStack from '../hstack/hstack';
|
import HStack from '../hstack/hstack';
|
||||||
import Icon from '../icon/icon';
|
import Icon from '../icon/icon';
|
||||||
|
import Stack from '../stack/stack';
|
||||||
|
import Text from '../text/text';
|
||||||
|
|
||||||
const renderText = (text: ToastText) => {
|
const renderText = (text: ToastText) => {
|
||||||
if (typeof text === 'string') {
|
if (typeof text === 'string') {
|
||||||
|
@ -24,13 +26,14 @@ interface IToast {
|
||||||
action?(): void
|
action?(): void
|
||||||
actionLink?: string
|
actionLink?: string
|
||||||
actionLabel?: ToastText
|
actionLabel?: ToastText
|
||||||
|
summary?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customizable Toasts for in-app notifications.
|
* Customizable Toasts for in-app notifications.
|
||||||
*/
|
*/
|
||||||
const Toast = (props: IToast) => {
|
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);
|
const dismissToast = () => toast.dismiss(t.id);
|
||||||
|
|
||||||
|
@ -109,35 +112,46 @@ const Toast = (props: IToast) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HStack space={4} alignItems='start'>
|
<Stack space={2}>
|
||||||
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
|
<HStack space={4} alignItems='start'>
|
||||||
<HStack space={3} alignItems='start' className='w-0 flex-1'>
|
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
|
||||||
<div className='shrink-0'>
|
<HStack space={3} alignItems='start' className='w-0 flex-1'>
|
||||||
{renderIcon()}
|
<div className='shrink-0'>
|
||||||
</div>
|
{renderIcon()}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className='pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'>
|
<Text
|
||||||
{renderText(message)}
|
size='sm'
|
||||||
</p>
|
data-testid='toast-message'
|
||||||
|
className='pt-0.5'
|
||||||
|
weight={typeof summary === 'undefined' ? 'normal' : 'medium'}
|
||||||
|
>
|
||||||
|
{renderText(message)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{renderAction()}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* Action */}
|
{/* Dismiss Button */}
|
||||||
{renderAction()}
|
<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>
|
</HStack>
|
||||||
|
|
||||||
{/* Dismiss Button */}
|
{summary ? (
|
||||||
<div className='flex shrink-0 pt-0.5'>
|
<Text theme='muted' size='sm'>{summary}</Text>
|
||||||
<button
|
) : null}
|
||||||
type='button'
|
</Stack>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -26,6 +26,7 @@ const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked, onChange, r
|
||||||
'cursor-default': disabled,
|
'cursor-default': disabled,
|
||||||
})}
|
})}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
<div className={clsx('rounded-full bg-white transition-transform', {
|
<div className={clsx('rounded-full bg-white transition-transform', {
|
||||||
'h-4.5 w-4.5': size === 'sm',
|
'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 {
|
||||||
import React from 'react';
|
arrow,
|
||||||
|
FloatingArrow,
|
||||||
import Portal from '../portal/portal';
|
FloatingPortal,
|
||||||
|
offset,
|
||||||
import './tooltip.css';
|
useFloating,
|
||||||
|
useHover,
|
||||||
|
useInteractions,
|
||||||
|
useTransitionStyles,
|
||||||
|
} from '@floating-ui/react';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
|
||||||
interface ITooltip {
|
interface ITooltip {
|
||||||
|
/** Element to display the tooltip around. */
|
||||||
|
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||||
/** Text to display in the tooltip. */
|
/** Text to display in the tooltip. */
|
||||||
text: string
|
text: string
|
||||||
/** Element to display the tooltip around. */
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const centered = (triggerRect: any, tooltipRect: any) => {
|
/**
|
||||||
const triggerCenter = triggerRect.left + triggerRect.width / 2;
|
* Tooltip
|
||||||
const left = triggerCenter - tooltipRect.width / 2;
|
*/
|
||||||
const maxLeft = window.innerWidth - tooltipRect.width - 2;
|
const Tooltip: React.FC<ITooltip> = (props) => {
|
||||||
return {
|
const { children, text } = props;
|
||||||
left: Math.min(Math.max(2, left), maxLeft) + window.scrollX,
|
|
||||||
top: triggerRect.bottom + 8 + window.scrollY,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Hoverable tooltip element. */
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
const Tooltip: React.FC<ITooltip> = ({
|
|
||||||
children,
|
|
||||||
text,
|
|
||||||
}) => {
|
|
||||||
// get the props from useTooltip
|
|
||||||
const [trigger, tooltip] = useTooltip();
|
|
||||||
|
|
||||||
// destructure off what we need to position the triangle
|
const arrowRef = useRef<SVGSVGElement>(null);
|
||||||
const { isVisible, triggerRect } = tooltip;
|
|
||||||
|
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 (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
{React.cloneElement(children as any, trigger)}
|
{React.cloneElement(children, {
|
||||||
|
ref: refs.setReference,
|
||||||
|
...getReferenceProps(),
|
||||||
|
})}
|
||||||
|
|
||||||
{isVisible && (
|
{(isMounted) && (
|
||||||
// The Triangle. We position it relative to the trigger, not the popup
|
<FloatingPortal>
|
||||||
// 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>
|
|
||||||
<div
|
<div
|
||||||
data-reach-tooltip-arrow='true'
|
ref={refs.setFloating}
|
||||||
style={{
|
style={{
|
||||||
left:
|
position: strategy,
|
||||||
triggerRect && triggerRect.left - 10 + triggerRect.width / 2 as any,
|
top: y ?? 0,
|
||||||
top: triggerRect && triggerRect.bottom + window.scrollY as any,
|
left: x ?? 0,
|
||||||
|
...styles,
|
||||||
}}
|
}}
|
||||||
/>
|
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'
|
||||||
</Portal>
|
{...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>
|
</BundleContainer>
|
||||||
|
|
||||||
<GdprBanner />
|
<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>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
|
|
|
@ -110,7 +110,7 @@ test('import entities with override', () => {
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', {
|
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', 'end', {
|
||||||
next: undefined,
|
next: undefined,
|
||||||
prev: undefined,
|
prev: undefined,
|
||||||
totalCount: 2,
|
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_IMPORT = 'ENTITIES_IMPORT' as const;
|
||||||
const ENTITIES_DELETE = 'ENTITIES_DELETE' 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;
|
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
|
||||||
|
|
||||||
/** Action to import entities into the cache. */
|
/** 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 {
|
return {
|
||||||
type: ENTITIES_IMPORT,
|
type: ENTITIES_IMPORT,
|
||||||
entityType,
|
entityType,
|
||||||
entities,
|
entities,
|
||||||
listKey,
|
listKey,
|
||||||
|
pos,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,6 +63,7 @@ function entitiesFetchSuccess(
|
||||||
entities: Entity[],
|
entities: Entity[],
|
||||||
entityType: string,
|
entityType: string,
|
||||||
listKey?: string,
|
listKey?: string,
|
||||||
|
pos?: ImportPosition,
|
||||||
newState?: EntityListState,
|
newState?: EntityListState,
|
||||||
overwrite = false,
|
overwrite = false,
|
||||||
) {
|
) {
|
||||||
|
@ -70,6 +72,7 @@ function entitiesFetchSuccess(
|
||||||
entityType,
|
entityType,
|
||||||
entities,
|
entities,
|
||||||
listKey,
|
listKey,
|
||||||
|
pos,
|
||||||
newState,
|
newState,
|
||||||
overwrite,
|
overwrite,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
export enum Entities {
|
export enum Entities {
|
||||||
ACCOUNTS = 'Accounts',
|
ACCOUNTS = 'Accounts',
|
||||||
GROUPS = 'Groups',
|
GROUPS = 'Groups',
|
||||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
|
||||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||||
|
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||||
|
GROUP_TAGS = 'GroupTags',
|
||||||
|
RELATIONSHIPS = 'Relationships',
|
||||||
|
STATUSES = 'Statuses'
|
||||||
}
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
export { useEntities } from './useEntities';
|
export { useEntities } from './useEntities';
|
||||||
export { useEntity } from './useEntity';
|
export { useEntity } from './useEntity';
|
||||||
export { useEntityActions } from './useEntityActions';
|
export { useEntityActions } from './useEntityActions';
|
||||||
|
export { useEntityLookup } from './useEntityLookup';
|
||||||
export { useCreateEntity } from './useCreateEntity';
|
export { useCreateEntity } from './useCreateEntity';
|
||||||
export { useDeleteEntity } from './useDeleteEntity';
|
export { useDeleteEntity } from './useDeleteEntity';
|
||||||
export { useDismissEntity } from './useDismissEntity';
|
export { useDismissEntity } from './useDismissEntity';
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useAppDispatch, useLoading } from 'soapbox/hooks';
|
import { useAppDispatch, useLoading } from 'soapbox/hooks';
|
||||||
|
@ -20,31 +21,35 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
|
||||||
) {
|
) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [isLoading, setPromise] = useLoading();
|
const [isSubmitting, setPromise] = useLoading();
|
||||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
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 {
|
try {
|
||||||
const result = await setPromise(entityFn(data));
|
const result = await setPromise(entityFn(data));
|
||||||
const schema = opts.schema || z.custom<TEntity>();
|
const schema = opts.schema || z.custom<TEntity>();
|
||||||
const entity = schema.parse(result.data);
|
const entity = schema.parse(result.data);
|
||||||
|
|
||||||
// TODO: optimistic updating
|
// TODO: optimistic updating
|
||||||
dispatch(importEntities([entity], entityType, listKey));
|
dispatch(importEntities([entity], entityType, listKey, 'start'));
|
||||||
|
|
||||||
if (callbacks.onSuccess) {
|
if (callbacks.onSuccess) {
|
||||||
callbacks.onSuccess(entity);
|
callbacks.onSuccess(entity);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (callbacks.onError) {
|
if (error instanceof AxiosError) {
|
||||||
callbacks.onError(error);
|
if (callbacks.onError) {
|
||||||
|
callbacks.onError(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createEntity,
|
createEntity,
|
||||||
isLoading,
|
isSubmitting,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ function useDeleteEntity(
|
||||||
) {
|
) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const getState = useGetState();
|
const getState = useGetState();
|
||||||
const [isLoading, setPromise] = useLoading();
|
const [isSubmitting, setPromise] = useLoading();
|
||||||
|
|
||||||
async function deleteEntity(entityId: string, callbacks: EntityCallbacks<string> = {}): Promise<void> {
|
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.
|
// Get the entity before deleting, so we can reverse the action if the API request fails.
|
||||||
|
@ -47,7 +47,7 @@ function useDeleteEntity(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deleteEntity,
|
deleteEntity,
|
||||||
isLoading,
|
isSubmitting,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,7 @@ function useEntities<TEntity extends Entity>(
|
||||||
const next = useListState(path, 'next');
|
const next = useListState(path, 'next');
|
||||||
const prev = useListState(path, 'prev');
|
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.
|
// Get `isFetching` state from the store again to prevent race conditions.
|
||||||
const isFetching = selectListState(getState(), path, 'fetching');
|
const isFetching = selectListState(getState(), path, 'fetching');
|
||||||
if (isFetching) return;
|
if (isFetching) return;
|
||||||
|
@ -65,11 +65,12 @@ function useEntities<TEntity extends Entity>(
|
||||||
const schema = opts.schema || z.custom<TEntity>();
|
const schema = opts.schema || z.custom<TEntity>();
|
||||||
const entities = filteredArray(schema).parse(response.data);
|
const entities = filteredArray(schema).parse(response.data);
|
||||||
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
|
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),
|
next: getNextLink(response),
|
||||||
prev: getPrevLink(response),
|
prev: getPrevLink(response),
|
||||||
totalCount: parsedCount.success ? parsedCount.data : undefined,
|
totalCount: Number(totalCount) >= entities.length ? totalCount : undefined,
|
||||||
fetching: false,
|
fetching: false,
|
||||||
fetched: true,
|
fetched: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
@ -82,18 +83,18 @@ function useEntities<TEntity extends Entity>(
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchEntities = async(): Promise<void> => {
|
const fetchEntities = async(): Promise<void> => {
|
||||||
await fetchPage(entityFn, true);
|
await fetchPage(entityFn, 'end', true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchNextPage = async(): Promise<void> => {
|
const fetchNextPage = async(): Promise<void> => {
|
||||||
if (next) {
|
if (next) {
|
||||||
await fetchPage(() => api.get(next));
|
await fetchPage(() => api.get(next), 'end');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPreviousPage = async(): Promise<void> => {
|
const fetchPreviousPage = async(): Promise<void> => {
|
||||||
if (prev) {
|
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) {
|
if (isInvalid || isUnset || isStale) {
|
||||||
fetchEntities();
|
fetchEntities();
|
||||||
}
|
}
|
||||||
}, [isEnabled]);
|
}, [isEnabled, ...path]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entities,
|
entities,
|
||||||
|
|
|
@ -14,6 +14,8 @@ interface UseEntityOpts<TEntity extends Entity> {
|
||||||
schema?: EntitySchema<TEntity>
|
schema?: EntitySchema<TEntity>
|
||||||
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
||||||
refetch?: boolean
|
refetch?: boolean
|
||||||
|
/** A flag to potentially disable sending requests to the API. */
|
||||||
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEntity<TEntity extends Entity>(
|
function useEntity<TEntity extends Entity>(
|
||||||
|
@ -21,7 +23,7 @@ function useEntity<TEntity extends Entity>(
|
||||||
entityFn: EntityFn<void>,
|
entityFn: EntityFn<void>,
|
||||||
opts: UseEntityOpts<TEntity> = {},
|
opts: UseEntityOpts<TEntity> = {},
|
||||||
) {
|
) {
|
||||||
const [isFetching, setPromise] = useLoading();
|
const [isFetching, setPromise] = useLoading(true);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [entityType, entityId] = path;
|
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 entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
|
||||||
|
|
||||||
|
const isEnabled = opts.enabled ?? true;
|
||||||
const isLoading = isFetching && !entity;
|
const isLoading = isFetching && !entity;
|
||||||
|
|
||||||
const fetchEntity = async () => {
|
const fetchEntity = async () => {
|
||||||
|
@ -44,10 +47,11 @@ function useEntity<TEntity extends Entity>(
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isEnabled) return;
|
||||||
if (!entity || opts.refetch) {
|
if (!entity || opts.refetch) {
|
||||||
fetchEntity();
|
fetchEntity();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isEnabled]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entity,
|
entity,
|
||||||
|
@ -59,4 +63,5 @@ function useEntity<TEntity extends Entity>(
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useEntity,
|
useEntity,
|
||||||
|
type UseEntityOpts,
|
||||||
};
|
};
|
|
@ -12,8 +12,9 @@ interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityActionEndpoints {
|
interface EntityActionEndpoints {
|
||||||
post?: string
|
|
||||||
delete?: string
|
delete?: string
|
||||||
|
patch?: string
|
||||||
|
post?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||||
|
@ -24,16 +25,20 @@ function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const { entityType, path } = parseEntitiesPath(expandedPath);
|
const { entityType, path } = parseEntitiesPath(expandedPath);
|
||||||
|
|
||||||
const { deleteEntity, isLoading: deleteLoading } =
|
const { deleteEntity, isSubmitting: deleteSubmitting } =
|
||||||
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
|
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);
|
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 {
|
return {
|
||||||
createEntity,
|
createEntity,
|
||||||
deleteEntity,
|
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 { createCache, createList, updateStore, updateList } from './utils';
|
||||||
|
|
||||||
import type { DeleteEntitiesOpts } from './actions';
|
import type { DeleteEntitiesOpts } from './actions';
|
||||||
import type { Entity, EntityCache, EntityListState } from './types';
|
import type { Entity, EntityCache, EntityListState, ImportPosition } from './types';
|
||||||
|
|
||||||
enableMapSet();
|
enableMapSet();
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ const importEntities = (
|
||||||
entityType: string,
|
entityType: string,
|
||||||
entities: Entity[],
|
entities: Entity[],
|
||||||
listKey?: string,
|
listKey?: string,
|
||||||
|
pos?: ImportPosition,
|
||||||
newState?: EntityListState,
|
newState?: EntityListState,
|
||||||
overwrite = false,
|
overwrite = false,
|
||||||
): State => {
|
): State => {
|
||||||
|
@ -43,7 +44,7 @@ const importEntities = (
|
||||||
list.ids = new Set();
|
list.ids = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
list = updateList(list, entities);
|
list = updateList(list, entities, pos);
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
list.state = newState;
|
list.state = newState;
|
||||||
|
@ -159,7 +160,7 @@ const invalidateEntityList = (state: State, entityType: string, listKey: string)
|
||||||
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ENTITIES_IMPORT:
|
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:
|
case ENTITIES_DELETE:
|
||||||
return deleteEntities(state, action.entityType, action.ids, action.opts);
|
return deleteEntities(state, action.entityType, action.ids, action.opts);
|
||||||
case ENTITIES_DISMISS:
|
case ENTITIES_DISMISS:
|
||||||
|
@ -167,7 +168,7 @@ function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
||||||
case ENTITIES_INCREMENT:
|
case ENTITIES_INCREMENT:
|
||||||
return incrementEntities(state, action.entityType, action.listKey, action.diff);
|
return incrementEntities(state, action.entityType, action.listKey, action.diff);
|
||||||
case ENTITIES_FETCH_SUCCESS:
|
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:
|
case ENTITIES_FETCH_REQUEST:
|
||||||
return setFetching(state, action.entityType, action.listKey, true);
|
return setFetching(state, action.entityType, action.listKey, true);
|
||||||
case ENTITIES_FETCH_FAIL:
|
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 {
|
export {
|
||||||
Entity,
|
Entity,
|
||||||
EntityStore,
|
EntityStore,
|
||||||
EntityList,
|
EntityList,
|
||||||
EntityListState,
|
EntityListState,
|
||||||
EntityCache,
|
EntityCache,
|
||||||
|
ImportPosition,
|
||||||
};
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue