Merge remote-tracking branch 'origin/develop' into nostr-ws

environments/review-nip07-f6txhi/deployments/3373
Alex Gleason 2023-05-13 19:27:52 -05:00
commit a5c616312f
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
353 zmienionych plików z 7736 dodań i 4041 usunięć

Wyświetl plik

@ -1,4 +1,4 @@
image: node:18
image: node:20
variables:
NODE_ENV: test

Wyświetl plik

@ -1 +1 @@
nodejs 18.14.0
nodejs 20.0.0

Wyświetl plik

@ -7,16 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Hashtags: let users follow hashtags (Mastodon, Akkoma).
- Posts: Support posts filtering on recent Mastodon versions
- Reactions: Support custom emoji reactions
- Compatbility: Support Mastodon v2 timeline filters.
- Compatbility: Preliminary support for Ditto backend.
- Posts: Support dislikes on Friendica.
- UI: added a character counter to some textareas.
### Changed
- Posts: truncate Nostr pubkeys in reply mentions.
- Posts: upgraded emoji picker component.
- Posts: improved design of threads.
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
- UI: added sticky column header.
- UI: add specific zones the user can drag-and-drop files.
### Fixed
- Posts: fixed emojis being cut off in reactions modal.
@ -27,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 18n: fixed Chinese language being detected from the browser.
- Conversations: fixed pagination (Mastodon).
- Compatibility: fix version parsing for Friendica.
- UI: fixed various overflow issues related to long usernames.
- UI: fixed display of Markdown code blocks in the reply indicator.
## [3.2.0] - 2023-02-15

Wyświetl plik

@ -1,4 +1,4 @@
FROM node:18 as build
FROM node:20 as build
WORKDIR /app
COPY package.json .
COPY yarn.lock .

Wyświetl plik

@ -1,4 +1,4 @@
FROM node:18
FROM node:20
RUN apt-get update &&\
apt-get install -y inotify-tools &&\

Wyświetl plik

@ -1,10 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes';
import { normalizeAccount, normalizeRelationship } from '../../normalizers';
import { normalizeAccount } from '../../normalizers';
import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';
import type { Account } from 'soapbox/types/entities';
@ -66,7 +67,7 @@ describe('initAccountNoteModal()', () => {
beforeEach(() => {
const state = rootState
.set('relationships', ImmutableMap({ '1': normalizeRelationship({ note: 'hello' }) }));
.set('relationships', ImmutableMap({ '1': buildRelationship({ note: 'hello' }) }));
store = mockStore(state);
});

Wyświetl plik

@ -1,10 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists';
import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers';
import { normalizeAccount, normalizeInstance } from '../../normalizers';
import {
authorizeFollowRequest,
blockAccount,
@ -1340,7 +1341,7 @@ describe('fetchRelationships()', () => {
describe('without newAccountIds', () => {
beforeEach(() => {
const state = rootState
.set('relationships', ImmutableMap({ [id]: normalizeRelationship({}) }))
.set('relationships', ImmutableMap({ [id]: buildRelationship() }))
.set('me', '123');
store = mockStore(state);
});

Wyświetl plik

@ -242,7 +242,8 @@ export const fetchOwnAccounts = () =>
return state.auth.users.forEach((user) => {
const account = state.accounts.get(user.id);
if (!account) {
dispatch(verifyCredentials(user.access_token, user.url));
dispatch(verifyCredentials(user.access_token, user.url))
.catch(() => console.warn(`Failed to load account: ${user.url}`));
}
});
};

Wyświetl plik

@ -22,6 +22,7 @@ import { createStatus } from './statuses';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import type { Emoji } from 'soapbox/features/emoji';
import type { Group } from 'soapbox/schemas';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
import type { History } from 'soapbox/types/history';
@ -168,6 +169,14 @@ const cancelQuoteCompose = () => ({
id: 'compose-modal',
});
const groupComposeModal = (group: Group) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const composeId = `group:${group.id}`;
dispatch(groupCompose(composeId, group.id));
dispatch(openModal('COMPOSE', { composeId }));
};
const resetCompose = (composeId = 'compose-modal') => ({
type: COMPOSE_RESET,
id: composeId,
@ -829,6 +838,7 @@ export {
uploadComposeFail,
undoUploadCompose,
groupCompose,
groupComposeModal,
setGroupTimelineVisible,
clearComposeSuggestions,
fetchComposeSuggestions,

Wyświetl plik

@ -1,21 +1,14 @@
import { defineMessages } from 'react-intl';
import { deleteEntities } from 'soapbox/entity-store/actions';
import toast from 'soapbox/toast';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedGroups, importFetchedAccounts } from './importer';
import { closeModal, openModal } from './modals';
import { deleteFromTimelines } from './timelines';
import type { AxiosError } from 'axios';
import type { GroupRole } from 'soapbox/reducers/group-memberships';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Group } from 'soapbox/types/entities';
const GROUP_EDITOR_SET = 'GROUP_EDITOR_SET';
import type { APIEntity } from 'soapbox/types/entities';
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
@ -41,10 +34,6 @@ const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST';
const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS';
const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST';
const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS';
const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL';
const GROUP_KICK_REQUEST = 'GROUP_KICK_REQUEST';
const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS';
const GROUP_KICK_FAIL = 'GROUP_KICK_FAIL';
@ -97,100 +86,6 @@ const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT
const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS';
const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL';
const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE';
const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE';
const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE';
const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE';
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
const messages = defineMessages({
success: { id: 'manage_group.submit_success', defaultMessage: 'The group was created' },
editSuccess: { id: 'manage_group.edit_success', defaultMessage: 'The group was edited' },
joinSuccess: { id: 'group.join.success', defaultMessage: 'Joined the group' },
joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' },
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
view: { id: 'toast.view', defaultMessage: 'View' },
});
const editGroup = (group: Group) => (dispatch: AppDispatch) => {
dispatch({
type: GROUP_EDITOR_SET,
group,
});
dispatch(openModal('MANAGE_GROUP'));
};
const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(createGroupRequest());
return api(getState).post('/api/v1/groups', params, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(({ data }) => {
dispatch(importFetchedGroups([data]));
dispatch(createGroupSuccess(data));
toast.success(messages.success, {
actionLabel: messages.view,
actionLink: `/groups/${data.id}`,
});
if (shouldReset) {
dispatch(resetGroupEditor());
}
return data;
}).catch(err => dispatch(createGroupFail(err)));
};
const createGroupRequest = () => ({
type: GROUP_CREATE_REQUEST,
});
const createGroupSuccess = (group: APIEntity) => ({
type: GROUP_CREATE_SUCCESS,
group,
});
const createGroupFail = (error: AxiosError) => ({
type: GROUP_CREATE_FAIL,
error,
});
const updateGroup = (id: string, params: Record<string, any>, shouldReset?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(updateGroupRequest());
return api(getState).put(`/api/v1/groups/${id}`, params)
.then(({ data }) => {
dispatch(importFetchedGroups([data]));
dispatch(updateGroupSuccess(data));
toast.success(messages.editSuccess);
if (shouldReset) {
dispatch(resetGroupEditor());
}
dispatch(closeModal('MANAGE_GROUP'));
}).catch(err => dispatch(updateGroupFail(err)));
};
const updateGroupRequest = () => ({
type: GROUP_UPDATE_REQUEST,
});
const updateGroupSuccess = (group: APIEntity) => ({
type: GROUP_UPDATE_SUCCESS,
group,
});
const updateGroupFail = (error: AxiosError) => ({
type: GROUP_UPDATE_FAIL,
error,
});
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(deleteEntities([id], 'Group'));
@ -306,36 +201,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({
skipNotFound: true,
});
const groupDeleteStatus = (groupId: string, statusId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(groupDeleteStatusRequest(groupId, statusId));
return api(getState).delete(`/api/v1/groups/${groupId}/statuses/${statusId}`)
.then(() => {
dispatch(deleteFromTimelines(statusId));
dispatch(groupDeleteStatusSuccess(groupId, statusId));
}).catch(err => dispatch(groupDeleteStatusFail(groupId, statusId, err)));
};
const groupDeleteStatusRequest = (groupId: string, statusId: string) => ({
type: GROUP_DELETE_STATUS_REQUEST,
groupId,
statusId,
});
const groupDeleteStatusSuccess = (groupId: string, statusId: string) => ({
type: GROUP_DELETE_STATUS_SUCCESS,
groupId,
statusId,
});
const groupDeleteStatusFail = (groupId: string, statusId: string, error: AxiosError) => ({
type: GROUP_DELETE_STATUS_SUCCESS,
groupId,
statusId,
error,
});
const groupKick = (groupId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(groupKickRequest(groupId, accountId));
@ -758,57 +623,7 @@ const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, er
error,
});
const changeGroupEditorTitle = (value: string) => ({
type: GROUP_EDITOR_TITLE_CHANGE,
value,
});
const changeGroupEditorDescription = (value: string) => ({
type: GROUP_EDITOR_DESCRIPTION_CHANGE,
value,
});
const changeGroupEditorPrivacy = (value: boolean) => ({
type: GROUP_EDITOR_PRIVACY_CHANGE,
value,
});
const changeGroupEditorMedia = (mediaType: 'header' | 'avatar', file: File) => ({
type: GROUP_EDITOR_MEDIA_CHANGE,
mediaType,
value: file,
});
const resetGroupEditor = () => ({
type: GROUP_EDITOR_RESET,
});
const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => {
const groupId = getState().group_editor.groupId;
const displayName = getState().group_editor.displayName;
const note = getState().group_editor.note;
const avatar = getState().group_editor.avatar;
const header = getState().group_editor.header;
const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social
const params: Record<string, any> = {
display_name: displayName,
group_visibility: visibility,
note,
};
if (avatar) params.avatar = avatar;
if (header) params.header = header;
if (groupId === null) {
return dispatch(createGroup(params, shouldReset));
} else {
return dispatch(updateGroup(groupId, params, shouldReset));
}
};
export {
GROUP_EDITOR_SET,
GROUP_CREATE_REQUEST,
GROUP_CREATE_SUCCESS,
GROUP_CREATE_FAIL,
@ -827,9 +642,6 @@ export {
GROUP_RELATIONSHIPS_FETCH_REQUEST,
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
GROUP_RELATIONSHIPS_FETCH_FAIL,
GROUP_DELETE_STATUS_REQUEST,
GROUP_DELETE_STATUS_SUCCESS,
GROUP_DELETE_STATUS_FAIL,
GROUP_KICK_REQUEST,
GROUP_KICK_SUCCESS,
GROUP_KICK_FAIL,
@ -869,20 +681,6 @@ export {
GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST,
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
GROUP_EDITOR_TITLE_CHANGE,
GROUP_EDITOR_DESCRIPTION_CHANGE,
GROUP_EDITOR_PRIVACY_CHANGE,
GROUP_EDITOR_MEDIA_CHANGE,
GROUP_EDITOR_RESET,
editGroup,
createGroup,
createGroupRequest,
createGroupSuccess,
createGroupFail,
updateGroup,
updateGroupRequest,
updateGroupSuccess,
updateGroupFail,
deleteGroup,
deleteGroupRequest,
deleteGroupSuccess,
@ -899,10 +697,6 @@ export {
fetchGroupRelationshipsRequest,
fetchGroupRelationshipsSuccess,
fetchGroupRelationshipsFail,
groupDeleteStatus,
groupDeleteStatusRequest,
groupDeleteStatusSuccess,
groupDeleteStatusFail,
groupKick,
groupKickRequest,
groupKickSuccess,
@ -955,10 +749,4 @@ export {
rejectGroupMembershipRequestRequest,
rejectGroupMembershipRequestSuccess,
rejectGroupMembershipRequestFail,
changeGroupEditorTitle,
changeGroupEditorDescription,
changeGroupEditorPrivacy,
changeGroupEditorMedia,
resetGroupEditor,
submitGroupEditor,
};

Wyświetl plik

@ -74,7 +74,7 @@ const importFetchedGroup = (group: APIEntity) =>
importFetchedGroups([group]);
const importFetchedGroups = (groups: APIEntity[]) => {
const entities = filteredArray(groupSchema).catch([]).parse(groups);
const entities = filteredArray(groupSchema).parse(groups);
return importGroups(entities);
};

Wyświetl plik

@ -142,7 +142,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = ()
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/trash.svg'),
heading: intl.formatMessage(messages.deleteStatusHeading),
message: intl.formatMessage(messages.deleteStatusPrompt, { acct }),
message: intl.formatMessage(messages.deleteStatusPrompt, { acct: <strong className='break-words'>{acct}</strong> }),
confirm: intl.formatMessage(messages.deleteStatusConfirm),
onConfirm: () => {
dispatch(deleteStatus(statusId)).then(() => {

Wyświetl plik

@ -1,7 +1,7 @@
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
import type { AxiosError } from 'axios';
import type { SearchFilter } from 'soapbox/reducers/search';
@ -83,10 +83,6 @@ const submitSearch = (filter?: SearchFilter) =>
dispatch(importFetchedStatuses(response.data.statuses));
}
if (response.data.groups) {
dispatch(importFetchedGroups(response.data.groups));
}
dispatch(fetchSearchSuccess(response.data, value, type));
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => {
@ -143,10 +139,6 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
dispatch(importFetchedStatuses(data.statuses));
}
if (data.groups) {
dispatch(importFetchedGroups(data.groups));
}
dispatch(expandSearchSuccess(data, value, type));
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => {

Wyświetl plik

@ -5,6 +5,7 @@ import { shouldHaveCard } from 'soapbox/utils/status';
import api, { getNextLink } from '../api';
import { setComposeToStatus } from './compose';
import { fetchGroupRelationships } from './groups';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { openModal } from './modals';
import { deleteFromTimelines } from './timelines';
@ -124,6 +125,9 @@ const fetchStatus = (id: string) => {
return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => {
dispatch(importFetchedStatus(status));
if (status.group) {
dispatch(fetchGroupRelationships([status.group.id]));
}
dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading });
return status;
}).catch(error => {

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -4,7 +4,7 @@ import { getSettings } from 'soapbox/actions/settings';
import { normalizeStatus } from 'soapbox/normalizers';
import { shouldFilter } from 'soapbox/utils/timelines';
import api, { getLinks } from '../api';
import api, { getNextLink, getPrevLink } from '../api';
import { importFetchedStatus, importFetchedStatuses } from './importer';
@ -139,7 +139,7 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
};
const replaceHomeTimeline = (
accountId: string | null,
accountId: string | undefined,
{ maxId }: Record<string, any> = {},
done?: () => void,
) => (dispatch: AppDispatch, _getState: () => RootState) => {
@ -162,7 +162,12 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
return dispatch(noOpAsync());
}
if (!params.max_id && !params.pinned && (timeline.items || ImmutableOrderedSet()).size > 0) {
if (
!params.max_id &&
!params.pinned &&
(timeline.items || ImmutableOrderedSet()).size > 0 &&
!path.includes('max_id=')
) {
params.since_id = timeline.getIn(['items', 0]);
}
@ -171,9 +176,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
dispatch(expandTimelineRequest(timelineId, isLoadingMore));
return api(getState).get(path, { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore));
dispatch(expandTimelineSuccess(
timelineId,
response.data,
getNextLink(response),
getPrevLink(response),
response.status === 206,
isLoadingRecent,
isLoadingMore,
));
done();
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
@ -181,9 +193,26 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
});
};
const expandHomeTimeline = ({ accountId, maxId }: Record<string, any> = {}, done = noOp) => {
const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home';
const params: any = { max_id: maxId };
interface ExpandHomeTimelineOpts {
accountId?: string
maxId?: string
url?: string
}
interface HomeTimelineParams {
max_id?: string
exclude_replies?: boolean
with_muted?: boolean
}
const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => {
const endpoint = url || (accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home');
const params: HomeTimelineParams = {};
if (!url && maxId) {
params.max_id = maxId;
}
if (accountId) {
params.exclude_replies = true;
params.with_muted = true;
@ -219,6 +248,9 @@ const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, don
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`group:tags:${id}:${tagName}`, `/api/v1/timelines/group/${id}/tags/${tagName}`, { max_id: maxId }, done);
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
@ -237,11 +269,20 @@ const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({
skipLoading: !isLoadingMore,
});
const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({
const expandTimelineSuccess = (
timeline: string,
statuses: APIEntity[],
next: string | undefined,
prev: string | undefined,
partial: boolean,
isLoadingRecent: boolean,
isLoadingMore: boolean,
) => ({
type: TIMELINE_EXPAND_SUCCESS,
timeline,
statuses,
next,
prev,
partial,
isLoadingRecent,
skipLoading: !isLoadingMore,
@ -312,6 +353,7 @@ export {
expandAccountMediaTimeline,
expandListTimeline,
expandGroupTimeline,
expandGroupTimelineFromTag,
expandGroupMediaTimeline,
expandHashtagTimeline,
expandTimelineRequest,

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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();
});
});
});

Wyświetl plik

@ -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();
});
});
});

Wyświetl plik

@ -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();
});
});
});

Wyświetl plik

@ -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();
});
});
});

Wyświetl plik

@ -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);
});
});
});

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -4,14 +4,14 @@ import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group } from 'soapbox/schemas';
function useDeleteGroup() {
const { deleteEntity, isLoading } = useEntityActions<Group>(
const { deleteEntity, isSubmitting } = useEntityActions<Group>(
[Entities.GROUPS],
{ delete: '/api/v1/groups/:id' },
);
return {
mutate: deleteEntity,
isLoading,
isSubmitting,
};
}

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -1,10 +1,11 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { useApi } from '../useApi';
import { useApi } from '../../../hooks/useApi';
function useGroupMembers(groupId: string, role: string) {
function useGroupMembers(groupId: string, role: GroupRoles) {
const api = useApi();
const { entities, ...result } = useEntities<GroupMember>(

Wyświetl plik

@ -1,7 +1,10 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities, useIncrementEntity } from 'soapbox/entity-store/hooks';
import { useDismissEntity, useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { accountSchema } from 'soapbox/schemas';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { useGroupRelationship } from './useGroupRelationship';
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
@ -9,19 +12,24 @@ function useGroupMembershipRequests(groupId: string) {
const api = useApi();
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
const { entities, invalidate, ...rest } = useEntities(
const { entity: relationship } = useGroupRelationship(groupId);
const { entities, invalidate, fetchEntities, ...rest } = useEntities(
path,
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
{ schema: accountSchema },
{
schema: accountSchema,
enabled: relationship?.role === GroupRoles.OWNER || relationship?.role === GroupRoles.ADMIN,
},
);
const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => {
const { dismissEntity: authorize } = useDismissEntity(path, async (accountId: string) => {
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`);
invalidate();
return response;
});
const { incrementEntity: reject } = useIncrementEntity(path, -1, async (accountId: string) => {
const { dismissEntity: reject } = useDismissEntity(path, async (accountId: string) => {
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`);
invalidate();
return response;
@ -29,6 +37,7 @@ function useGroupMembershipRequests(groupId: string) {
return {
accounts: entities,
refetch: fetchEntities,
authorize,
reject,
...rest,

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -1,11 +1,9 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi, useFeatures } from 'soapbox/hooks';
import { groupSchema } from 'soapbox/schemas';
import { useApi } from '../../useApi';
import { useFeatures } from '../../useFeatures';
import { useGroupRelationships } from './useGroups';
import { useGroupRelationships } from './useGroupRelationships';
import type { Group } from 'soapbox/schemas';

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas';
function useJoinGroup(group: Group) {
const { invalidate } = useGroups();
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/join` },
{ schema: groupRelationshipSchema },
@ -17,7 +17,7 @@ function useJoinGroup(group: Group) {
return {
mutate: createEntity,
isLoading,
isSubmitting,
invalidate,
};
}

Wyświetl plik

@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas';
function useLeaveGroup(group: Group) {
const { invalidate } = useGroups();
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/leave` },
{ schema: groupRelationshipSchema },
@ -17,7 +17,7 @@ function useLeaveGroup(group: Group) {
return {
mutate: createEntity,
isLoading,
isSubmitting,
invalidate,
};
}

Wyświetl plik

@ -2,9 +2,10 @@ import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { Group, groupSchema } from 'soapbox/schemas';
import { useGroupRelationships } from '../api/groups/useGroups';
import { useApi } from '../useApi';
import { useFeatures } from '../useFeatures';
import { useApi } from '../../../hooks/useApi';
import { useFeatures } from '../../../hooks/useFeatures';
import { useGroupRelationships } from './useGroupRelationships';
function usePopularGroups() {
const api = useApi();

Wyświetl plik

@ -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 };

Wyświetl plik

@ -1,10 +1,9 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { Group, groupSchema } from 'soapbox/schemas';
import { useApi, useFeatures } from 'soapbox/hooks';
import { type Group, groupSchema } from 'soapbox/schemas';
import { useGroupRelationships } from '../api/groups/useGroups';
import { useApi } from '../useApi';
import { useFeatures } from '../useFeatures';
import { useGroupRelationships } from './useGroupRelationships';
function useSuggestedGroups() {
const api = useApi();

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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';

Wyświetl plik

@ -14,6 +14,23 @@ interface IAuthorizeRejectButtons {
const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize, onReject, countdown }) => {
const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending');
const timeout = useRef<NodeJS.Timeout>();
const interval = useRef<ReturnType<typeof setInterval>>();
const [progress, setProgress] = useState<number>(0);
const startProgressInterval = () => {
let startValue = 1;
interval.current = setInterval(() => {
startValue++;
const newValue = startValue * 3.6; // get to 360 (deg)
setProgress(newValue);
if (newValue >= 360) {
clearInterval(interval.current as NodeJS.Timeout);
setProgress(0);
}
}, (countdown as number) / 100);
};
function handleAction(
present: 'authorizing' | 'rejecting',
@ -21,6 +38,9 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
action: () => Promise<unknown> | unknown,
): void {
if (state === present) {
if (interval.current) {
clearInterval(interval.current);
}
if (timeout.current) {
clearTimeout(timeout.current);
}
@ -31,12 +51,13 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
await action();
setState(past);
} catch (e) {
console.error(e);
if (e) console.error(e);
}
};
if (typeof countdown === 'number') {
setState(present);
timeout.current = setTimeout(doAction, countdown);
startProgressInterval();
} else {
doAction();
}
@ -46,11 +67,28 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize);
const handleReject = async () => handleAction('rejecting', 'rejected', onReject);
const renderStyle = (selectedState: typeof state) => {
if (state === 'authorizing' && selectedState === 'authorizing') {
return {
background: `conic-gradient(rgb(var(--color-primary-500)) ${progress}deg, rgb(var(--color-primary-500) / 0.1) 0deg)`,
};
} else if (state === 'rejecting' && selectedState === 'rejecting') {
return {
background: `conic-gradient(rgb(var(--color-danger-600)) ${progress}deg, rgb(var(--color-danger-600) / 0.1) 0deg)`,
};
}
return {};
};
useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
if (interval.current) {
clearInterval(interval.current);
}
};
}, []);
@ -72,6 +110,7 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
action={handleReject}
isLoading={state === 'rejecting'}
disabled={state === 'authorizing'}
style={renderStyle('rejecting')}
/>
<AuthorizeRejectButton
theme='primary'
@ -79,6 +118,7 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
action={handleAuthorize}
isLoading={state === 'authorizing'}
disabled={state === 'rejecting'}
style={renderStyle('authorizing')}
/>
</HStack>
);
@ -105,33 +145,34 @@ interface IAuthorizeRejectButton {
action(): void
isLoading?: boolean
disabled?: boolean
style: React.CSSProperties
}
const AuthorizeRejectButton: React.FC<IAuthorizeRejectButton> = ({ theme, icon, action, isLoading, disabled }) => {
const AuthorizeRejectButton: React.FC<IAuthorizeRejectButton> = ({ theme, icon, action, isLoading, style, disabled }) => {
return (
<div className='relative'>
<IconButton
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
onClick={action}
theme='seamless'
className={clsx('h-10 w-10 items-center justify-center border-2', {
'border-primary-500/10 hover:border-primary-500': theme === 'primary',
'border-danger-600/10 hover:border-danger-600': theme === 'danger',
})}
iconClassName={clsx('h-6 w-6', {
'text-primary-500': theme === 'primary',
'text-danger-600': theme === 'danger',
})}
disabled={disabled}
/>
{(isLoading) && (
<div
className={clsx('pointer-events-none absolute inset-0 h-10 w-10 animate-spin rounded-full border-2 border-transparent', {
'border-t-primary-500': theme === 'primary',
'border-t-danger-600': theme === 'danger',
<div
style={style}
className={
clsx({
'flex h-11 w-11 items-center justify-center rounded-full': true,
'bg-danger-600/10': theme === 'danger',
'bg-primary-500/10': theme === 'primary',
})
}
>
<IconButton
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
onClick={action}
theme='seamless'
className='h-10 w-10 items-center justify-center bg-white focus:!ring-0 dark:!bg-gray-900'
iconClassName={clsx('h-6 w-6', {
'text-primary-500': theme === 'primary',
'text-danger-600': theme === 'danger',
})}
disabled={disabled}
/>
)}
</div>
</div>
);
};

Wyświetl plik

@ -58,6 +58,7 @@ const BirthdayPanel = ({ limit }: IBirthdayPanel) => {
key={accountId}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={accountId}
withRelationship={false}
/>
))}
</Widget>

Wyświetl plik

@ -94,7 +94,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
>
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{item.text}</span>
<span className='truncate font-medium'>{item.text}</span>
{item.count ? (
<span className='ml-auto h-5 w-5 flex-none'>

Wyświetl plik

@ -1,6 +1,6 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import GroupHeaderImage from 'soapbox/features/group/components/group-header-image';
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import GroupRelationship from 'soapbox/features/group/components/group-relationship';
@ -10,17 +10,11 @@ import { HStack, Stack, Text } from './ui';
import type { Group as GroupEntity } from 'soapbox/types/entities';
const messages = defineMessages({
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
});
interface IGroupCard {
group: GroupEntity
}
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
const intl = useIntl();
return (
<Stack
className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'
@ -28,12 +22,10 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
>
{/* Group Cover Image */}
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
{group.header && (
<img
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
src={group.header} alt={intl.formatMessage(messages.groupHeader)}
/>
)}
<GroupHeaderImage
group={group}
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
/>
</Stack>
{/* Group Avatar */}

Wyświetl plik

@ -1,6 +1,6 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Link, matchPath, useHistory } from 'react-router-dom';
import { Button, Divider, HStack, Popover, Stack, Text } from 'soapbox/components/ui';
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
@ -26,6 +26,13 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
const { children, group, isEnabled } = props;
const intl = useIntl();
const history = useHistory();
const path = history.location.pathname;
const shouldHideAction = matchPath(path, {
path: ['/group/:groupSlug'],
exact: true,
});
if (!isEnabled) {
return children;
@ -36,7 +43,7 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
interaction='click'
referenceElementClassName='cursor-pointer'
content={
<Stack space={4} className='w-80'>
<Stack space={4} className='w-80 pb-4'>
<Stack
className='relative h-60 rounded-lg bg-white dark:border-primary-800 dark:bg-primary-900'
data-testid='group-card'
@ -79,13 +86,15 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
</Text>
</Stack>
<div className='px-4 pb-4'>
<Link to={`/groups/${group.id}`}>
<Button type='button' theme='secondary' block>
{intl.formatMessage(messages.action)}
</Button>
</Link>
</div>
{!shouldHideAction && (
<div className='px-4'>
<Link to={`/group/${group.slug}`}>
<Button type='button' theme='secondary' block>
{intl.formatMessage(messages.action)}
</Button>
</Link>
</div>
)}
</Stack>
}
isFlush

Wyświetl plik

@ -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;

Wyświetl plik

@ -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;

Wyświetl plik

@ -56,14 +56,13 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
return (
<Comp
className={clsx({
'flex items-center justify-between px-4 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 dark:from-gradient-start/10 dark:to-gradient-end/10': true,
className={clsx('flex items-center justify-between overflow-hidden bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 px-4 py-2 first:rounded-t-lg last:rounded-b-lg dark:from-gradient-start/10 dark:to-gradient-end/10', {
'cursor-pointer hover:from-gradient-start/30 hover:to-gradient-end/30 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
})}
{...linkProps}
>
<div className='flex flex-col py-1.5 pr-4 rtl:pl-4 rtl:pr-0'>
<LabelComp className='font-medium text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp>
<LabelComp className='text-gray-900 dark:text-gray-100' htmlFor={domId}>{label}</LabelComp>
{hint ? (
<span className='text-sm text-gray-700 dark:text-gray-600'>{hint}</span>
@ -71,7 +70,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
</div>
{onClick ? (
<HStack space={1} alignItems='center' className='text-gray-700 dark:text-gray-600'>
<HStack space={1} alignItems='center' className='overflow-hidden text-gray-700 dark:text-gray-600'>
{children}
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1 rtl:rotate-180' />

Wyświetl plik

@ -181,7 +181,9 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
};
const getSiblings = () => {
return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])).filter(node => node !== ref.current);
return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[]))
.filter(node => (node as HTMLDivElement).id !== 'toaster')
.filter(node => node !== ref.current);
};
useEffect(() => {

Wyświetl plik

@ -4,14 +4,22 @@ import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { __stub } from 'soapbox/api';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { mockStore, render, screen, rootState } from 'soapbox/jest/test-helpers';
import { type Poll } from 'soapbox/schemas';
import { mockStore, render, screen, rootState } from '../../../jest/test-helpers';
import PollFooter from '../poll-footer';
let poll = normalizePoll({
id: 1,
options: [{ title: 'Apples', votes_count: 0 }],
let poll: Poll = {
id: '1',
options: [{
title: 'Apples',
votes_count: 0,
title_emojified: 'Apples',
}, {
title: 'Oranges',
votes_count: 0,
title_emojified: 'Oranges',
}],
emojis: [],
expired: false,
expires_at: '2020-03-24T19:33:06.000Z',
@ -20,7 +28,7 @@ let poll = normalizePoll({
votes_count: 0,
own_votes: null,
voted: false,
});
};
describe('<PollFooter />', () => {
describe('with "showResults" enabled', () => {
@ -62,10 +70,10 @@ describe('<PollFooter />', () => {
describe('when the Poll has not expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
expired: false,
});
};
});
it('renders time remaining', () => {
@ -77,10 +85,10 @@ describe('<PollFooter />', () => {
describe('when the Poll has expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
expired: true,
});
};
});
it('renders closed', () => {
@ -100,10 +108,10 @@ describe('<PollFooter />', () => {
describe('when the Poll is multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
multiple: true,
});
};
});
it('renders the Vote button', () => {
@ -115,10 +123,10 @@ describe('<PollFooter />', () => {
describe('when the Poll is not multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
multiple: false,
});
};
});
it('does not render the Vote button', () => {

Wyświetl plik

@ -40,21 +40,21 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
let votesCount = null;
if (poll.voters_count !== null && poll.voters_count !== undefined) {
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.voters_count }} />;
} else {
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.votes_count }} />;
}
return (
<Stack space={4} data-testid='poll-footer'>
{(!showResults && poll?.multiple) && (
{(!showResults && poll.multiple) && (
<Button onClick={handleVote} theme='primary' block>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</Button>
)}
<HStack space={1.5} alignItems='center' wrap>
{poll.pleroma.get('non_anonymous') && (
{poll.pleroma?.non_anonymous && (
<>
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
<Text theme='muted' weight='medium'>

Wyświetl plik

@ -112,10 +112,13 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
const pollVotesCount = poll.voters_count || poll.votes_count;
const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100;
const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count);
const voted = poll.own_votes?.includes(index);
const message = intl.formatMessage(messages.votes, { votes: option.votes_count });
const leading = poll.options
.filter(other => other.title !== option.title)
.every(other => option.votes_count >= other.votes_count);
return (
<div key={option.title}>
{showResults ? (

Wyświetl plik

@ -106,7 +106,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
onMouseEnter={handleMouseEnter(dispatch)}
onMouseLeave={handleMouseLeave(dispatch)}
>
<Card variant='rounded' className='relative isolate'>
<Card variant='rounded' className='relative isolate overflow-hidden'>
<CardBody>
<Stack space={2}>
<BundleContainer fetchComponent={UserPanel}>

Wyświetl plik

@ -7,18 +7,20 @@ import { blockAccount } from 'soapbox/actions/accounts';
import { launchChat } from 'soapbox/actions/chats';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
import { editEvent } from 'soapbox/actions/events';
import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups';
import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import { deleteFromTimelines } from 'soapbox/actions/timelines';
import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusActionButton from 'soapbox/components/status-action-button';
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
import { HStack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast';
import { isLocal, isRemote } from 'soapbox/utils/accounts';
import copy from 'soapbox/utils/copy';
@ -87,16 +89,7 @@ const messages = defineMessages({
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' },
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
groupModKick: { id: 'status.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
groupModBlock: { id: 'status.group_mod_block', defaultMessage: 'Block @{name} from group' },
deleteFromGroupHeading: { id: 'confirmations.delete_from_group.heading', defaultMessage: 'Delete from group' },
deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' },
kickFromGroupHeading: { id: 'confirmations.kick_from_group.heading', defaultMessage: 'Kick group member' },
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
kickFromGroupConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Block group member' },
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
blockFromGroupConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
});
interface IStatusActionBar {
@ -121,6 +114,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const features = useFeatures();
const settings = useSettings();
const soapboxConfig = useSoapboxConfig();
const deleteGroupStatus = useDeleteGroupStatus(status?.group as Group, status.id);
const { allowedEmoji } = soapboxConfig;
@ -258,8 +252,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/ban.svg'),
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.get('acct') }} />,
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.id)),
secondary: intl.formatMessage(messages.blockAndReport),
@ -313,31 +307,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }),
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: <strong className='break-words'>{account.username}</strong> }),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)),
}));
};
const handleKickFromGroup: React.EventHandler<React.MouseEvent> = () => {
const account = status.account as Account;
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.kickFromGroupHeading),
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
confirm: intl.formatMessage(messages.kickFromGroupConfirm),
onConfirm: () => dispatch(groupKick((status.group as Group).id, account.id)),
}));
};
const handleBlockFromGroup: React.EventHandler<React.MouseEvent> = () => {
const account = status.account as Account;
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.blockFromGroupHeading),
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
confirm: intl.formatMessage(messages.blockFromGroupConfirm),
onConfirm: () => dispatch(groupBlock((status.group as Group).id, account.id)),
onConfirm: () => {
deleteGroupStatus.mutate(status.id, {
onSuccess() {
dispatch(deleteFromTimelines(status.id));
},
});
},
}));
};
@ -362,7 +340,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
menu.push({
text: intl.formatMessage(messages.copy),
action: handleCopy,
icon: require('@tabler/icons/link.svg'),
icon: require('@tabler/icons/clipboard-copy.svg'),
});
if (features.embeds && isLocal(account)) {
@ -466,7 +444,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
menu.push({
text: intl.formatMessage(messages.mute, { name: username }),
action: handleMuteClick,
icon: require('@tabler/icons/circle-x.svg'),
icon: require('@tabler/icons/volume-3.svg'),
});
menu.push({
text: intl.formatMessage(messages.block, { name: username }),
@ -480,23 +458,17 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
});
}
if (status.group && groupRelationship?.role && ['admin', 'moderator'].includes(groupRelationship.role)) {
if (status.group &&
groupRelationship?.role &&
[GroupRoles.OWNER].includes(groupRelationship.role) &&
!ownAccount
) {
menu.push(null);
menu.push({
text: intl.formatMessage(messages.groupModDelete),
action: handleDeleteFromGroup,
icon: require('@tabler/icons/trash.svg'),
});
// TODO: figure out when an account is not in the group anymore
menu.push({
text: intl.formatMessage(messages.groupModKick, { name: account.get('username') }),
action: handleKickFromGroup,
icon: require('@tabler/icons/user-minus.svg'),
});
menu.push({
text: intl.formatMessage(messages.groupModBlock, { name: account.get('username') }),
action: handleBlockFromGroup,
icon: require('@tabler/icons/ban.svg'),
destructive: true,
});
}
@ -536,7 +508,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
return menu;
};
const publicStatus = ['public', 'unlisted'].includes(status.visibility);
const publicStatus = ['public', 'unlisted', 'group'].includes(status.visibility);
const replyCount = status.replies_count;
const reblogCount = status.reblogs_count;
@ -609,7 +581,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
replyTitle = intl.formatMessage(messages.replyAll);
}
const canShare = ('share' in navigator) && status.visibility === 'public';
const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group');
return (
<HStack data-testid='status-action-bar'>

Wyświetl plik

@ -53,7 +53,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
src={icon}
className={clsx(
{
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
'fill-accent-300 text-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
},
iconClassName,
)}

Wyświetl plik

@ -139,6 +139,7 @@ const StatusList: React.FC<IStatusList> = ({
onMoveDown={handleMoveDown}
contextType={timelineId}
showGroup={showGroup}
variant={divideType === 'border' ? 'slim' : 'rounded'}
/>
);
};
@ -172,6 +173,7 @@ const StatusList: React.FC<IStatusList> = ({
onMoveDown={handleMoveDown}
contextType={timelineId}
showGroup={showGroup}
variant={divideType === 'border' ? 'slim' : 'default'}
/>
));
};
@ -245,7 +247,7 @@ const StatusList: React.FC<IStatusList> = ({
isLoading={isLoading}
showLoading={isLoading && statusIds.size === 0}
onLoadMore={handleLoadOlder}
placeholderComponent={PlaceholderStatus}
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
placeholderCount={20}
ref={node}
className={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {

Wyświetl plik

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { openModal } from 'soapbox/actions/modals';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import { GroupLinkPreview } from 'soapbox/features/groups/components/group-link-preview';
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
import Card from 'soapbox/features/status/components/card';
import Bundle from 'soapbox/features/ui/components/bundle';
@ -153,6 +154,10 @@ const StatusMedia: React.FC<IStatusMedia> = ({
</Bundle>
);
}
} else if (status.spoiler_text.length === 0 && !status.quote && status.card?.group) {
media = (
<GroupLinkPreview card={status.card} />
);
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
media = (
<Card

Wyświetl plik

@ -54,7 +54,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
<Link
key={account.id}
to={`/@${account.acct}`}
className='reply-mentions__account'
className='reply-mentions__account max-w-[200px] truncate align-bottom'
onClick={(e) => e.stopPropagation()}
>
@{isPubkey(account.username) ? account.username.slice(0, 8) : account.username}

Wyświetl plik

@ -2,13 +2,12 @@ import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { HotKeys } from 'react-hotkeys';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { Link, useHistory } from 'react-router-dom';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon';
import TranslateButton from 'soapbox/components/translate-button';
import AccountContainer from 'soapbox/containers/account-container';
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
@ -22,7 +21,8 @@ import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
import StatusInfo from './statuses/status-info';
import { Card, Stack, Text } from './ui';
import Tombstone from './tombstone';
import { Card, Icon, Stack, Text } from './ui';
import type {
Account as AccountEntity,
@ -51,7 +51,7 @@ export interface IStatus {
featured?: boolean
hideActionBar?: boolean
hoverable?: boolean
variant?: 'default' | 'rounded'
variant?: 'default' | 'rounded' | 'slim'
showGroup?: boolean
accountAction?: React.ReactElement
}
@ -212,26 +212,67 @@ const Status: React.FC<IStatus> = (props) => {
};
const renderStatusInfo = () => {
if (isReblog) {
if (isReblog && showGroup && group) {
return (
<StatusInfo
avatarSize={avatarSize}
to={`/@${status.getIn(['account', 'acct'])}`}
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />}
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='h-4 w-4 text-green-600' />}
text={
<FormattedMessage
id='status.reblogged_by_with_group'
defaultMessage='{name} reposted from {group}'
values={{
name: (
<Link
to={`/@${status.getIn(['account', 'acct'])}`}
className='hover:underline'
>
<bdi className='truncate'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: String(status.getIn(['account', 'display_name_html'])),
}}
/>
</bdi>
</Link>
),
group: (
<Link to={`/group/${(status.group as GroupEntity).slug}`} className='hover:underline'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: (status.group as GroupEntity).display_name_html,
}}
/>
</Link>
),
}}
/>
}
/>
);
} else if (isReblog) {
return (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='h-4 w-4 text-green-600' />}
text={
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: (
<bdi className='truncate pr-1 rtl:pl-1'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: String(status.getIn(['account', 'display_name_html'])),
}}
/>
</bdi>
<Link to={`/@${status.getIn(['account', 'acct'])}`} className='hover:underline'>
<bdi className='truncate'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: String(status.getIn(['account', 'display_name_html'])),
}}
/>
</bdi>
</Link>
),
}}
/>
@ -242,11 +283,9 @@ const Status: React.FC<IStatus> = (props) => {
return (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={require('@tabler/icons/pinned.svg')} className='text-gray-600 dark:text-gray-400' />}
icon={<Icon src={require('@tabler/icons/pinned.svg')} className='h-4 w-4 text-gray-600 dark:text-gray-400' />}
text={
<Text size='xs' theme='muted' weight='medium'>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</Text>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
}
/>
);
@ -254,18 +293,23 @@ const Status: React.FC<IStatus> = (props) => {
return (
<StatusInfo
avatarSize={avatarSize}
to={`/groups/${group.id}`}
icon={<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />}
icon={<Icon src={require('@tabler/icons/circles.svg')} className='h-4 w-4 text-primary-600 dark:text-accent-blue' />}
text={
<Text size='xs' theme='muted' weight='medium'>
<FormattedMessage
id='status.group'
defaultMessage='Posted in {group}'
values={{ group: (
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
) }}
/>
</Text>
<FormattedMessage
id='status.group'
defaultMessage='Posted in {group}'
values={{
group: (
<Link to={`/group/${group.slug}`} className='hover:underline'>
<bdi className='truncate'>
<strong className='text-gray-800 dark:text-gray-200'>
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
</strong>
</bdi>
</Link>
),
}}
/>
}
/>
);
@ -345,6 +389,17 @@ const Status: React.FC<IStatus> = (props) => {
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.hidden;
const isSoftDeleted = status.tombstone?.reason === 'deleted';
if (isSoftDeleted) {
return (
<Tombstone
id={status.id}
onMoveUp={(id) => onMoveUp ? onMoveUp(id) : null}
onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null}
/>
);
}
return (
<HotKeys handlers={handlers} data-testid='status'>

Wyświetl plik

@ -1,37 +1,44 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { HStack, Text } from '../ui';
interface IStatusInfo {
avatarSize: number
to?: string
icon: React.ReactNode
text: React.ReactNode
}
const StatusInfo = (props: IStatusInfo) => {
const { avatarSize, to, icon, text } = props;
const { avatarSize, icon, text } = props;
const onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
const onClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
event.stopPropagation();
};
const Container = to ? Link : 'div';
const containerProps: any = to ? { onClick, to } : {};
return (
<Container
{...containerProps}
className='flex items-center space-x-3 text-xs font-medium text-gray-700 hover:underline rtl:space-x-reverse dark:text-gray-600'
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
// eslint-disable-next-line jsx-a11y/aria-role
role='status-info'
onClick={onClick}
>
<div
className='flex justify-end'
style={{ width: avatarSize }}
<HStack
space={3}
alignItems='center'
className='cursor-default text-xs font-medium text-gray-700 rtl:space-x-reverse dark:text-gray-600'
>
{icon}
</div>
<div
className='flex justify-end'
style={{ width: avatarSize }}
>
{icon}
</div>
{text}
</Container>
<Text size='xs' theme='muted' weight='medium'>
{text}
</Text>
</HStack>
</div>
);
};

Wyświetl plik

@ -3,7 +3,7 @@ import React, { useRef } from 'react';
import { useSettings } from 'soapbox/hooks';
interface IStillImage {
export interface IStillImage {
/** Image alt text. */
alt?: string
/** Extra class names for the outer <div> container. */
@ -16,10 +16,12 @@ interface IStillImage {
letterboxed?: boolean
/** Whether to show the file extension in the corner. */
showExt?: boolean
/** Callback function if the image fails to load */
onError?(): void
}
/** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterboxed = false, showExt = false }) => {
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterboxed = false, showExt = false, onError }) => {
const settings = useSettings();
const autoPlayGif = settings.get('autoPlayGif');
@ -55,6 +57,7 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterb
alt={alt}
ref={img}
onLoad={handleImageLoad}
onError={onError}
className={clsx(baseClassName, {
'invisible group-hover:visible': hoverToPlay,
})}

Wyświetl plik

@ -19,10 +19,17 @@ const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown }) => {
return (
<HotKeys handlers={handlers}>
<div className='focusable flex items-center justify-center border border-solid border-gray-200 bg-gray-100 p-9 dark:border-gray-800 dark:bg-gray-900 sm:rounded-xl' tabIndex={0}>
<Text>
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />
</Text>
<div className='h-16'>
<div
className='focusable flex h-[42px] items-center justify-center rounded-lg border-2 border-gray-200 text-center'
>
<Text theme='muted'>
<FormattedMessage
id='statuses.tombstone'
defaultMessage='One or more posts are unavailable.'
/>
</Text>
</div>
</div>
</HotKeys>
);

Wyświetl plik

@ -21,13 +21,16 @@ interface IAccordion {
menu?: Menu
expanded?: boolean
onToggle?: (value: boolean) => void
action?: () => void
actionIcon?: string
actionLabel?: string
}
/**
* Accordion
* An accordion is a vertically stacked group of collapsible sections.
*/
const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded = false, onToggle = () => {} }) => {
const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded = false, onToggle = () => {}, action, actionIcon, actionLabel }) => {
const intl = useIntl();
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>) => {
@ -35,6 +38,13 @@ const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded =
e.preventDefault();
};
const handleAction = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!action) return;
action();
e.preventDefault();
};
return (
<div className='rounded-lg bg-white text-gray-900 shadow dark:bg-primary-800 dark:text-gray-100 dark:shadow-none'>
<button
@ -53,6 +63,14 @@ const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded =
src={require('@tabler/icons/dots-vertical.svg')}
/>
)}
{action && actionIcon && (
<button onClick={handleAction} title={actionLabel}>
<Icon
src={actionIcon}
className='h-5 w-5 text-gray-700 dark:text-gray-600'
/>
</button>
)}
<Icon
src={expanded ? require('@tabler/icons/chevron-up.svg') : require('@tabler/icons/chevron-down.svg')}
className='h-5 w-5 text-gray-700 dark:text-gray-600'

Wyświetl plik

@ -1,34 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import React, { useState } from 'react';
import StillImage from 'soapbox/components/still-image';
import StillImage, { IStillImage } from 'soapbox/components/still-image';
import Icon from '../icon/icon';
const AVATAR_SIZE = 42;
interface IAvatar {
/** URL to the avatar image. */
src: string
interface IAvatar extends Pick<IStillImage, 'src' | 'onError' | 'className'> {
/** Width and height of the avatar in pixels. */
size?: number
/** Extra class names for the div surrounding the avatar image. */
className?: string
}
/** Round profile avatar for accounts. */
const Avatar = (props: IAvatar) => {
const { src, size = AVATAR_SIZE, className } = props;
const [isAvatarMissing, setIsAvatarMissing] = useState<boolean>(false);
const handleLoadFailure = () => setIsAvatarMissing(true);
const style: React.CSSProperties = React.useMemo(() => ({
width: size,
height: size,
}), [size]);
if (isAvatarMissing) {
return (
<div
style={{
width: size,
height: size,
}}
className={clsx('flex items-center justify-center rounded-full bg-gray-200 dark:bg-gray-900', className)}
>
<Icon
src={require('@tabler/icons/photo-off.svg')}
className='h-4 w-4 text-gray-500 dark:text-gray-700'
/>
</div>
);
}
return (
<StillImage
className={clsx('rounded-full', className)}
style={style}
src={src}
alt='Avatar'
onError={handleLoadFailure}
/>
);
};

Wyświetl plik

@ -16,11 +16,13 @@ const messages = defineMessages({
back: { id: 'card.back.label', defaultMessage: 'Back' },
});
export type CardSizes = keyof typeof sizes
interface ICard {
/** The type of card. */
variant?: 'default' | 'rounded'
variant?: 'default' | 'rounded' | 'slim'
/** Card size preset. */
size?: keyof typeof sizes
size?: CardSizes
/** Extra classnames for the <div> element. */
className?: string
/** Elements inside the card. */
@ -33,8 +35,9 @@ const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant = 'def
ref={ref}
{...filteredProps}
className={clsx({
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded',
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none': variant === 'rounded',
[sizes[size]]: variant === 'rounded',
'py-4': variant === 'slim',
}, className)}
>
{children}
@ -72,7 +75,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
};
return (
<HStack alignItems='center' space={2} className={clsx('mb-4', className)}>
<HStack alignItems='center' space={2} className={className}>
{renderBackButton()}
{children}

Wyświetl plik

@ -1,11 +1,12 @@
import clsx from 'clsx';
import React from 'react';
import throttle from 'lodash/throttle';
import React, { useCallback, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import Helmet from 'soapbox/components/helmet';
import { useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from '../card/card';
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'action'>;
@ -50,17 +51,35 @@ export interface IColumn {
withHeader?: boolean
/** Extra class name for top <div> element. */
className?: string
/** Extra class name for the <CardBody> element. */
bodyClassName?: string
/** Ref forwarded to column. */
ref?: React.Ref<HTMLDivElement>
/** Children to display in the column. */
children?: React.ReactNode
/** Action for the ColumnHeader, displayed at the end. */
action?: React.ReactNode
/** Column size, inherited from Card. */
size?: CardSizes
}
/** A backdrop for the main section of the UI. */
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
const { backHref, children, label, transparent = false, withHeader = true, className, action } = props;
const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props;
const soapboxConfig = useSoapboxConfig();
const [isScrolled, setIsScrolled] = useState(false);
const handleScroll = useCallback(throttle(() => {
setIsScrolled(window.pageYOffset > 32);
}, 50), []);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return (
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
@ -76,17 +95,23 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
)}
</Helmet>
<Card variant={transparent ? undefined : 'rounded'} className={className}>
<Card size={size} variant={transparent ? undefined : 'rounded'} className={className}>
{withHeader && (
<ColumnHeader
label={label}
backHref={backHref}
className={clsx({ 'px-4 pt-4 sm:p-0': transparent })}
className={clsx({
'rounded-t-3xl': !isScrolled && !transparent,
'sticky top-12 z-10 bg-white/90 dark:bg-primary-900/90 backdrop-blur lg:top-16': !transparent,
'p-4 sm:p-0 sm:pb-4': transparent,
'-mt-4 -mx-4 p-4': size !== 'lg' && !transparent,
'-mt-4 -mx-4 p-4 sm:-mt-6 sm:-mx-6 sm:p-6': size === 'lg' && !transparent,
})}
action={action}
/>
)}
<CardBody>
<CardBody className={bodyClassName}>
{children}
</CardBody>
</Card>

Wyświetl plik

@ -11,24 +11,22 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
src: string
/** Text to display next ot the button. */
text?: string
/** Don't render a background behind the icon. */
transparent?: boolean
/** Predefined styles to display for the button. */
theme?: 'seamless' | 'outlined' | 'secondary'
theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent'
/** Override the data-testid */
'data-testid'?: string
}
/** A clickable icon. */
const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef<HTMLButtonElement>): JSX.Element => {
const { src, className, iconClassName, text, transparent = false, theme = 'seamless', ...filteredProps } = props;
const { src, className, iconClassName, text, theme = 'seamless', ...filteredProps } = props;
return (
<button
ref={ref}
type='button'
className={clsx('flex items-center space-x-2 rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-offset-0', {
'bg-white dark:bg-transparent': !transparent,
'bg-white dark:bg-transparent': theme === 'seamless',
'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined',
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200': theme === 'secondary',
'opacity-50': filteredProps.disabled,

Wyświetl plik

@ -17,11 +17,16 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
src: string
/** Width and height of the icon in pixels. */
size?: number
/** Override the data-testid */
'data-testid'?: string
}
/** Renders and SVG icon with optional counter. */
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
<div className='relative flex shrink-0 flex-col' data-testid='icon'>
<div
className='relative flex shrink-0 flex-col'
data-testid={filteredProps['data-testid'] || 'icon'}
>
{count ? (
<span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'>
<Counter count={count} countMax={countMax} />

Wyświetl plik

@ -55,10 +55,11 @@ interface IModal {
title?: React.ReactNode
width?: keyof typeof widths
children?: React.ReactNode
className?: string
}
/** Displays a modal dialog box. */
const Modal: React.FC<IModal> = ({
const Modal = React.forwardRef<HTMLDivElement, IModal>(({
cancelAction,
cancelText,
children,
@ -76,7 +77,8 @@ const Modal: React.FC<IModal> = ({
skipFocus = false,
title,
width = 'xl',
}) => {
className,
}, ref) => {
const intl = useIntl();
const buttonRef = React.useRef<HTMLButtonElement>(null);
@ -87,7 +89,11 @@ const Modal: React.FC<IModal> = ({
}, [skipFocus, buttonRef]);
return (
<div data-testid='modal' className={clsx('pointer-events-auto mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all dark:bg-primary-900 dark:text-gray-100', widths[width])}>
<div
ref={ref}
data-testid='modal'
className={clsx(className, 'pointer-events-auto mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all dark:bg-primary-900 dark:text-gray-100', widths[width])}
>
<div className='w-full justify-between sm:flex sm:items-start'>
<div className='w-full'>
{title && (
@ -96,7 +102,7 @@ const Modal: React.FC<IModal> = ({
'flex-row-reverse': closePosition === 'left',
})}
>
<h3 className='grow text-lg font-bold leading-6 text-gray-900 dark:text-white'>
<h3 className='grow truncate text-lg font-bold leading-6 text-gray-900 dark:text-white'>
{title}
</h3>
@ -157,6 +163,6 @@ const Modal: React.FC<IModal> = ({
)}
</div>
);
};
});
export default Modal;

Wyświetl plik

@ -16,6 +16,7 @@ const messages = defineMessages({
export type StreamfieldComponent<T> = React.ComponentType<{
value: T
onChange: (value: T) => void
autoFocus: boolean
}>;
interface IStreamfield {
@ -69,14 +70,19 @@ const Streamfield: React.FC<IStreamfield> = ({
</Stack>
{(values.length > 0) && (
<Stack>
<Stack space={1}>
{values.map((value, i) => value?._destroy ? null : (
<HStack space={2} alignItems='center'>
<Component key={i} onChange={handleChange(i)} value={value} />
<Component
key={i}
onChange={handleChange(i)}
value={value}
autoFocus={i > 0}
/>
{values.length > minItems && onRemoveItem && (
<IconButton
iconClassName='h-4 w-4'
className='bg-transparent text-gray-400 hover:text-gray-600'
className='bg-transparent text-gray-600 hover:text-gray-600'
src={require('@tabler/icons/x.svg')}
onClick={() => onRemoveItem(i)}
title={intl.formatMessage(messages.remove)}
@ -87,11 +93,9 @@ const Streamfield: React.FC<IStreamfield> = ({
</Stack>
)}
{onAddItem && (
{(onAddItem && (values.length < maxItems)) && (
<Button
icon={require('@tabler/icons/plus.svg')}
onClick={onAddItem}
disabled={values.length >= maxItems}
theme='secondary'
block
>

Wyświetl plik

@ -20,7 +20,6 @@ const Tag: React.FC<ITag> = ({ tag, onDelete }) => {
iconClassName='h-4 w-4'
src={require('@tabler/icons/x.svg')}
onClick={() => onDelete(tag)}
transparent
/>
</div>
);

Wyświetl plik

@ -8,6 +8,8 @@ import { ToastText, ToastType } from 'soapbox/toast';
import HStack from '../hstack/hstack';
import Icon from '../icon/icon';
import Stack from '../stack/stack';
import Text from '../text/text';
const renderText = (text: ToastText) => {
if (typeof text === 'string') {
@ -24,13 +26,14 @@ interface IToast {
action?(): void
actionLink?: string
actionLabel?: ToastText
summary?: string
}
/**
* Customizable Toasts for in-app notifications.
*/
const Toast = (props: IToast) => {
const { t, message, type, action, actionLink, actionLabel } = props;
const { t, message, type, action, actionLink, actionLabel, summary } = props;
const dismissToast = () => toast.dismiss(t.id);
@ -109,35 +112,46 @@ const Toast = (props: IToast) => {
})
}
>
<HStack space={4} alignItems='start'>
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
<HStack space={3} alignItems='start' className='w-0 flex-1'>
<div className='shrink-0'>
{renderIcon()}
</div>
<Stack space={2}>
<HStack space={4} alignItems='start'>
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
<HStack space={3} alignItems='start' className='w-0 flex-1'>
<div className='shrink-0'>
{renderIcon()}
</div>
<p className='pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'>
{renderText(message)}
</p>
<Text
size='sm'
data-testid='toast-message'
className='pt-0.5'
weight={typeof summary === 'undefined' ? 'normal' : 'medium'}
>
{renderText(message)}
</Text>
</HStack>
{/* Action */}
{renderAction()}
</HStack>
{/* Action */}
{renderAction()}
{/* Dismiss Button */}
<div className='flex shrink-0 pt-0.5'>
<button
type='button'
className='inline-flex rounded-md text-gray-600 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:text-gray-600 dark:hover:text-gray-500'
onClick={dismissToast}
data-testid='toast-dismiss'
>
<span className='sr-only'>Close</span>
<Icon src={require('@tabler/icons/x.svg')} className='h-5 w-5' />
</button>
</div>
</HStack>
{/* Dismiss Button */}
<div className='flex shrink-0 pt-0.5'>
<button
type='button'
className='inline-flex rounded-md text-gray-600 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:text-gray-600 dark:hover:text-gray-500'
onClick={dismissToast}
data-testid='toast-dismiss'
>
<span className='sr-only'>Close</span>
<Icon src={require('@tabler/icons/x.svg')} className='h-5 w-5' />
</button>
</div>
</HStack>
{summary ? (
<Text theme='muted' size='sm'>{summary}</Text>
) : null}
</Stack>
</div>
);
};

Wyświetl plik

@ -26,6 +26,7 @@ const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked, onChange, r
'cursor-default': disabled,
})}
onClick={handleClick}
type='button'
>
<div className={clsx('rounded-full bg-white transition-transform', {
'h-4.5 w-4.5': size === 'sm',

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -1,67 +1,88 @@
import { TooltipPopup, useTooltip } from '@reach/tooltip';
import React from 'react';
import Portal from '../portal/portal';
import './tooltip.css';
import {
arrow,
FloatingArrow,
FloatingPortal,
offset,
useFloating,
useHover,
useInteractions,
useTransitionStyles,
} from '@floating-ui/react';
import React, { useRef, useState } from 'react';
interface ITooltip {
/** Element to display the tooltip around. */
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
/** Text to display in the tooltip. */
text: string
/** Element to display the tooltip around. */
children: React.ReactNode
}
const centered = (triggerRect: any, tooltipRect: any) => {
const triggerCenter = triggerRect.left + triggerRect.width / 2;
const left = triggerCenter - tooltipRect.width / 2;
const maxLeft = window.innerWidth - tooltipRect.width - 2;
return {
left: Math.min(Math.max(2, left), maxLeft) + window.scrollX,
top: triggerRect.bottom + 8 + window.scrollY,
};
};
/**
* Tooltip
*/
const Tooltip: React.FC<ITooltip> = (props) => {
const { children, text } = props;
/** Hoverable tooltip element. */
const Tooltip: React.FC<ITooltip> = ({
children,
text,
}) => {
// get the props from useTooltip
const [trigger, tooltip] = useTooltip();
const [isOpen, setIsOpen] = useState<boolean>(false);
// destructure off what we need to position the triangle
const { isVisible, triggerRect } = tooltip;
const arrowRef = useRef<SVGSVGElement>(null);
const { x, y, strategy, refs, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
middleware: [
offset(6),
arrow({
element: arrowRef,
}),
],
});
const hover = useHover(context);
const { isMounted, styles } = useTransitionStyles(context, {
initial: {
opacity: 0,
transform: 'scale(0.8)',
},
duration: {
open: 200,
close: 200,
},
});
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
]);
return (
<React.Fragment>
{React.cloneElement(children as any, trigger)}
<>
{React.cloneElement(children, {
ref: refs.setReference,
...getReferenceProps(),
})}
{isVisible && (
// The Triangle. We position it relative to the trigger, not the popup
// so that collisions don't have a triangle pointing off to nowhere.
// Using a Portal may seem a little extreme, but we can keep the
// positioning logic simpler here instead of needing to consider
// the popup's position relative to the trigger and collisions
<Portal>
{(isMounted) && (
<FloatingPortal>
<div
data-reach-tooltip-arrow='true'
ref={refs.setFloating}
style={{
left:
triggerRect && triggerRect.left - 10 + triggerRect.width / 2 as any,
top: triggerRect && triggerRect.bottom + window.scrollY as any,
position: strategy,
top: y ?? 0,
left: x ?? 0,
...styles,
}}
/>
</Portal>
className='pointer-events-none z-[100] whitespace-nowrap rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900'
{...getFloatingProps()}
>
{text}
<FloatingArrow ref={arrowRef} context={context} className='fill-gray-800 dark:fill-gray-100' />
</div>
</FloatingPortal>
)}
<TooltipPopup
{...tooltip}
label={text}
aria-label={text}
position={centered}
/>
</React.Fragment>
</>
);
};
export default Tooltip;
export default Tooltip;

Wyświetl plik

@ -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;

Wyświetl plik

@ -191,7 +191,14 @@ const SoapboxMount = () => {
</BundleContainer>
<GdprBanner />
<Toaster position='top-right' containerClassName='top-10' containerStyle={{ top: 75 }} />
<div id='toaster'>
<Toaster
position='top-right'
containerClassName='top-10'
containerStyle={{ top: 75 }}
/>
</div>
</Route>
</Switch>
</ScrollContext>

Wyświetl plik

@ -110,7 +110,7 @@ test('import entities with override', () => {
const now = new Date();
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', {
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', 'end', {
next: undefined,
prev: undefined,
totalCount: 2,

Wyświetl plik

@ -1,4 +1,4 @@
import type { Entity, EntityListState } from './types';
import type { Entity, EntityListState, ImportPosition } from './types';
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
@ -10,12 +10,13 @@ const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
/** Action to import entities into the cache. */
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) {
return {
type: ENTITIES_IMPORT,
entityType,
entities,
listKey,
pos,
};
}
@ -62,6 +63,7 @@ function entitiesFetchSuccess(
entities: Entity[],
entityType: string,
listKey?: string,
pos?: ImportPosition,
newState?: EntityListState,
overwrite = false,
) {
@ -70,6 +72,7 @@ function entitiesFetchSuccess(
entityType,
entities,
listKey,
pos,
newState,
overwrite,
};

Wyświetl plik

@ -1,6 +1,9 @@
export enum Entities {
ACCOUNTS = 'Accounts',
GROUPS = 'Groups',
GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_MEMBERSHIPS = 'GroupMemberships',
GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_TAGS = 'GroupTags',
RELATIONSHIPS = 'Relationships',
STATUSES = 'Statuses'
}

Wyświetl plik

@ -1,6 +1,7 @@
export { useEntities } from './useEntities';
export { useEntity } from './useEntity';
export { useEntityActions } from './useEntityActions';
export { useEntityLookup } from './useEntityLookup';
export { useCreateEntity } from './useCreateEntity';
export { useDeleteEntity } from './useDeleteEntity';
export { useDismissEntity } from './useDismissEntity';

Wyświetl plik

@ -1,3 +1,4 @@
import { AxiosError } from 'axios';
import { z } from 'zod';
import { useAppDispatch, useLoading } from 'soapbox/hooks';
@ -20,31 +21,35 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
) {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const [isSubmitting, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity, AxiosError> = {}): Promise<void> {
try {
const result = await setPromise(entityFn(data));
const schema = opts.schema || z.custom<TEntity>();
const entity = schema.parse(result.data);
// TODO: optimistic updating
dispatch(importEntities([entity], entityType, listKey));
dispatch(importEntities([entity], entityType, listKey, 'start'));
if (callbacks.onSuccess) {
callbacks.onSuccess(entity);
}
} catch (error) {
if (callbacks.onError) {
callbacks.onError(error);
if (error instanceof AxiosError) {
if (callbacks.onError) {
callbacks.onError(error);
}
} else {
throw error;
}
}
}
return {
createEntity,
isLoading,
isSubmitting,
};
}

Wyświetl plik

@ -15,7 +15,7 @@ function useDeleteEntity(
) {
const dispatch = useAppDispatch();
const getState = useGetState();
const [isLoading, setPromise] = useLoading();
const [isSubmitting, setPromise] = useLoading();
async function deleteEntity(entityId: string, callbacks: EntityCallbacks<string> = {}): Promise<void> {
// Get the entity before deleting, so we can reverse the action if the API request fails.
@ -47,7 +47,7 @@ function useDeleteEntity(
return {
deleteEntity,
isLoading,
isSubmitting,
};
}

Wyświetl plik

@ -54,7 +54,7 @@ function useEntities<TEntity extends Entity>(
const next = useListState(path, 'next');
const prev = useListState(path, 'prev');
const fetchPage = async(req: EntityFn<void>, overwrite = false): Promise<void> => {
const fetchPage = async(req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => {
// Get `isFetching` state from the store again to prevent race conditions.
const isFetching = selectListState(getState(), path, 'fetching');
if (isFetching) return;
@ -65,11 +65,12 @@ function useEntities<TEntity extends Entity>(
const schema = opts.schema || z.custom<TEntity>();
const entities = filteredArray(schema).parse(response.data);
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
const totalCount = parsedCount.success ? parsedCount.data : undefined;
dispatch(entitiesFetchSuccess(entities, entityType, listKey, {
dispatch(entitiesFetchSuccess(entities, entityType, listKey, pos, {
next: getNextLink(response),
prev: getPrevLink(response),
totalCount: parsedCount.success ? parsedCount.data : undefined,
totalCount: Number(totalCount) >= entities.length ? totalCount : undefined,
fetching: false,
fetched: true,
error: null,
@ -82,18 +83,18 @@ function useEntities<TEntity extends Entity>(
};
const fetchEntities = async(): Promise<void> => {
await fetchPage(entityFn, true);
await fetchPage(entityFn, 'end', true);
};
const fetchNextPage = async(): Promise<void> => {
if (next) {
await fetchPage(() => api.get(next));
await fetchPage(() => api.get(next), 'end');
}
};
const fetchPreviousPage = async(): Promise<void> => {
if (prev) {
await fetchPage(() => api.get(prev));
await fetchPage(() => api.get(prev), 'start');
}
};
@ -112,7 +113,7 @@ function useEntities<TEntity extends Entity>(
if (isInvalid || isUnset || isStale) {
fetchEntities();
}
}, [isEnabled]);
}, [isEnabled, ...path]);
return {
entities,

Wyświetl plik

@ -14,6 +14,8 @@ interface UseEntityOpts<TEntity extends Entity> {
schema?: EntitySchema<TEntity>
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
refetch?: boolean
/** A flag to potentially disable sending requests to the API. */
enabled?: boolean
}
function useEntity<TEntity extends Entity>(
@ -21,7 +23,7 @@ function useEntity<TEntity extends Entity>(
entityFn: EntityFn<void>,
opts: UseEntityOpts<TEntity> = {},
) {
const [isFetching, setPromise] = useLoading();
const [isFetching, setPromise] = useLoading(true);
const dispatch = useAppDispatch();
const [entityType, entityId] = path;
@ -31,6 +33,7 @@ function useEntity<TEntity extends Entity>(
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
const isEnabled = opts.enabled ?? true;
const isLoading = isFetching && !entity;
const fetchEntity = async () => {
@ -44,10 +47,11 @@ function useEntity<TEntity extends Entity>(
};
useEffect(() => {
if (!isEnabled) return;
if (!entity || opts.refetch) {
fetchEntity();
}
}, []);
}, [isEnabled]);
return {
entity,
@ -59,4 +63,5 @@ function useEntity<TEntity extends Entity>(
export {
useEntity,
type UseEntityOpts,
};

Wyświetl plik

@ -12,8 +12,9 @@ interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
}
interface EntityActionEndpoints {
post?: string
delete?: string
patch?: string
post?: string
}
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
@ -24,16 +25,20 @@ function useEntityActions<TEntity extends Entity = Entity, Data = any>(
const api = useApi();
const { entityType, path } = parseEntitiesPath(expandedPath);
const { deleteEntity, isLoading: deleteLoading } =
const { deleteEntity, isSubmitting: deleteSubmitting } =
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
const { createEntity, isLoading: createLoading } =
const { createEntity, isSubmitting: createSubmitting } =
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
const { createEntity: updateEntity, isSubmitting: updateSubmitting } =
useCreateEntity<TEntity, Data>(path, (data) => api.patch(endpoints.patch!, data), opts);
return {
createEntity,
deleteEntity,
isLoading: createLoading || deleteLoading,
updateEntity,
isSubmitting: createSubmitting || deleteSubmitting || updateSubmitting,
};
}

Wyświetl plik

@ -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 };

Wyświetl plik

@ -14,7 +14,7 @@ import {
import { createCache, createList, updateStore, updateList } from './utils';
import type { DeleteEntitiesOpts } from './actions';
import type { Entity, EntityCache, EntityListState } from './types';
import type { Entity, EntityCache, EntityListState, ImportPosition } from './types';
enableMapSet();
@ -29,6 +29,7 @@ const importEntities = (
entityType: string,
entities: Entity[],
listKey?: string,
pos?: ImportPosition,
newState?: EntityListState,
overwrite = false,
): State => {
@ -43,7 +44,7 @@ const importEntities = (
list.ids = new Set();
}
list = updateList(list, entities);
list = updateList(list, entities, pos);
if (newState) {
list.state = newState;
@ -159,7 +160,7 @@ const invalidateEntityList = (state: State, entityType: string, listKey: string)
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
switch (action.type) {
case ENTITIES_IMPORT:
return importEntities(state, action.entityType, action.entities, action.listKey);
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos);
case ENTITIES_DELETE:
return deleteEntities(state, action.entityType, action.ids, action.opts);
case ENTITIES_DISMISS:
@ -167,7 +168,7 @@ function reducer(state: Readonly<State> = {}, action: EntityAction): State {
case ENTITIES_INCREMENT:
return incrementEntities(state, action.entityType, action.listKey, action.diff);
case ENTITIES_FETCH_SUCCESS:
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite);
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos, action.newState, action.overwrite);
case ENTITIES_FETCH_REQUEST:
return setFetching(state, action.entityType, action.listKey, true);
case ENTITIES_FETCH_FAIL:

Wyświetl plik

@ -47,10 +47,14 @@ interface EntityCache<TEntity extends Entity = Entity> {
}
}
/** Whether to import items at the start or end of the list. */
type ImportPosition = 'start' | 'end'
export {
Entity,
EntityStore,
EntityList,
EntityListState,
EntityCache,
ImportPosition,
};

Some files were not shown because too many files have changed in this diff Show More