Merge remote-tracking branch 'soapbox/develop' into lexical

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-05-07 22:54:04 +02:00
commit e703cd5059
217 changed files with 1435 additions and 1230 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

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
@ -30,6 +31,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

@ -4,7 +4,6 @@ import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
import { importFetchedGroups, importFetchedAccounts } from './importer'; import { importFetchedGroups, importFetchedAccounts } from './importer';
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';
@ -35,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';
@ -206,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));
@ -677,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,
@ -735,10 +697,6 @@ export {
fetchGroupRelationshipsRequest, fetchGroupRelationshipsRequest,
fetchGroupRelationshipsSuccess, fetchGroupRelationshipsSuccess,
fetchGroupRelationshipsFail, fetchGroupRelationshipsFail,
groupDeleteStatus,
groupDeleteStatusRequest,
groupDeleteStatusSuccess,
groupDeleteStatusFail,
groupKick, groupKick,
groupKickRequest, groupKickRequest,
groupKickSuccess, groupKickSuccess,

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

@ -1,8 +1,8 @@
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks'; import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { type Account, accountSchema } from 'soapbox/schemas'; import { type Account, accountSchema } from 'soapbox/schemas';
import { useApi } from '../useApi';
import { useRelationships } from './useRelationships'; import { useRelationships } from './useRelationships';

View File

@ -1,9 +1,8 @@
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 } from 'soapbox/hooks/useApi';
import { type Relationship, relationshipSchema } from 'soapbox/schemas'; import { type Relationship, relationshipSchema } from 'soapbox/schemas';
import { useApi } from '../useApi';
function useRelationships(ids: string[]) { function useRelationships(ids: string[]) {
const api = useApi(); const api = useApi();

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

@ -3,7 +3,7 @@ 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 { GroupRoles } from 'soapbox/schemas/group-member';
import { useApi } from '../useApi'; import { useApi } from '../../../hooks/useApi';
function useGroupMembers(groupId: string, role: GroupRoles) { function useGroupMembers(groupId: string, role: GroupRoles) {
const api = useApi(); const api = useApi();

View File

@ -1,10 +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 { GroupRoles } from 'soapbox/schemas/group-member';
import { useGroupRelationship } from './useGroups'; import { useGroupRelationship } from './useGroupRelationship';
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types'; import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
@ -14,7 +14,7 @@ function useGroupMembershipRequests(groupId: string) {
const { entity: relationship } = useGroupRelationship(groupId); const { entity: relationship } = useGroupRelationship(groupId);
const { entities, invalidate, ...rest } = useEntities( const { entities, invalidate, fetchEntities, ...rest } = useEntities(
path, path,
() => api.get(`/api/v1/groups/${groupId}/membership_requests`), () => api.get(`/api/v1/groups/${groupId}/membership_requests`),
{ {
@ -23,13 +23,13 @@ function useGroupMembershipRequests(groupId: string) {
}, },
); );
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;
@ -37,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,32 @@
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) {
const api = useApi();
const dispatch = useAppDispatch();
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, groupId],
() => api.get(`/api/v1/groups/relationships?id[]=${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,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

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

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

@ -1,9 +1,7 @@
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 { GroupTag, groupTagSchema } from 'soapbox/schemas'; import { useApi, useFeatures } from 'soapbox/hooks';
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
import { useApi } from '../../useApi';
import { useFeatures } from '../../useFeatures';
function usePopularTags() { function usePopularTags() {
const api = useApi(); const api = useApi();

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

@ -1,7 +1,8 @@
/** /**
* Accounts * Accounts
*/ */
export { useAccount } from './useAccount'; export { useAccount } from './accounts/useAccount';
/** /**
* Groups * Groups
@ -11,22 +12,29 @@ export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest'
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup'; export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
export { useDeleteGroup } from './groups/useDeleteGroup'; export { useDeleteGroup } from './groups/useDeleteGroup';
export { useDemoteGroupMember } from './groups/useDemoteGroupMember'; export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
export { useGroup, useGroups } from './groups/useGroups'; export { useGroup } from './groups/useGroup';
export { useGroupLookup } from './groups/useGroupLookup';
export { useGroupMedia } from './groups/useGroupMedia'; export { useGroupMedia } from './groups/useGroupMedia';
export { useGroupMembers } from './groups/useGroupMembers';
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests'; export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
export { useGroupRelationship } from './groups/useGroupRelationship';
export { useGroupRelationships } from './groups/useGroupRelationships';
export { useGroupSearch } from './groups/useGroupSearch'; export { useGroupSearch } from './groups/useGroupSearch';
export { useGroupTag } from './groups/useGroupTag'; export { useGroupTag } from './groups/useGroupTag';
export { useGroupTags } from './groups/useGroupTags'; export { useGroupTags } from './groups/useGroupTags';
export { useGroupValidation } from './groups/useGroupValidation'; export { useGroupValidation } from './groups/useGroupValidation';
export { useGroups } from './groups/useGroups';
export { useGroupsFromTag } from './groups/useGroupsFromTag'; export { useGroupsFromTag } from './groups/useGroupsFromTag';
export { useJoinGroup } from './groups/useJoinGroup'; export { useJoinGroup } from './groups/useJoinGroup';
export { useLeaveGroup } from './groups/useLeaveGroup'; export { useLeaveGroup } from './groups/useLeaveGroup';
export { usePopularGroups } from './groups/usePopularGroups';
export { usePopularTags } from './groups/usePopularTags'; export { usePopularTags } from './groups/usePopularTags';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember'; export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
export { useSuggestedGroups } from './groups/useSuggestedGroups';
export { useUpdateGroup } from './groups/useUpdateGroup'; export { useUpdateGroup } from './groups/useUpdateGroup';
export { useUpdateGroupTag } from './groups/useUpdateGroupTag'; export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
/** /**
* Relationships * Relationships
*/ */
export { useRelationships } from './useRelationships'; export { useRelationships } from './accounts/useRelationships';

View File

@ -51,7 +51,7 @@ 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') {

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

@ -93,7 +93,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,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useGroupLookup } from 'soapbox/api/hooks';
import ColumnLoading from 'soapbox/features/ui/components/column-loading'; import ColumnLoading from 'soapbox/features/ui/components/column-loading';
import { useGroupLookup } from 'soapbox/hooks/api/groups/useGroupLookup';
import { Layout } from '../ui'; import { Layout } from '../ui';

View File

@ -56,8 +56,7 @@ 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}
@ -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

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

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

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

@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, 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>

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

@ -130,7 +130,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
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.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.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),
@ -215,7 +215,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
const unfollowModal = getSettings(getState()).get('unfollowModal'); const unfollowModal = getSettings(getState()).get('unfollowModal');
if (unfollowModal) { if (unfollowModal) {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.remove_from_followers.message' defaultMessage='Are you sure you want to remove {name} from your followers?' values={{ name: <strong>@{account.acct}</strong> }} />, message: <FormattedMessage id='confirmations.remove_from_followers.message' defaultMessage='Are you sure you want to remove {name} from your followers?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
confirm: intl.formatMessage(messages.removeFromFollowersConfirm), confirm: intl.formatMessage(messages.removeFromFollowersConfirm),
onConfirm: () => dispatch(removeFromFollowers(account.id)), onConfirm: () => dispatch(removeFromFollowers(account.id)),
})); }));

View File

@ -6,7 +6,6 @@ import type { Card } from 'soapbox/types/entities';
/** Map of available provider modules. */ /** Map of available provider modules. */
const PROVIDERS: Record<string, () => Promise<AdProvider>> = { const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default, soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default,
rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default,
truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default, truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default,
}; };

View File

@ -1,58 +0,0 @@
import axios from 'axios';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { normalizeAd, normalizeCard } from 'soapbox/normalizers';
import type { AdProvider } from '.';
/** Rumble ad API entity. */
interface RumbleAd {
type: number
impression: string
click: string
asset: string
expires: number
}
/** Response from Rumble ad server. */
interface RumbleApiResponse {
count: number
ads: RumbleAd[]
}
/** Provides ads from Soapbox Config. */
const RumbleAdProvider: AdProvider = {
getAds: async(getState) => {
const state = getState();
const settings = getSettings(state);
const soapboxConfig = getSoapboxConfig(state);
const endpoint = soapboxConfig.extensions.getIn(['ads', 'endpoint']) as string | undefined;
if (endpoint) {
try {
const { data } = await axios.get<RumbleApiResponse>(endpoint, {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
},
});
return data.ads.map(item => normalizeAd({
impression: item.impression,
card: normalizeCard({
type: item.type === 1 ? 'link' : 'rich',
image: item.asset,
url: item.click,
}),
expires_at: new Date(item.expires * 1000),
}));
} catch (e) {
// do nothing
}
}
return [];
},
};
export default RumbleAdProvider;

View File

@ -1,18 +1,19 @@
import axios from 'axios'; import axios from 'axios';
import { z } from 'zod';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import { normalizeCard } from 'soapbox/normalizers'; import { cardSchema } from 'soapbox/schemas/card';
import { filteredArray } from 'soapbox/schemas/utils';
import type { AdProvider } from '.'; import type { AdProvider } from '.';
import type { Card } from 'soapbox/types/entities';
/** TruthSocial ad API entity. */ /** TruthSocial ad API entity. */
interface TruthAd { const truthAdSchema = z.object({
impression: string impression: z.string(),
card: Card card: cardSchema,
expires_at: string expires_at: z.string(),
reason: string reason: z.string().catch(''),
} });
/** Provides ads from the TruthSocial API. */ /** Provides ads from the TruthSocial API. */
const TruthAdProvider: AdProvider = { const TruthAdProvider: AdProvider = {
@ -21,16 +22,13 @@ const TruthAdProvider: AdProvider = {
const settings = getSettings(state); const settings = getSettings(state);
try { try {
const { data } = await axios.get<TruthAd[]>('/api/v2/truth/ads?device=desktop', { const { data } = await axios.get('/api/v2/truth/ads?device=desktop', {
headers: { headers: {
'Accept-Language': settings.get('locale', '*') as string, 'Accept-Language': z.string().catch('*').parse(settings.get('locale')),
}, },
}); });
return data.map(item => ({ return filteredArray(truthAdSchema).parse(data);
...item,
card: normalizeCard(item.card),
}));
} catch (e) { } catch (e) {
// do nothing // do nothing
} }

View File

@ -1,12 +1,10 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
import { render, screen } from '../../../../jest/test-helpers'; import { render, screen } from '../../../../jest/test-helpers';
import ChatMessageReaction from '../chat-message-reaction'; import ChatMessageReaction from '../chat-message-reaction';
const emojiReaction = normalizeEmojiReaction({ const emojiReaction = ({
name: '👍', name: '👍',
count: 1, count: 1,
me: false, me: false,
@ -56,7 +54,7 @@ describe('<ChatMessageReaction />', () => {
render( render(
<ChatMessageReaction <ChatMessageReaction
emojiReaction={normalizeEmojiReaction({ emojiReaction={({
name: '👍', name: '👍',
count: 1, count: 1,
me: true, me: true,

View File

@ -312,7 +312,7 @@ const ChatMessage = (props: IChatMessage) => {
</Stack> </Stack>
</HStack> </HStack>
{(chatMessage.emoji_reactions?.size) ? ( {(chatMessage.emoji_reactions?.length) ? (
<div <div
className={clsx({ className={clsx({
'space-y-1': true, 'space-y-1': true,

View File

@ -2,7 +2,8 @@ import clsx from 'clsx';
import React from 'react'; import React from 'react';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import { Stack, Text } from 'soapbox/components/ui'; import Markup from 'soapbox/components/markup';
import { Stack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container'; import AccountContainer from 'soapbox/containers/account-container';
import { isRtl } from 'soapbox/rtl'; import { isRtl } from 'soapbox/rtl';
@ -45,8 +46,8 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
hideActions={hideActions} hideActions={hideActions}
/> />
<Text <Markup
className='status__content break-words' className='break-words'
size='sm' size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }} dangerouslySetInnerHTML={{ __html: status.contentHtml }}
direction={isRtl(status.search_index) ? 'rtl' : 'ltr'} direction={isRtl(status.search_index) ? 'rtl' : 'ltr'}

View File

@ -9,13 +9,11 @@ import IconButton from 'soapbox/components/icon-button';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { HStack, Tabs, Text } from 'soapbox/components/ui'; import { HStack, Tabs, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container'; import AccountContainer from 'soapbox/containers/account-container';
import GroupContainer from 'soapbox/containers/group-container';
import StatusContainer from 'soapbox/containers/status-container'; import StatusContainer from 'soapbox/containers/status-container';
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account'; import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
import PlaceholderGroupCard from 'soapbox/features/placeholder/components/placeholder-group-card';
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag'; import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import type { VirtuosoHandle } from 'react-virtuoso'; import type { VirtuosoHandle } from 'react-virtuoso';
@ -24,7 +22,6 @@ import type { SearchFilter } from 'soapbox/reducers/search';
const messages = defineMessages({ const messages = defineMessages({
accounts: { id: 'search_results.accounts', defaultMessage: 'People' }, accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' }, statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
groups: { id: 'search_results.groups', defaultMessage: 'Groups' },
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' }, hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
}); });
@ -33,7 +30,6 @@ const SearchResults = () => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const features = useFeatures();
const value = useAppSelector((state) => state.search.submittedValue); const value = useAppSelector((state) => state.search.submittedValue);
const results = useAppSelector((state) => state.search.results); const results = useAppSelector((state) => state.search.results);
@ -66,14 +62,6 @@ const SearchResults = () => {
}, },
); );
if (features.groups) items.push(
{
text: intl.formatMessage(messages.groups),
action: () => selectFilter('groups'),
name: 'groups',
},
);
items.push( items.push(
{ {
text: intl.formatMessage(messages.hashtags), text: intl.formatMessage(messages.hashtags),
@ -186,31 +174,6 @@ const SearchResults = () => {
} }
} }
if (selectedFilter === 'groups') {
hasMore = results.groupsHasMore;
loaded = results.groupsLoaded;
placeholderComponent = PlaceholderGroupCard;
if (results.groups && results.groups.size > 0) {
searchResults = results.groups.map((groupId: string) => (
<GroupContainer id={groupId} />
));
resultsIds = results.groups;
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
searchResults = null;
} else if (loaded) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
id='empty_column.search.groups'
defaultMessage='There are no groups results for "{term}"'
values={{ term: value }}
/>
</div>
);
}
}
if (selectedFilter === 'hashtags') { if (selectedFilter === 'hashtags') {
hasMore = results.hashtagsHasMore; hasMore = results.hashtagsHasMore;
loaded = results.hashtagsLoaded; loaded = results.hashtagsLoaded;
@ -238,11 +201,11 @@ const SearchResults = () => {
{filterByAccount ? ( {filterByAccount ? (
<HStack className='mb-4 border-b border-solid border-gray-200 px-2 pb-4 dark:border-gray-800' space={2}> <HStack className='mb-4 border-b border-solid border-gray-200 px-2 pb-4 dark:border-gray-800' space={2}>
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/x.svg')} onClick={handleUnsetAccount} /> <IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/x.svg')} onClick={handleUnsetAccount} />
<Text> <Text truncate>
<FormattedMessage <FormattedMessage
id='search_results.filter_message' id='search_results.filter_message'
defaultMessage='You are searching for posts from @{acct}.' defaultMessage='You are searching for posts from @{acct}.'
values={{ acct: account }} values={{ acct: <strong className='break-words'>{account}</strong> }}
/> />
</Text> </Text>
</HStack> </HStack>

View File

@ -124,7 +124,7 @@ const accountToCredentials = (account: Account): AccountCredentials => {
discoverable: account.discoverable, discoverable: account.discoverable,
bot: account.bot, bot: account.bot,
display_name: account.display_name, display_name: account.display_name,
note: account.source.get('note'), note: account.source.get('note', ''),
locked: account.locked, locked: account.locked,
fields_attributes: [...account.source.get<Iterable<AccountCredentialsField>>('fields', ImmutableList()).toJS()], fields_attributes: [...account.source.get<Iterable<AccountCredentialsField>>('fields', ImmutableList()).toJS()],
stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true, stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true,

View File

@ -207,7 +207,7 @@ const FeedCarousel = () => {
style={{ width: widthPerAvatar || 'auto' }} style={{ width: widthPerAvatar || 'auto' }}
key={idx} key={idx}
> >
<PlaceholderAvatar size={56} withText /> <PlaceholderAvatar size={56} withText className='py-3' />
</div> </div>
)) ))
) : ( ) : (

View File

@ -2,6 +2,7 @@ import React from 'react';
import { buildGroup, buildGroupRelationship } from 'soapbox/jest/factory'; import { buildGroup, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers'; import { render, screen } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { Group } from 'soapbox/types/entities'; import { Group } from 'soapbox/types/entities';
import GroupActionButton from '../group-action-button'; import GroupActionButton from '../group-action-button';
@ -45,7 +46,7 @@ describe('<GroupActionButton />', () => {
beforeEach(() => { beforeEach(() => {
group = buildGroup({ group = buildGroup({
relationship: buildGroupRelationship({ relationship: buildGroupRelationship({
member: null, member: false,
}), }),
}); });
}); });
@ -98,7 +99,7 @@ describe('<GroupActionButton />', () => {
relationship: buildGroupRelationship({ relationship: buildGroupRelationship({
requested: false, requested: false,
member: true, member: true,
role: 'owner', role: GroupRoles.OWNER,
}), }),
}); });
}); });
@ -116,7 +117,7 @@ describe('<GroupActionButton />', () => {
relationship: buildGroupRelationship({ relationship: buildGroupRelationship({
requested: false, requested: false,
member: true, member: true,
role: 'user', role: GroupRoles.USER,
}), }),
}); });
}); });

View File

@ -0,0 +1,46 @@
import React from 'react';
import { buildGroup } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { Group } from 'soapbox/types/entities';
import GroupHeader from '../group-header';
let group: Group;
describe('<GroupHeader />', () => {
describe('without a group', () => {
it('should render the blankslate', () => {
render(<GroupHeader group={null} />);
expect(screen.getByTestId('group-header-missing')).toBeInTheDocument();
});
});
describe('when the Group has been deleted', () => {
it('only shows name, header, and avatar', () => {
group = buildGroup({ display_name: 'my group', deleted_at: new Date().toISOString() });
render(<GroupHeader group={group} />);
expect(screen.queryAllByTestId('group-header-missing')).toHaveLength(0);
expect(screen.queryAllByTestId('group-actions')).toHaveLength(0);
expect(screen.queryAllByTestId('group-meta')).toHaveLength(0);
expect(screen.getByTestId('group-header-image')).toBeInTheDocument();
expect(screen.getByTestId('group-avatar')).toBeInTheDocument();
expect(screen.getByTestId('group-name')).toBeInTheDocument();
});
});
describe('with a valid Group', () => {
it('only shows all fields', () => {
group = buildGroup({ display_name: 'my group', deleted_at: null });
render(<GroupHeader group={group} />);
expect(screen.queryAllByTestId('group-header-missing')).toHaveLength(0);
expect(screen.getByTestId('group-actions')).toBeInTheDocument();
expect(screen.getByTestId('group-meta')).toBeInTheDocument();
expect(screen.getByTestId('group-header-image')).toBeInTheDocument();
expect(screen.getByTestId('group-avatar')).toBeInTheDocument();
expect(screen.getByTestId('group-name')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,320 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { __stub } from 'soapbox/api';
import { buildGroup, buildGroupMember, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import GroupMemberListItem from '../group-member-list-item';
describe('<GroupMemberListItem />', () => {
describe('account rendering', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the users avatar', async () => {
const group = buildGroup({
relationship: buildGroupRelationship(),
});
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('group-member-list-item')).toHaveTextContent(groupMember.account.display_name);
});
});
});
describe('role badge', () => {
const accountId = '4';
const group = buildGroup();
describe('when the user is an Owner', () => {
const groupMember = buildGroupMember({ role: GroupRoles.OWNER }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('role-badge')).toHaveTextContent('owner');
});
});
});
describe('when the user is an Admin', () => {
const groupMember = buildGroupMember({ role: GroupRoles.ADMIN }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('role-badge')).toHaveTextContent('admin');
});
});
});
describe('when the user is an User', () => {
const groupMember = buildGroupMember({ role: GroupRoles.USER }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render no correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.queryAllByTestId('role-badge')).toHaveLength(0);
});
});
});
});
describe('as a Group owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
describe('when the user has role of "user"', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
describe('when "canPromoteToAdmin is true', () => {
it('should render dropdown with correct Owner actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).toHaveTextContent('Assign admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
describe('when "canPromoteToAdmin is false', () => {
it('should prevent promoting user to Admin', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin={false} />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
await user.click(screen.getByTitle('Assign admin role'));
});
expect(screen.getByTestId('toast')).toHaveTextContent('Admin limit reached');
});
});
});
describe('when the user has role of "admin"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.ADMIN,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render dropdown with correct Owner actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).toHaveTextContent('Remove admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
});
describe('as a Group admin', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
describe('when the user has role of "user"', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render dropdown with correct Admin actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).not.toHaveTextContent('Assign admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
describe('when the user has role of "admin"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.ADMIN,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
describe('when the user has role of "owner"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.OWNER,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
});
describe('as a Group user', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.USER,
member: true,
}),
});
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
});

View File

@ -17,7 +17,7 @@ describe('<GroupOptionsButton />', () => {
requested: false, requested: false,
member: true, member: true,
blocked_by: true, blocked_by: true,
role: 'user', role: GroupRoles.USER,
}), }),
}); });
}); });

View File

@ -0,0 +1,123 @@
import React from 'react';
import { buildGroup, buildGroupTag, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import GroupTagListItem from '../group-tag-list-item';
describe('<GroupTagListItem />', () => {
describe('tag name', () => {
const name = 'hello';
it('should render the tag name', () => {
const group = buildGroup();
const tag = buildGroupTag({ name });
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-list-item')).toHaveTextContent(`#${name}`);
});
describe('when the tag is "visible"', () => {
const group = buildGroup();
const tag = buildGroupTag({ name, visible: true });
it('renders the default name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-900');
});
});
describe('when the tag is not "visible" and user is Owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
const tag = buildGroupTag({
name,
visible: false,
});
it('renders the subtle name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-400');
});
});
describe('when the tag is not "visible" and user is Admin or User', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
const tag = buildGroupTag({
name,
visible: false,
});
it('renders the subtle name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-900');
});
});
});
describe('pinning', () => {
describe('as an owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
describe('when the tag is visible', () => {
const tag = buildGroupTag({ visible: true });
it('renders the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('pin-icon')).toBeInTheDocument();
});
});
describe('when the tag is not visible', () => {
const tag = buildGroupTag({ visible: false });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
describe('as a non-owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
describe('when the tag is visible', () => {
const tag = buildGroupTag({ visible: true });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
describe('when the tag is not visible', () => {
const tag = buildGroupTag({ visible: false });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
});
});
});
});

View File

@ -3,11 +3,11 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups'; import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/api/hooks';
import { Button } from 'soapbox/components/ui'; import { Button } from 'soapbox/components/ui';
import { importEntities } from 'soapbox/entity-store/actions'; import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks'; import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/hooks/api';
import { queryClient } from 'soapbox/queries/client'; import { queryClient } from 'soapbox/queries/client';
import { GroupKeys } from 'soapbox/queries/groups'; import { GroupKeys } from 'soapbox/queries/groups';
import { GroupRoles } from 'soapbox/schemas/group-member'; import { GroupRoles } from 'soapbox/schemas/group-member';

View File

@ -34,7 +34,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
if (!group) { if (!group) {
return ( return (
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'> <div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6' data-testid='group-header-missing'>
<div> <div>
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' /> <div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' />
</div> </div>
@ -107,7 +107,10 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
} }
return ( return (
<div className='flex h-32 w-full items-center justify-center bg-gray-200 dark:bg-gray-800/30 md:rounded-t-xl lg:h-52'> <div
data-testid='group-header-image'
className='flex h-32 w-full items-center justify-center bg-gray-200 dark:bg-gray-800/30 md:rounded-t-xl lg:h-52'
>
{isHeaderMissing ? ( {isHeaderMissing ? (
<Icon src={require('@tabler/icons/photo-off.svg')} className='h-6 w-6 text-gray-500 dark:text-gray-700' /> <Icon src={require('@tabler/icons/photo-off.svg')} className='h-6 w-6 text-gray-500 dark:text-gray-700' />
) : header} ) : header}
@ -120,7 +123,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
<div className='relative'> <div className='relative'>
{renderHeader()} {renderHeader()}
<div className='absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2'> <div className='absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2' data-testid='group-avatar'>
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'> <a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
<GroupAvatar <GroupAvatar
group={group} group={group}
@ -136,11 +139,12 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
size='xl' size='xl'
weight='bold' weight='bold'
dangerouslySetInnerHTML={{ __html: group.display_name_html }} dangerouslySetInnerHTML={{ __html: group.display_name_html }}
data-testid='group-name'
/> />
{!isDeleted && ( {!isDeleted && (
<> <>
<Stack space={1} alignItems='center'> <Stack data-testid='group-meta' space={1} alignItems='center'>
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap> <HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
<GroupRelationship group={group} /> <GroupRelationship group={group} />
<GroupPrivacy group={group} /> <GroupPrivacy group={group} />
@ -154,7 +158,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
/> />
</Stack> </Stack>
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2} data-testid='group-actions'>
<GroupOptionsButton group={group} /> <GroupOptionsButton group={group} />
<GroupActionButton group={group} /> <GroupActionButton group={group} />
</HStack> </HStack>

View File

@ -4,6 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { groupKick } from 'soapbox/actions/groups'; import { groupKick } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { useAccount, useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/api/hooks';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu'; import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu';
import { HStack } from 'soapbox/components/ui'; import { HStack } from 'soapbox/components/ui';
@ -11,7 +12,6 @@ import { deleteEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account'; import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
import { useAppDispatch, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import { useAccount, useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/hooks/api';
import { GroupRoles } from 'soapbox/schemas/group-member'; import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
@ -180,7 +180,11 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
} }
return ( return (
<HStack alignItems='center' justifyContent='between'> <HStack
alignItems='center'
justifyContent='between'
data-testid='group-member-list-item'
>
<div className='w-full'> <div className='w-full'>
<Account account={member.account} withRelationship={false} /> <Account account={member.account} withRelationship={false} />
</div> </div>
@ -188,6 +192,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
{(isMemberOwner || isMemberAdmin) ? ( {(isMemberOwner || isMemberAdmin) ? (
<span <span
data-testid='role-badge'
className={ className={
clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', { clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', {
'bg-primary-200 text-primary-500': isMemberOwner, 'bg-primary-200 text-primary-500': isMemberOwner,

View File

@ -3,10 +3,10 @@ import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { initReport, ReportableEntities } from 'soapbox/actions/reports'; import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { useLeaveGroup } from 'soapbox/api/hooks';
import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu'; import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu';
import { IconButton } from 'soapbox/components/ui'; import { IconButton } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks'; import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { useLeaveGroup } from 'soapbox/hooks/api';
import { GroupRoles } from 'soapbox/schemas/group-member'; import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';

View File

@ -2,11 +2,11 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useUpdateGroupTag } from 'soapbox/api/hooks';
import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui'; import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui';
import { importEntities } from 'soapbox/entity-store/actions'; import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { useUpdateGroupTag } from 'soapbox/hooks/api';
import { GroupRoles } from 'soapbox/schemas/group-member'; import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { shortNumberFormat } from 'soapbox/utils/numbers';
@ -102,6 +102,7 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
require('@tabler/icons/pin.svg') require('@tabler/icons/pin.svg')
} }
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue' iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
data-testid='pin-icon'
/> />
</Tooltip> </Tooltip>
); );
@ -123,13 +124,18 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
}; };
return ( return (
<HStack alignItems='center' justifyContent='between'> <HStack
alignItems='center'
justifyContent='between'
data-testid='group-tag-list-item'
>
<Link to={`/group/${group.slug}/tag/${tag.id}`} className='group grow'> <Link to={`/group/${group.slug}/tag/${tag.id}`} className='group grow'>
<Stack> <Stack>
<Text <Text
weight='bold' weight='bold'
theme={(tag.visible || !isOwner) ? 'default' : 'subtle'} theme={(tag.visible || !isOwner) ? 'default' : 'subtle'}
className='group-hover:underline' className='group-hover:underline'
data-testid='group-tag-name'
> >
#{tag.name} #{tag.name}
</Text> </Text>
@ -137,7 +143,7 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
{intl.formatMessage(messages.total)}: {intl.formatMessage(messages.total)}:
{' '} {' '}
<Text size='sm' theme='inherit' weight='semibold' tag='span'> <Text size='sm' theme='inherit' weight='semibold' tag='span'>
{shortNumberFormat(tag.groups)} {shortNumberFormat(tag.uses)}
</Text> </Text>
</Text> </Text>
</Stack> </Stack>

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useGroup, useUpdateGroup } from 'soapbox/api/hooks';
import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui'; import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui';
import { useAppSelector, useInstance } from 'soapbox/hooks'; import { useAppSelector, useInstance } from 'soapbox/hooks';
import { useGroup, useUpdateGroup } from 'soapbox/hooks/api';
import { useImageField, useTextField } from 'soapbox/hooks/forms'; import { useImageField, useTextField } from 'soapbox/hooks/forms';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts'; import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';

View File

@ -2,11 +2,11 @@ import React, { useCallback, useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchGroupBlocks, groupUnblock } from 'soapbox/actions/groups'; import { fetchGroupBlocks, groupUnblock } from 'soapbox/actions/groups';
import { useGroup } from 'soapbox/api/hooks';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui'; import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { useGroup } from 'soapbox/hooks/api';
import { makeGetAccount } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';

View File

@ -2,11 +2,11 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { useGroup, useGroupMedia } from 'soapbox/api/hooks';
import LoadMore from 'soapbox/components/load-more'; import LoadMore from 'soapbox/components/load-more';
import MissingIndicator from 'soapbox/components/missing-indicator'; import MissingIndicator from 'soapbox/components/missing-indicator';
import { Column, Spinner } from 'soapbox/components/ui'; import { Column, Spinner } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { useGroup, useGroupMedia } from 'soapbox/hooks/api';
import MediaItem from '../account-gallery/components/media-item'; import MediaItem from '../account-gallery/components/media-item';

View File

@ -1,12 +1,10 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useGroup, useGroupMembers, useGroupMembershipRequests } from 'soapbox/api/hooks';
import { PendingItemsRow } from 'soapbox/components/pending-items-row'; import { PendingItemsRow } from 'soapbox/components/pending-items-row';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { useFeatures } from 'soapbox/hooks'; import { useFeatures } from 'soapbox/hooks';
import { useGroup } from 'soapbox/hooks/api';
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
import { GroupRoles } from 'soapbox/schemas/group-member'; import { GroupRoles } from 'soapbox/schemas/group-member';
import PlaceholderAccount from '../placeholder/components/placeholder-account'; import PlaceholderAccount from '../placeholder/components/placeholder-account';
@ -15,6 +13,7 @@ import GroupMemberListItem from './components/group-member-list-item';
import type { Group } from 'soapbox/types/entities'; import type { Group } from 'soapbox/types/entities';
interface IGroupMembers { interface IGroupMembers {
params: { groupId: string } params: { groupId: string }
} }

View File

@ -1,12 +1,12 @@
import { AxiosError } from 'axios';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useGroup, useGroupMembers, useGroupMembershipRequests } from 'soapbox/api/hooks';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons'; import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Column, HStack, Spinner } from 'soapbox/components/ui'; import { Column, HStack, Spinner } from 'soapbox/components/ui';
import { useGroup, useGroupMembershipRequests } from 'soapbox/hooks/api';
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
import { GroupRoles } from 'soapbox/schemas/group-member'; import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
@ -59,7 +59,7 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
const { group } = useGroup(id); const { group } = useGroup(id);
const { accounts, authorize, reject, isLoading } = useGroupMembershipRequests(id); const { accounts, authorize, reject, refetch, isLoading } = useGroupMembershipRequests(id);
const { invalidate } = useGroupMembers(id, GroupRoles.USER); const { invalidate } = useGroupMembers(id, GroupRoles.USER);
useEffect(() => { useEffect(() => {
@ -81,19 +81,35 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
} }
async function handleAuthorize(account: AccountEntity) { async function handleAuthorize(account: AccountEntity) {
try { return authorize(account.id)
await authorize(account.id); .then(() => Promise.resolve())
} catch (_e) { .catch((error: AxiosError) => {
toast.error(intl.formatMessage(messages.authorizeFail, { name: account.username })); refetch();
}
let message = intl.formatMessage(messages.authorizeFail, { name: account.username });
if (error.response?.status === 409) {
message = (error.response?.data as any).error;
}
toast.error(message);
return Promise.reject();
});
} }
async function handleReject(account: AccountEntity) { async function handleReject(account: AccountEntity) {
try { return reject(account.id)
await reject(account.id); .then(() => Promise.resolve())
} catch (_e) { .catch((error: AxiosError) => {
toast.error(intl.formatMessage(messages.rejectFail, { name: account.username })); refetch();
}
let message = intl.formatMessage(messages.rejectFail, { name: account.username });
if (error.response?.status === 409) {
message = (error.response?.data as any).error;
}
toast.error(message);
return Promise.reject();
});
} }
return ( return (

View File

@ -2,9 +2,9 @@ import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { expandGroupTimelineFromTag } from 'soapbox/actions/timelines'; import { expandGroupTimelineFromTag } from 'soapbox/actions/timelines';
import { useGroup, useGroupTag } from 'soapbox/api/hooks';
import { Column, Icon, Stack, Text } from 'soapbox/components/ui'; import { Column, Icon, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { useGroup, useGroupTag } from 'soapbox/hooks/api';
import Timeline from '../ui/components/timeline'; import Timeline from '../ui/components/timeline';

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useGroupTags } from 'soapbox/api/hooks';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Icon, Stack, Text } from 'soapbox/components/ui'; import { Icon, Stack, Text } from 'soapbox/components/ui';
import { useGroupTags } from 'soapbox/hooks/api';
import { useGroup } from 'soapbox/queries/groups'; import { useGroup } from 'soapbox/queries/groups';
import PlaceholderAccount from '../placeholder/components/placeholder-account'; import PlaceholderAccount from '../placeholder/components/placeholder-account';

View File

@ -6,10 +6,10 @@ import { Link } from 'react-router-dom';
import { groupCompose, setGroupTimelineVisible, uploadCompose } from 'soapbox/actions/compose'; import { groupCompose, setGroupTimelineVisible, uploadCompose } from 'soapbox/actions/compose';
import { connectGroupStream } from 'soapbox/actions/streaming'; import { connectGroupStream } from 'soapbox/actions/streaming';
import { expandGroupTimeline } from 'soapbox/actions/timelines'; import { expandGroupTimeline } from 'soapbox/actions/timelines';
import { useGroup } from 'soapbox/api/hooks';
import { Avatar, HStack, Icon, Stack, Text, Toggle } from 'soapbox/components/ui'; import { Avatar, HStack, Icon, Stack, Text, Toggle } from 'soapbox/components/ui';
import ComposeForm from 'soapbox/features/compose/components/compose-form'; import ComposeForm from 'soapbox/features/compose/components/compose-form';
import { useAppDispatch, useAppSelector, useDraggedFiles, useOwnAccount } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useDraggedFiles, useOwnAccount } from 'soapbox/hooks';
import { useGroup } from 'soapbox/hooks/api';
import Timeline from '../ui/components/timeline'; import Timeline from '../ui/components/timeline';

View File

@ -3,10 +3,10 @@ import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { useDeleteGroup, useGroup } from 'soapbox/api/hooks';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui'; import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui';
import { useAppDispatch, useBackend, useGroupsPath } from 'soapbox/hooks'; import { useAppDispatch, useBackend, useGroupsPath } from 'soapbox/hooks';
import { useDeleteGroup, useGroup } from 'soapbox/hooks/api';
import { GroupRoles } from 'soapbox/schemas/group-member'; import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
import { TRUTHSOCIAL } from 'soapbox/utils/features'; import { TRUTHSOCIAL } from 'soapbox/utils/features';
@ -16,16 +16,16 @@ import ColumnForbidden from '../ui/components/column-forbidden';
type RouteParams = { groupId: string }; type RouteParams = { groupId: string };
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' }, heading: { id: 'column.manage_group', defaultMessage: 'Manage Group' },
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' }, editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit Group' },
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending Requests' }, pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending Requests' },
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Banned Members' }, blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Banned Members' },
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' }, deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete Group' },
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' }, deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete Group' },
deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' }, deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' },
members: { id: 'group.tabs.members', defaultMessage: 'Members' }, members: { id: 'group.tabs.members', defaultMessage: 'Members' },
other: { id: 'settings.other', defaultMessage: 'Other options' }, other: { id: 'settings.other', defaultMessage: 'Other Options' },
deleteSuccess: { id: 'group.delete.success', defaultMessage: 'Group successfully deleted' }, deleteSuccess: { id: 'group.delete.success', defaultMessage: 'Group successfully deleted' },
}); });

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { usePopularGroups } from 'soapbox/api/hooks';
import Link from 'soapbox/components/link'; import Link from 'soapbox/components/link';
import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui'; import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui';
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover'; import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups';
import GroupGridItem from './group-grid-item'; import GroupGridItem from './group-grid-item';

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { usePopularTags } from 'soapbox/api/hooks';
import Link from 'soapbox/components/link'; import Link from 'soapbox/components/link';
import { HStack, Stack, Text } from 'soapbox/components/ui'; import { HStack, Stack, Text } from 'soapbox/components/ui';
import { usePopularTags } from 'soapbox/hooks/api';
import TagListItem from './tag-list-item'; import TagListItem from './tag-list-item';

View File

@ -3,8 +3,8 @@ import React, { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
import { useGroupSearch } from 'soapbox/api/hooks';
import { HStack, Stack, Text } from 'soapbox/components/ui'; import { HStack, Stack, Text } from 'soapbox/components/ui';
import { useGroupSearch } from 'soapbox/hooks/api';
import GroupGridItem from '../group-grid-item'; import GroupGridItem from '../group-grid-item';
import GroupListItem from '../group-list-item'; import GroupListItem from '../group-list-item';

View File

@ -1,10 +1,10 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useGroupSearch } from 'soapbox/api/hooks';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search'; import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
import { useDebounce, useOwnAccount } from 'soapbox/hooks'; import { useDebounce, useOwnAccount } from 'soapbox/hooks';
import { useGroupSearch } from 'soapbox/hooks/api';
import { saveGroupSearch } from 'soapbox/utils/groups'; import { saveGroupSearch } from 'soapbox/utils/groups';
import Blankslate from './blankslate'; import Blankslate from './blankslate';

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useSuggestedGroups } from 'soapbox/api/hooks';
import Link from 'soapbox/components/link'; import Link from 'soapbox/components/link';
import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui'; import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui';
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover'; import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups';
import GroupGridItem from './group-grid-item'; import GroupGridItem from './group-grid-item';

View File

@ -3,11 +3,11 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { useGroups } from 'soapbox/api/hooks';
import GroupCard from 'soapbox/components/group-card'; import GroupCard from 'soapbox/components/group-card';
import ScrollableList from 'soapbox/components/scrollable-list'; import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Input, Stack, Text } from 'soapbox/components/ui'; import { Button, Input, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useDebounce, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useDebounce, useFeatures } from 'soapbox/hooks';
import { useGroups } from 'soapbox/hooks/api';
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions'; import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card'; import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';

View File

@ -3,8 +3,8 @@ import React, { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
import { usePopularGroups } from 'soapbox/api/hooks';
import { Column } from 'soapbox/components/ui'; import { Column } from 'soapbox/components/ui';
import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups';
import GroupGridItem from './components/discover/group-grid-item'; import GroupGridItem from './components/discover/group-grid-item';
import GroupListItem from './components/discover/group-list-item'; import GroupListItem from './components/discover/group-list-item';

View File

@ -3,8 +3,8 @@ import React, { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
import { useSuggestedGroups } from 'soapbox/api/hooks';
import { Column } from 'soapbox/components/ui'; import { Column } from 'soapbox/components/ui';
import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups';
import GroupGridItem from './components/discover/group-grid-item'; import GroupGridItem from './components/discover/group-grid-item';
import GroupListItem from './components/discover/group-list-item'; import GroupListItem from './components/discover/group-list-item';

View File

@ -2,8 +2,8 @@ import clsx from 'clsx';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
import { useGroupTag, useGroupsFromTag } from 'soapbox/api/hooks';
import { Column, HStack, Icon } from 'soapbox/components/ui'; import { Column, HStack, Icon } from 'soapbox/components/ui';
import { useGroupTag, useGroupsFromTag } from 'soapbox/hooks/api';
import GroupGridItem from './components/discover/group-grid-item'; import GroupGridItem from './components/discover/group-grid-item';
import GroupListItem from './components/discover/group-list-item'; import GroupListItem from './components/discover/group-list-item';

View File

@ -3,8 +3,8 @@ import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { usePopularTags } from 'soapbox/api/hooks';
import { Column, Text } from 'soapbox/components/ui'; import { Column, Text } from 'soapbox/components/ui';
import { usePopularTags } from 'soapbox/hooks/api';
import TagListItem from './components/discover/tag-list-item'; import TagListItem from './components/discover/tag-list-item';

View File

@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react'; import React from 'react';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
@ -5,10 +6,11 @@ import { Stack } from 'soapbox/components/ui';
interface IPlaceholderAvatar { interface IPlaceholderAvatar {
size: number size: number
withText?: boolean withText?: boolean
className?: string
} }
/** Fake avatar to display while data is loading. */ /** Fake avatar to display while data is loading. */
const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size, withText = false }) => { const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size, withText = false, className }) => {
const style = React.useMemo(() => { const style = React.useMemo(() => {
if (!size) { if (!size) {
return {}; return {};
@ -21,7 +23,10 @@ const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size, withText = fals
}, [size]); }, [size]);
return ( return (
<Stack space={2} className='animate-pulse py-3 text-center'> <Stack
space={2}
className={clsx('animate-pulse text-center', className)}
>
<div <div
className='mx-auto block rounded-full bg-primary-50 dark:bg-primary-800' className='mx-auto block rounded-full bg-primary-50 dark:bg-primary-800'
style={style} style={style}

View File

@ -8,11 +8,11 @@ import PlaceholderDisplayName from './placeholder-display-name';
import PlaceholderStatusContent from './placeholder-status-content'; import PlaceholderStatusContent from './placeholder-status-content';
interface IPlaceholderStatus { interface IPlaceholderStatus {
variant?: 'rounded' | 'slim' variant?: 'rounded' | 'slim' | 'default'
} }
/** Fake status to display while data is loading. */ /** Fake status to display while data is loading. */
const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ variant = 'rounded' }) => ( const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ variant }) => (
<div <div
className={clsx({ className={clsx({
'status-placeholder bg-white dark:bg-primary-900': true, 'status-placeholder bg-white dark:bg-primary-900': true,

View File

@ -74,7 +74,7 @@ const Settings = () => {
<CardBody> <CardBody>
<List> <List>
<ListItem label={intl.formatMessage(messages.editProfile)} onClick={navigateToEditProfile}> <ListItem label={intl.formatMessage(messages.editProfile)} onClick={navigateToEditProfile}>
<span>{displayName}</span> <span className='max-w-full truncate'>{displayName}</span>
</ListItem> </ListItem>
</List> </List>
</CardBody> </CardBody>

View File

@ -125,7 +125,6 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
<Account <Account
key={account.id} key={account.id}
account={account} account={account}
timestamp={actualStatus.created_at}
avatarSize={42} avatarSize={42}
hideActions hideActions
approvalStatus={actualStatus.approval_status} approvalStatus={actualStatus.approval_status}

View File

@ -230,7 +230,7 @@ const InteractionCounter: React.FC<IInteractionCounter> = ({ count, onClick, chi
} }
> >
<HStack space={1} alignItems='center'> <HStack space={1} alignItems='center'>
<Text theme='primary' weight='bold'> <Text weight='bold'>
{shortNumberFormat(count)} {shortNumberFormat(count)}
</Text> </Text>

View File

@ -46,7 +46,7 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
// @ts-ignore FIXME // @ts-ignore FIXME
<StatusContainer {...props} showGroup={false} /> <StatusContainer {...props} showGroup={false} />
) : ( ) : (
<PlaceholderStatus variant='slim' /> <PlaceholderStatus variant='default' />
)} )}
</div> </div>
); );

View File

@ -1,8 +1,9 @@
// import { Map as ImmutableMap } from 'immutable';
import React from 'react'; import React from 'react';
import { render, screen } from '../../../../jest/test-helpers'; import { buildRelationship } from 'soapbox/jest/factory';
import { normalizeAccount, normalizeRelationship } from '../../../../normalizers'; import { render, screen } from 'soapbox/jest/test-helpers';
import { normalizeAccount } from 'soapbox/normalizers';
import SubscribeButton from '../subscription-button'; import SubscribeButton from '../subscription-button';
import type { ReducerAccount } from 'soapbox/reducers/accounts'; import type { ReducerAccount } from 'soapbox/reducers/accounts';
@ -19,162 +20,10 @@ describe('<SubscribeButton />', () => {
describe('with "accountNotifies" disabled', () => { describe('with "accountNotifies" disabled', () => {
it('renders nothing', () => { it('renders nothing', () => {
const account = normalizeAccount({ ...justin, relationship: normalizeRelationship({ following: true }) }) as ReducerAccount; const account = normalizeAccount({ ...justin, relationship: buildRelationship({ following: true }) }) as ReducerAccount;
render(<SubscribeButton account={account} />, undefined, store); render(<SubscribeButton account={account} />, undefined, store);
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
}); });
}); });
// describe('with "accountNotifies" enabled', () => {
// beforeEach(() => {
// store = {
// ...store,
// instance: normalizeInstance({
// version: '3.4.1 (compatible; TruthSocial 1.0.0)',
// software: 'TRUTHSOCIAL',
// pleroma: ImmutableMap({}),
// }),
// };
// });
// describe('when the relationship is requested', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ requested: true }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button')).toBeInTheDocument();
// });
// describe('when the user "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: true }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Unsubscribe to notifications from @${account.acct}`);
// });
// });
// describe('when the user is not "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: false }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`);
// });
// });
// });
// describe('when the user is not following the account', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: false }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders nothing', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
// });
// });
// describe('when the user is following the account', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: true }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button')).toBeInTheDocument();
// });
// describe('when the user "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: true }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Unsubscribe to notifications from @${account.acct}`);
// });
// });
// describe('when the user is not "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: false }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`);
// });
// });
// });
// });
}); });

View File

@ -4,9 +4,9 @@ import { useLocation, useRouteMatch } from 'react-router-dom';
import { groupComposeModal } from 'soapbox/actions/compose'; import { groupComposeModal } from 'soapbox/actions/compose';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { useGroupLookup } from 'soapbox/api/hooks';
import { Avatar, Button, HStack } from 'soapbox/components/ui'; import { Avatar, Button, HStack } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { useGroupLookup } from 'soapbox/hooks/api/groups/useGroupLookup';
const ComposeButton = () => { const ComposeButton = () => {
const location = useLocation(); const location = useLocation();
@ -25,7 +25,6 @@ const HomeComposeButton = () => {
return ( return (
<Button <Button
theme='accent' theme='accent'
icon={require('@tabler/icons/pencil-plus.svg')}
size='lg' size='lg'
onClick={onOpenCompose} onClick={onOpenCompose}
block block

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