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

This commit is contained in:
Alex Gleason 2023-05-13 19:27:52 -05:00
commit a5c616312f
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
353 changed files with 7736 additions and 4041 deletions

View File

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

View File

@ -1 +1 @@
nodejs 18.14.0 nodejs 20.0.0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

201
app/soapbox/actions/tags.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,8 @@ import { ToastText, ToastType } from 'soapbox/toast';
import HStack from '../hstack/hstack'; import HStack from '../hstack/hstack';
import Icon from '../icon/icon'; import Icon from '../icon/icon';
import Stack from '../stack/stack';
import Text from '../text/text';
const renderText = (text: ToastText) => { const renderText = (text: ToastText) => {
if (typeof text === 'string') { if (typeof text === 'string') {
@ -24,13 +26,14 @@ interface IToast {
action?(): void action?(): void
actionLink?: string actionLink?: string
actionLabel?: ToastText actionLabel?: ToastText
summary?: string
} }
/** /**
* Customizable Toasts for in-app notifications. * Customizable Toasts for in-app notifications.
*/ */
const Toast = (props: IToast) => { const Toast = (props: IToast) => {
const { t, message, type, action, actionLink, actionLabel } = props; const { t, message, type, action, actionLink, actionLabel, summary } = props;
const dismissToast = () => toast.dismiss(t.id); const dismissToast = () => toast.dismiss(t.id);
@ -109,6 +112,7 @@ const Toast = (props: IToast) => {
}) })
} }
> >
<Stack space={2}>
<HStack space={4} alignItems='start'> <HStack space={4} alignItems='start'>
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'> <HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
<HStack space={3} alignItems='start' className='w-0 flex-1'> <HStack space={3} alignItems='start' className='w-0 flex-1'>
@ -116,9 +120,14 @@ const Toast = (props: IToast) => {
{renderIcon()} {renderIcon()}
</div> </div>
<p className='pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'> <Text
size='sm'
data-testid='toast-message'
className='pt-0.5'
weight={typeof summary === 'undefined' ? 'normal' : 'medium'}
>
{renderText(message)} {renderText(message)}
</p> </Text>
</HStack> </HStack>
{/* Action */} {/* Action */}
@ -138,6 +147,11 @@ const Toast = (props: IToast) => {
</button> </button>
</div> </div>
</HStack> </HStack>
{summary ? (
<Text theme='muted' size='sm'>{summary}</Text>
) : null}
</Stack>
</div> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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