Merge remote-tracking branch 'soapbox/develop' into lexical
This commit is contained in:
commit
65db6f503d
57 changed files with 1148 additions and 106 deletions
|
@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Hashtags: let users follow hashtags (Mastodon, Akkoma).
|
||||
- Posts: Support posts filtering on recent Mastodon versions
|
||||
- Reactions: Support custom emoji reactions
|
||||
- Compatbility: Support Mastodon v2 timeline filters.
|
||||
|
|
|
@ -242,7 +242,8 @@ export const fetchOwnAccounts = () =>
|
|||
return state.auth.users.forEach((user) => {
|
||||
const account = state.accounts.get(user.id);
|
||||
if (!account) {
|
||||
dispatch(verifyCredentials(user.access_token, user.url));
|
||||
dispatch(verifyCredentials(user.access_token, user.url))
|
||||
.catch(() => console.warn(`Failed to load account: ${user.url}`));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
201
app/soapbox/actions/tags.ts
Normal file
201
app/soapbox/actions/tags.ts
Normal file
|
@ -0,0 +1,201 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||
const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||
const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||
|
||||
const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||
const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||
const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||
|
||||
const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
|
||||
const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
|
||||
const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
|
||||
|
||||
const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchHashtagRequest());
|
||||
|
||||
api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => {
|
||||
dispatch(fetchHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(fetchHashtagFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchHashtagRequest = () => ({
|
||||
type: HASHTAG_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_FETCH_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const fetchHashtagFail = (error: AxiosError) => ({
|
||||
type: HASHTAG_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(followHashtagRequest(name));
|
||||
|
||||
api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
|
||||
dispatch(followHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(followHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
const followHashtagRequest = (name: string) => ({
|
||||
type: HASHTAG_FOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
const followHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_FOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const followHashtagFail = (name: string, error: AxiosError) => ({
|
||||
type: HASHTAG_FOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(unfollowHashtagRequest(name));
|
||||
|
||||
api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
|
||||
dispatch(unfollowHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(unfollowHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
const unfollowHashtagRequest = (name: string) => ({
|
||||
type: HASHTAG_UNFOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_UNFOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const unfollowHashtagFail = (name: string, error: AxiosError) => ({
|
||||
type: HASHTAG_UNFOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get('/api/v1/followed_tags').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchFollowedHashtagsFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchFollowedHashtagsRequest = () => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtagsFail = (error: AxiosError) => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().followed_tags.next;
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowedHashtagsFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandFollowedHashtagsRequest = () => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
});
|
||||
|
||||
const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandFollowedHashtagsFail = (error: AxiosError) => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
|
||||
export {
|
||||
HASHTAG_FETCH_REQUEST,
|
||||
HASHTAG_FETCH_SUCCESS,
|
||||
HASHTAG_FETCH_FAIL,
|
||||
HASHTAG_FOLLOW_REQUEST,
|
||||
HASHTAG_FOLLOW_SUCCESS,
|
||||
HASHTAG_FOLLOW_FAIL,
|
||||
HASHTAG_UNFOLLOW_REQUEST,
|
||||
HASHTAG_UNFOLLOW_SUCCESS,
|
||||
HASHTAG_UNFOLLOW_FAIL,
|
||||
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
fetchHashtag,
|
||||
fetchHashtagRequest,
|
||||
fetchHashtagSuccess,
|
||||
fetchHashtagFail,
|
||||
followHashtag,
|
||||
followHashtagRequest,
|
||||
followHashtagSuccess,
|
||||
followHashtagFail,
|
||||
unfollowHashtag,
|
||||
unfollowHashtagRequest,
|
||||
unfollowHashtagSuccess,
|
||||
unfollowHashtagFail,
|
||||
fetchFollowedHashtags,
|
||||
fetchFollowedHashtagsRequest,
|
||||
fetchFollowedHashtagsSuccess,
|
||||
fetchFollowedHashtagsFail,
|
||||
expandFollowedHashtags,
|
||||
expandFollowedHashtagsRequest,
|
||||
expandFollowedHashtagsSuccess,
|
||||
expandFollowedHashtagsFail,
|
||||
};
|
41
app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts
Normal file
41
app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroup } from '../useGroup';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
|
||||
describe('useGroup hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroup(group.id));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.group?.id).toBe(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${group.id}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroup(group.id));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.group).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroupLookup } from '../useGroupLookup';
|
||||
|
||||
const group = buildGroup({ id: '1', slug: 'soapbox' });
|
||||
|
||||
describe('useGroupLookup hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupLookup(group.slug));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entity?.id).toBe(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupLookup(group.slug));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
44
app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts
Normal file
44
app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildStatus } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroupMedia } from '../useGroupMedia';
|
||||
|
||||
const status = buildStatus();
|
||||
const groupId = '1';
|
||||
|
||||
describe('useGroupMedia hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupMedia(groupId));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entities.length).toBe(1);
|
||||
expect(result.current.entities[0].id).toBe(status.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupMedia(groupId));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entities.length).toBe(0);
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroupMember } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useGroupMembers } from '../useGroupMembers';
|
||||
|
||||
const groupMember = buildGroupMember();
|
||||
const groupId = '1';
|
||||
|
||||
describe('useGroupMembers hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groupMembers.length).toBe(1);
|
||||
expect(result.current.groupMembers[0].id).toBe(groupMember.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groupMembers.length).toBe(0);
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
47
app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts
Normal file
47
app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import { useGroups } from '../useGroups';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
const store = {
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
};
|
||||
|
||||
describe('useGroups hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [group]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(useGroups, undefined, store);
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(useGroups, undefined, store);
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,15 +3,24 @@ import { useEntityLookup } from 'soapbox/entity-store/hooks';
|
|||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationship } from './useGroupRelationship';
|
||||
|
||||
function useGroupLookup(slug: string) {
|
||||
const api = useApi();
|
||||
|
||||
return useEntityLookup(
|
||||
const { entity: group, ...result } = useEntityLookup(
|
||||
Entities.GROUPS,
|
||||
(group) => group.slug === slug,
|
||||
() => api.get(`/api/v1/groups/lookup?name=${slug}`),
|
||||
{ schema: groupSchema },
|
||||
);
|
||||
|
||||
const { entity: relationship } = useGroupRelationship(group?.id);
|
||||
|
||||
return {
|
||||
...result,
|
||||
entity: group ? { ...group, relationship: relationship || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupLookup };
|
|
@ -1,7 +1,10 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { statusSchema } from 'soapbox/schemas/status';
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { toSchema } from 'soapbox/utils/normalizers';
|
||||
|
||||
const statusSchema = toSchema(normalizeStatus);
|
||||
|
||||
function useGroupMedia(groupId: string) {
|
||||
const api = useApi();
|
||||
|
|
|
@ -7,14 +7,17 @@ import { useEntity } from 'soapbox/entity-store/hooks';
|
|||
import { useApi, useAppDispatch } from 'soapbox/hooks';
|
||||
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupRelationship(groupId: string) {
|
||||
function useGroupRelationship(groupId: string | undefined) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, groupId],
|
||||
[Entities.GROUP_RELATIONSHIPS, groupId as string],
|
||||
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
|
||||
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
|
||||
{
|
||||
enabled: !!groupId,
|
||||
schema: z.array(groupRelationshipSchema).transform(arr => arr[0]),
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -21,6 +21,7 @@ import StatusMedia from './status-media';
|
|||
import StatusReplyMentions from './status-reply-mentions';
|
||||
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
|
||||
import StatusInfo from './statuses/status-info';
|
||||
import Tombstone from './tombstone';
|
||||
import { Card, Icon, Stack, Text } from './ui';
|
||||
|
||||
import type {
|
||||
|
@ -388,6 +389,17 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
|
||||
const isUnderReview = actualStatus.visibility === 'self';
|
||||
const isSensitive = actualStatus.hidden;
|
||||
const isSoftDeleted = status.tombstone?.reason === 'deleted';
|
||||
|
||||
if (isSoftDeleted) {
|
||||
return (
|
||||
<Tombstone
|
||||
id={status.id}
|
||||
onMoveUp={(id) => onMoveUp ? onMoveUp(id) : null}
|
||||
onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} data-testid='status'>
|
||||
|
|
|
@ -19,10 +19,17 @@ const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown }) => {
|
|||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className='focusable flex items-center justify-center border border-solid border-gray-200 bg-gray-100 p-9 dark:border-gray-800 dark:bg-gray-900 sm:rounded-xl' tabIndex={0}>
|
||||
<Text>
|
||||
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />
|
||||
</Text>
|
||||
<div className='h-16'>
|
||||
<div
|
||||
className='focusable flex h-[42px] items-center justify-center rounded-lg border-2 border-gray-200 text-center'
|
||||
>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='statuses.tombstone'
|
||||
defaultMessage='One or more posts are unavailable.'
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
|
|
|
@ -51,6 +51,8 @@ export interface IColumn {
|
|||
withHeader?: boolean
|
||||
/** Extra class name for top <div> element. */
|
||||
className?: string
|
||||
/** Extra class name for the <CardBody> element. */
|
||||
bodyClassName?: string
|
||||
/** Ref forwarded to column. */
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
/** Children to display in the column. */
|
||||
|
@ -63,7 +65,7 @@ export interface IColumn {
|
|||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, action, size } = props;
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props;
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
|
@ -109,7 +111,7 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
/>
|
||||
)}
|
||||
|
||||
<CardBody>
|
||||
<CardBody className={bodyClassName}>
|
||||
{children}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
|
|
@ -17,11 +17,16 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
|||
src: string
|
||||
/** Width and height of the icon in pixels. */
|
||||
size?: number
|
||||
/** Override the data-testid */
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
/** Renders and SVG icon with optional counter. */
|
||||
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
|
||||
<div className='relative flex shrink-0 flex-col' data-testid='icon'>
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col'
|
||||
data-testid={filteredProps['data-testid'] || 'icon'}
|
||||
>
|
||||
{count ? (
|
||||
<span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'>
|
||||
<Counter count={count} countMax={countMax} />
|
||||
|
|
|
@ -14,6 +14,8 @@ interface UseEntityOpts<TEntity extends Entity> {
|
|||
schema?: EntitySchema<TEntity>
|
||||
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
||||
refetch?: boolean
|
||||
/** A flag to potentially disable sending requests to the API. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function useEntity<TEntity extends Entity>(
|
||||
|
@ -31,6 +33,7 @@ function useEntity<TEntity extends Entity>(
|
|||
|
||||
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
|
||||
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isLoading = isFetching && !entity;
|
||||
|
||||
const fetchEntity = async () => {
|
||||
|
@ -44,10 +47,11 @@ function useEntity<TEntity extends Entity>(
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (!entity || opts.refetch) {
|
||||
fetchEntity();
|
||||
}
|
||||
}, []);
|
||||
}, [isEnabled]);
|
||||
|
||||
return {
|
||||
entity,
|
||||
|
|
|
@ -30,6 +30,7 @@ import { queryClient } from 'soapbox/queries/client';
|
|||
import toast from 'soapbox/toast';
|
||||
import { Account } from 'soapbox/types/entities';
|
||||
import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts';
|
||||
import copy from 'soapbox/utils/copy';
|
||||
import { MASTODON, parseVersion } from 'soapbox/utils/features';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -44,6 +45,7 @@ const messages = defineMessages({
|
|||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to profile' },
|
||||
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
|
||||
media: { id: 'account.media', defaultMessage: 'Media' },
|
||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
|
||||
|
@ -273,6 +275,10 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleCopy: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
copy(account.url);
|
||||
};
|
||||
|
||||
const makeMenu = () => {
|
||||
const menu: Menu = [];
|
||||
|
||||
|
@ -306,8 +312,22 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: handleCopy,
|
||||
icon: require('@tabler/icons/clipboard-copy.svg'),
|
||||
});
|
||||
|
||||
if (!ownAccount) return menu;
|
||||
|
||||
if (features.searchFromAccount) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(account.id === ownAccount.id ? messages.searchSelf : messages.search, { name: account.username }),
|
||||
action: onSearch,
|
||||
icon: require('@tabler/icons/search.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (menu.length) {
|
||||
menu.push(null);
|
||||
}
|
||||
|
@ -323,13 +343,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
to: '/settings',
|
||||
icon: require('@tabler/icons/settings.svg'),
|
||||
});
|
||||
if (features.searchFromAccount) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.searchSelf, { name: account.username }),
|
||||
action: onSearch,
|
||||
icon: require('@tabler/icons/search.svg'),
|
||||
});
|
||||
}
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mutes),
|
||||
|
@ -386,8 +399,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
icon: require('@tabler/icons/user-check.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
} else if (features.lists && features.unrestrictedLists) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
|
@ -396,13 +407,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (features.searchFromAccount) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.search, { name: account.username }),
|
||||
action: onSearch,
|
||||
icon: require('@tabler/icons/search.svg'),
|
||||
});
|
||||
}
|
||||
menu.push(null);
|
||||
|
||||
if (features.removeFromFollowers && account.relationship?.followed_by) {
|
||||
menu.push({
|
||||
|
|
|
@ -57,7 +57,7 @@ const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
|
|||
<FormGroup
|
||||
labelText={passwordLabel}
|
||||
hintText={
|
||||
<Link to='/reset-password' className='hover:underline'>
|
||||
<Link to='/reset-password' className='hover:underline' tabIndex={-1}>
|
||||
<FormattedMessage
|
||||
id='login.reset_password_hint'
|
||||
defaultMessage='Trouble logging in?'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -135,6 +135,18 @@ const Search = (props: ISearch) => {
|
|||
componentProps.autoSelect = false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const newPath = history.location.pathname;
|
||||
const shouldPersistSearch = !!newPath.match(/@.+\/posts\/\d+/g)
|
||||
|| !!newPath.match(/\/tags\/.+/g);
|
||||
|
||||
if (!shouldPersistSearch) {
|
||||
dispatch(changeSearch(''));
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<label htmlFor='search' className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
|
||||
|
|
52
app/soapbox/features/followed_tags/index.tsx
Normal file
52
app/soapbox/features/followed_tags/index.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchFollowedHashtags, expandFollowedHashtags } from 'soapbox/actions/tags';
|
||||
import Hashtag from 'soapbox/components/hashtag';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandFollowedHashtags());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const FollowedTags = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFollowedHashtags());
|
||||
}, []);
|
||||
|
||||
const tags = useAppSelector((state => state.followed_tags.items));
|
||||
const isLoading = useAppSelector((state => state.followed_tags.isLoading));
|
||||
const hasMore = useAppSelector((state => !!state.followed_tags.next));
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage="You haven't followed any hashtag yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
placeholderComponent={PlaceholderHashtag}
|
||||
placeholderCount={5}
|
||||
itemClassName='pb-3'
|
||||
>
|
||||
{tags.map(tag => <Hashtag key={tag.name} hashtag={tag} />)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowedTags;
|
|
@ -91,31 +91,32 @@ describe('<GroupTagListItem />', () => {
|
|||
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('as a non-owner', () => {
|
||||
const group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
role: GroupRoles.ADMIN,
|
||||
member: true,
|
||||
}),
|
||||
describe('as a non-owner', () => {
|
||||
const group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
role: GroupRoles.ADMIN,
|
||||
member: true,
|
||||
}),
|
||||
});
|
||||
|
||||
describe('when the tag is pinned', () => {
|
||||
const tag = buildGroupTag({ pinned: true, visible: true });
|
||||
|
||||
it('does render the pin icon', () => {
|
||||
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
|
||||
screen.debug();
|
||||
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the tag is visible', () => {
|
||||
const tag = buildGroupTag({ visible: true });
|
||||
describe('when the tag is not pinned', () => {
|
||||
const tag = buildGroupTag({ pinned: false, 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);
|
||||
});
|
||||
it('does not render the pin icon', () => {
|
||||
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
|
||||
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -55,6 +55,12 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
|
|||
: intl.formatMessage(messages.joinSuccess),
|
||||
);
|
||||
},
|
||||
onError(error) {
|
||||
const message = (error.response?.data as any).error;
|
||||
if (message) {
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
|
|
|
@ -99,7 +99,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
|
||||
if (!isDefaultHeader(group.header)) {
|
||||
header = (
|
||||
<a href={group.header} onClick={handleHeaderClick} target='_blank' className='relative'>
|
||||
<a href={group.header} onClick={handleHeaderClick} target='_blank' className='relative w-full'>
|
||||
{header}
|
||||
</a>
|
||||
);
|
||||
|
@ -155,6 +155,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
theme='muted'
|
||||
align='center'
|
||||
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
|
||||
className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ const messages = defineMessages({
|
|||
leave: { id: 'group.leave.label', defaultMessage: 'Leave' },
|
||||
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
|
||||
report: { id: 'group.report.label', defaultMessage: 'Report' },
|
||||
share: { id: 'group.share.label', defaultMessage: 'Share' },
|
||||
});
|
||||
|
||||
interface IGroupActionButton {
|
||||
|
@ -35,6 +36,15 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
|
|||
const isAdmin = group.relationship?.role === GroupRoles.ADMIN;
|
||||
const isBlocked = group.relationship?.blocked_by;
|
||||
|
||||
const handleShare = () => {
|
||||
navigator.share({
|
||||
text: group.display_name,
|
||||
url: group.url,
|
||||
}).catch((e) => {
|
||||
if (e.name !== 'AbortError') console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
|
@ -49,6 +59,7 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
|
|||
}));
|
||||
|
||||
const menu: Menu = useMemo(() => {
|
||||
const canShare = 'share' in navigator;
|
||||
const items = [];
|
||||
|
||||
if (isMember || isAdmin) {
|
||||
|
@ -59,6 +70,14 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (canShare) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.share),
|
||||
icon: require('@tabler/icons/share.svg'),
|
||||
action: handleShare,
|
||||
});
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.leave),
|
||||
|
|
|
@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useUpdateGroupTag } from 'soapbox/api/hooks';
|
||||
import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui';
|
||||
import { HStack, Icon, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui';
|
||||
import { importEntities } from 'soapbox/entity-store/actions';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
@ -84,6 +84,20 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
|
|||
};
|
||||
|
||||
const renderPinIcon = () => {
|
||||
if (!isOwner && tag.pinned) {
|
||||
return (
|
||||
<Icon
|
||||
src={require('@tabler/icons/pin-filled.svg')}
|
||||
className='h-5 w-5 text-gray-600'
|
||||
data-testid='pin-icon'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isPinnable) {
|
||||
return (
|
||||
<Tooltip
|
||||
|
@ -149,12 +163,12 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
|
|||
</Stack>
|
||||
</Link>
|
||||
|
||||
{isOwner ? (
|
||||
<HStack alignItems='center' space={2}>
|
||||
{tag.visible ? (
|
||||
renderPinIcon()
|
||||
) : null}
|
||||
<HStack alignItems='center' space={2}>
|
||||
{tag.visible ? (
|
||||
renderPinIcon()
|
||||
) : null}
|
||||
|
||||
{isOwner ? (
|
||||
<Tooltip
|
||||
text={
|
||||
tag.visible ?
|
||||
|
@ -173,8 +187,8 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
|
|||
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
) : null}
|
||||
) : null}
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Input, Streamfield } from 'soapbox/components/ui';
|
||||
|
@ -36,15 +36,19 @@ const GroupTagsField: React.FC<IGroupTagsField> = ({ tags, onChange, onAddItem,
|
|||
const HashtagField: StreamfieldComponent<string> = ({ value, onChange, autoFocus = false }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const formattedValue = useMemo(() => {
|
||||
return `#${value}`;
|
||||
}, [value]);
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
onChange(target.value);
|
||||
onChange(target.value.replace('#', ''));
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
outerClassName='w-full'
|
||||
type='text'
|
||||
value={value}
|
||||
value={formattedValue}
|
||||
onChange={handleChange}
|
||||
placeholder={intl.formatMessage(messages.hashtagPlaceholder)}
|
||||
autoFocus={autoFocus}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useGroup, useUpdateGroup } from 'soapbox/api/hooks';
|
||||
import { useGroup, useGroupTags, useUpdateGroup } from 'soapbox/api/hooks';
|
||||
import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useInstance } from 'soapbox/hooks';
|
||||
import { useImageField, useTextField } from 'soapbox/hooks/forms';
|
||||
|
@ -36,6 +36,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
|
|||
|
||||
const { group, isLoading } = useGroup(groupId);
|
||||
const { updateGroup } = useUpdateGroup(groupId);
|
||||
const { invalidate } = useGroupTags(groupId);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [tags, setTags] = useState<string[]>(['']);
|
||||
|
@ -64,6 +65,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
|
|||
tags,
|
||||
}, {
|
||||
onSuccess() {
|
||||
invalidate();
|
||||
toast.success(intl.formatMessage(messages.groupSaved));
|
||||
},
|
||||
onError(error) {
|
||||
|
|
|
@ -36,7 +36,7 @@ const GroupTopics: React.FC<IGroupTopics> = (props) => {
|
|||
showLoading={!group || isLoading && tags.length === 0}
|
||||
placeholderComponent={PlaceholderAccount}
|
||||
placeholderCount={3}
|
||||
className='divide-y divide-solid divide-gray-300'
|
||||
className='divide-y divide-solid divide-gray-300 dark:divide-gray-800'
|
||||
itemClassName='py-3 last:pb-0'
|
||||
emptyMessage={
|
||||
<Stack space={4} className='pt-6' justifyContent='center' alignItems='center'>
|
||||
|
|
|
@ -19,7 +19,7 @@ const GroupLinkPreview: React.FC<IGroupLinkPreview> = ({ card }) => {
|
|||
return (
|
||||
<Stack className='cursor-default overflow-hidden rounded-lg border border-gray-300 text-center dark:border-gray-800'>
|
||||
<div
|
||||
className='-mb-8 h-32 w-full bg-center'
|
||||
className='-mb-8 h-32 w-full bg-cover bg-center'
|
||||
style={{ backgroundImage: `url(${group.header})` }}
|
||||
/>
|
||||
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { connectHashtagStream } from 'soapbox/actions/streaming';
|
||||
import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags';
|
||||
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Column, Toggle } from 'soapbox/components/ui';
|
||||
import Timeline from 'soapbox/features/ui/components/timeline';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import type { Tag as TagEntity } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -32,9 +34,11 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
const intl = useIntl();
|
||||
const id = params?.id || '';
|
||||
const tags = params?.tags || { any: [], all: [], none: [] };
|
||||
|
||||
|
||||
const features = useFeatures();
|
||||
const dispatch = useAppDispatch();
|
||||
const disconnects = useRef<(() => void)[]>([]);
|
||||
const tag = useAppSelector((state) => state.tags.get(id));
|
||||
|
||||
// Mastodon supports displaying results from multiple hashtags.
|
||||
// https://github.com/mastodon/mastodon/issues/6359
|
||||
|
@ -88,9 +92,18 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
dispatch(expandHashtagTimeline(id, { maxId, tags }));
|
||||
};
|
||||
|
||||
const handleFollow = () => {
|
||||
if (tag?.following) {
|
||||
dispatch(unfollowHashtag(id));
|
||||
} else {
|
||||
dispatch(followHashtag(id));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
subscribe();
|
||||
dispatch(expandHashtagTimeline(id, { tags }));
|
||||
dispatch(fetchHashtag(id));
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
|
@ -105,7 +118,19 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
}, [id]);
|
||||
|
||||
return (
|
||||
<Column label={title()} transparent>
|
||||
<Column bodyClassName='space-y-3' label={title()} transparent>
|
||||
{features.followHashtags && (
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='hashtag.follow' defaultMessage='Follow hashtag' />}
|
||||
>
|
||||
<Toggle
|
||||
checked={tag?.following}
|
||||
onChange={handleFollow}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
<Timeline
|
||||
scrollKey='hashtag_timeline'
|
||||
timelineId={`hashtag:${id}`}
|
||||
|
@ -117,4 +142,4 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default HashtagTimeline;
|
||||
export default HashtagTimeline;
|
||||
|
|
|
@ -31,9 +31,8 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={clsx('thread__connector', {
|
||||
'thread__connector--top': isConnectedTop,
|
||||
'thread__connector--bottom': isConnectedBottom,
|
||||
className={clsx('absolute left-5 z-[1] hidden w-0.5 bg-gray-200 rtl:left-auto rtl:right-5 dark:bg-primary-800', {
|
||||
'!block top-[calc(12px+42px)] h-[calc(100%-42px-8px-1rem)]': isConnectedBottom,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -404,7 +404,7 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
useEffect(() => {
|
||||
scroller.current?.scrollToIndex({
|
||||
index: ancestorsIds.size,
|
||||
offset: -140,
|
||||
offset: -146,
|
||||
});
|
||||
|
||||
setImmediate(() => statusRef.current?.querySelector<HTMLDivElement>('.detailed-actualStatus')?.focus());
|
||||
|
@ -443,7 +443,9 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
);
|
||||
} else if (!status) {
|
||||
return (
|
||||
<PlaceholderStatus />
|
||||
<Column>
|
||||
<PlaceholderStatus />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -10,8 +10,12 @@ import { useAppDispatch } from 'soapbox/hooks';
|
|||
|
||||
const ComposeButton = () => {
|
||||
const location = useLocation();
|
||||
const isOnGroupPage = location.pathname.startsWith('/group/');
|
||||
const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug');
|
||||
const { entity: group } = useGroupLookup(match?.params.groupSlug || '');
|
||||
const isGroupMember = !!group?.relationship?.member;
|
||||
|
||||
if (location.pathname.startsWith('/group/')) {
|
||||
if (isOnGroupPage && isGroupMember) {
|
||||
return <GroupComposeButton />;
|
||||
}
|
||||
|
||||
|
|
|
@ -56,7 +56,7 @@ const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
|
|||
<Text size='2xl' weight='bold' align='center'>{group.display_name}</Text>
|
||||
<Text
|
||||
size='md'
|
||||
className='mx-auto max-w-sm'
|
||||
className='mx-auto max-w-sm [&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
|
||||
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
|
||||
/>
|
||||
</Stack>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import {
|
||||
accountSchema,
|
||||
adSchema,
|
||||
|
@ -17,6 +18,7 @@ import {
|
|||
type GroupRelationship,
|
||||
type GroupTag,
|
||||
type Relationship,
|
||||
type Status,
|
||||
} from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
|
@ -77,6 +79,12 @@ function buildRelationship(props: Partial<Relationship> = {}): Relationship {
|
|||
}, props));
|
||||
}
|
||||
|
||||
function buildStatus(props: Partial<Status> = {}) {
|
||||
return normalizeStatus(Object.assign({
|
||||
id: uuidv4(),
|
||||
}, props));
|
||||
}
|
||||
|
||||
export {
|
||||
buildAd,
|
||||
buildCard,
|
||||
|
@ -85,4 +93,5 @@ export {
|
|||
buildGroupRelationship,
|
||||
buildGroupTag,
|
||||
buildRelationship,
|
||||
buildStatus,
|
||||
};
|
|
@ -805,6 +805,7 @@
|
|||
"group.report.label": "Report",
|
||||
"group.role.admin": "Admin",
|
||||
"group.role.owner": "Owner",
|
||||
"group.share.label": "Share",
|
||||
"group.tabs.all": "All",
|
||||
"group.tabs.media": "Media",
|
||||
"group.tabs.members": "Members",
|
||||
|
@ -856,6 +857,7 @@
|
|||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "or {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "without {additional}",
|
||||
"hashtag.follow": "Follow hashtag",
|
||||
"header.home.label": "Home",
|
||||
"header.login.email.placeholder": "E-mail address",
|
||||
"header.login.forgot_password": "Forgot password?",
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
|
||||
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||
import { normalizeMention } from 'soapbox/normalizers/mention';
|
||||
import { cardSchema, pollSchema } from 'soapbox/schemas';
|
||||
import { cardSchema, pollSchema, tombstoneSchema } from 'soapbox/schemas';
|
||||
|
||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
|
@ -36,6 +36,10 @@ export const EventRecord = ImmutableRecord({
|
|||
links: ImmutableList<Attachment>(),
|
||||
});
|
||||
|
||||
interface Tombstone {
|
||||
reason: 'deleted'
|
||||
}
|
||||
|
||||
// https://docs.joinmastodon.org/entities/status/
|
||||
export const StatusRecord = ImmutableRecord({
|
||||
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||
|
@ -72,6 +76,7 @@ export const StatusRecord = ImmutableRecord({
|
|||
sensitive: false,
|
||||
spoiler_text: '',
|
||||
tags: ImmutableList<ImmutableMap<string, any>>(),
|
||||
tombstone: null as Tombstone | null,
|
||||
uri: '',
|
||||
url: '',
|
||||
visibility: 'public' as StatusVisibility,
|
||||
|
@ -116,6 +121,15 @@ const normalizeStatusPoll = (status: ImmutableMap<string, any>) => {
|
|||
}
|
||||
};
|
||||
|
||||
const normalizeTombstone = (status: ImmutableMap<string, any>) => {
|
||||
try {
|
||||
const tombstone = tombstoneSchema.parse(status.get('tombstone').toJS());
|
||||
return status.set('tombstone', tombstone);
|
||||
} catch (_e) {
|
||||
return status.set('tombstone', null);
|
||||
}
|
||||
};
|
||||
|
||||
// Normalize card
|
||||
const normalizeStatusCard = (status: ImmutableMap<string, any>) => {
|
||||
try {
|
||||
|
@ -246,6 +260,7 @@ export const normalizeStatus = (status: Record<string, any>) => {
|
|||
fixContent(status);
|
||||
normalizeFilterResults(status);
|
||||
normalizeDislikes(status);
|
||||
normalizeTombstone(status);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({
|
|||
name: '',
|
||||
url: '',
|
||||
history: null as ImmutableList<History> | null,
|
||||
following: false,
|
||||
});
|
||||
|
||||
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {
|
||||
|
|
47
app/soapbox/reducers/followed_tags.ts
Normal file
47
app/soapbox/reducers/followed_tags.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import {
|
||||
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
} from 'soapbox/actions/tags';
|
||||
import { normalizeTag } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity, Tag } from 'soapbox/types/entities';
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
items: ImmutableList<Tag>(),
|
||||
isLoading: false,
|
||||
next: null,
|
||||
});
|
||||
|
||||
export default function followed_tags(state = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case FOLLOWED_HASHTAGS_FETCH_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('items', ImmutableList(action.followed_tags.map((item: APIEntity) => normalizeTag(item))));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_FETCH_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.update('items', list => list.concat(action.followed_tags.map((item: APIEntity) => normalizeTag(item))));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -28,6 +28,7 @@ import custom_emojis from './custom-emojis';
|
|||
import domain_lists from './domain-lists';
|
||||
import dropdown_menu from './dropdown-menu';
|
||||
import filters from './filters';
|
||||
import followed_tags from './followed_tags';
|
||||
import group_memberships from './group-memberships';
|
||||
import group_relationships from './group-relationships';
|
||||
import groups from './groups';
|
||||
|
@ -61,6 +62,7 @@ import status_hover_card from './status-hover-card';
|
|||
import status_lists from './status-lists';
|
||||
import statuses from './statuses';
|
||||
import suggestions from './suggestions';
|
||||
import tags from './tags';
|
||||
import timelines from './timelines';
|
||||
import trending_statuses from './trending-statuses';
|
||||
import trends from './trends';
|
||||
|
@ -92,6 +94,7 @@ const reducers = {
|
|||
dropdown_menu,
|
||||
entities,
|
||||
filters,
|
||||
followed_tags,
|
||||
group_memberships,
|
||||
group_relationships,
|
||||
groups,
|
||||
|
@ -125,6 +128,7 @@ const reducers = {
|
|||
status_lists,
|
||||
statuses,
|
||||
suggestions,
|
||||
tags,
|
||||
timelines,
|
||||
trending_statuses,
|
||||
trends,
|
||||
|
|
30
app/soapbox/reducers/tags.ts
Normal file
30
app/soapbox/reducers/tags.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
HASHTAG_FETCH_SUCCESS,
|
||||
HASHTAG_FOLLOW_REQUEST,
|
||||
HASHTAG_FOLLOW_FAIL,
|
||||
HASHTAG_UNFOLLOW_REQUEST,
|
||||
HASHTAG_UNFOLLOW_FAIL,
|
||||
} from 'soapbox/actions/tags';
|
||||
import { normalizeTag } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { Tag } from 'soapbox/types/entities';
|
||||
|
||||
const initialState = ImmutableMap<string, Tag>();
|
||||
|
||||
export default function tags(state = initialState, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case HASHTAG_FETCH_SUCCESS:
|
||||
return state.set(action.name, normalizeTag(action.tag));
|
||||
case HASHTAG_FOLLOW_REQUEST:
|
||||
case HASHTAG_UNFOLLOW_FAIL:
|
||||
return state.setIn([action.name, 'following'], true);
|
||||
case HASHTAG_FOLLOW_FAIL:
|
||||
case HASHTAG_UNFOLLOW_REQUEST:
|
||||
return state.setIn([action.name, 'following'], false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ import emojify from 'soapbox/features/emoji';
|
|||
|
||||
import { customEmojiSchema } from './custom-emoji';
|
||||
import { relationshipSchema } from './relationship';
|
||||
import { filteredArray, makeCustomEmojiMap } from './utils';
|
||||
import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils';
|
||||
|
||||
const avatarMissing = require('assets/images/avatar-missing.png');
|
||||
const headerMissing = require('assets/images/header-missing.png');
|
||||
|
@ -39,7 +39,7 @@ const accountSchema = z.object({
|
|||
z.string(),
|
||||
z.null(),
|
||||
]).catch(null),
|
||||
note: z.string().catch(''),
|
||||
note: contentSchema,
|
||||
pleroma: z.any(), // TODO
|
||||
source: z.any(), // TODO
|
||||
statuses_count: z.number().catch(0),
|
||||
|
@ -121,4 +121,4 @@ const accountSchema = z.object({
|
|||
|
||||
type Account = z.infer<typeof accountSchema>;
|
||||
|
||||
export { accountSchema, Account };
|
||||
export { accountSchema, type Account };
|
89
app/soapbox/schemas/attachment.ts
Normal file
89
app/soapbox/schemas/attachment.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { isBlurhashValid } from 'blurhash';
|
||||
import { z } from 'zod';
|
||||
|
||||
const blurhashSchema = z.string().superRefine((value, ctx) => {
|
||||
const r = isBlurhashValid(value);
|
||||
|
||||
if (!r.result) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: r.errorReason,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const baseAttachmentSchema = z.object({
|
||||
blurhash: blurhashSchema.nullable().catch(null),
|
||||
description: z.string().catch(''),
|
||||
external_video_id: z.string().optional().catch(undefined), // TruthSocial
|
||||
id: z.string(),
|
||||
pleroma: z.object({
|
||||
mime_type: z.string().regex(/^\w+\/[-+.\w]+$/),
|
||||
}).optional().catch(undefined),
|
||||
preview_url: z.string().url().catch(''),
|
||||
remote_url: z.string().url().nullable().catch(null),
|
||||
type: z.string(),
|
||||
url: z.string().url(),
|
||||
});
|
||||
|
||||
const imageMetaSchema = z.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
aspect: z.number().optional().catch(undefined),
|
||||
}).transform((meta) => ({
|
||||
...meta,
|
||||
aspect: typeof meta.aspect === 'number' ? meta.aspect : meta.width / meta.height,
|
||||
}));
|
||||
|
||||
const imageAttachmentSchema = baseAttachmentSchema.extend({
|
||||
type: z.literal('image'),
|
||||
meta: z.object({
|
||||
original: imageMetaSchema.optional().catch(undefined),
|
||||
}).catch({}),
|
||||
});
|
||||
|
||||
const videoAttachmentSchema = baseAttachmentSchema.extend({
|
||||
type: z.literal('video'),
|
||||
meta: z.object({
|
||||
duration: z.number().optional().catch(undefined),
|
||||
original: imageMetaSchema.optional().catch(undefined),
|
||||
}).catch({}),
|
||||
});
|
||||
|
||||
const gifvAttachmentSchema = baseAttachmentSchema.extend({
|
||||
type: z.literal('gifv'),
|
||||
meta: z.object({
|
||||
duration: z.number().optional().catch(undefined),
|
||||
original: imageMetaSchema.optional().catch(undefined),
|
||||
}).catch({}),
|
||||
});
|
||||
|
||||
const audioAttachmentSchema = baseAttachmentSchema.extend({
|
||||
type: z.literal('audio'),
|
||||
meta: z.object({
|
||||
duration: z.number().optional().catch(undefined),
|
||||
}).catch({}),
|
||||
});
|
||||
|
||||
const unknownAttachmentSchema = baseAttachmentSchema.extend({
|
||||
type: z.literal('unknown'),
|
||||
});
|
||||
|
||||
/** https://docs.joinmastodon.org/entities/attachment */
|
||||
const attachmentSchema = z.discriminatedUnion('type', [
|
||||
imageAttachmentSchema,
|
||||
videoAttachmentSchema,
|
||||
gifvAttachmentSchema,
|
||||
audioAttachmentSchema,
|
||||
unknownAttachmentSchema,
|
||||
]).transform((attachment) => {
|
||||
if (!attachment.preview_url) {
|
||||
attachment.preview_url = attachment.url;
|
||||
}
|
||||
|
||||
return attachment;
|
||||
});
|
||||
|
||||
type Attachment = z.infer<typeof attachmentSchema>;
|
||||
|
||||
export { attachmentSchema, type Attachment };
|
26
app/soapbox/schemas/chat-message.ts
Normal file
26
app/soapbox/schemas/chat-message.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { attachmentSchema } from './attachment';
|
||||
import { cardSchema } from './card';
|
||||
import { customEmojiSchema } from './custom-emoji';
|
||||
import { contentSchema, emojiSchema, filteredArray } from './utils';
|
||||
|
||||
const chatMessageSchema = z.object({
|
||||
account_id: z.string(),
|
||||
media_attachments: filteredArray(attachmentSchema),
|
||||
card: cardSchema.nullable().catch(null),
|
||||
chat_id: z.string(),
|
||||
content: contentSchema,
|
||||
created_at: z.string().datetime().catch(new Date().toUTCString()),
|
||||
emojis: filteredArray(customEmojiSchema),
|
||||
expiration: z.number().optional().catch(undefined),
|
||||
emoji_reactions: z.array(emojiSchema).min(1).nullable().catch(null),
|
||||
id: z.string(),
|
||||
unread: z.coerce.boolean(),
|
||||
deleting: z.coerce.boolean(),
|
||||
pending: z.coerce.boolean(),
|
||||
});
|
||||
|
||||
type ChatMessage = z.infer<typeof chatMessageSchema>;
|
||||
|
||||
export { chatMessageSchema, type ChatMessage };
|
|
@ -14,4 +14,4 @@ const customEmojiSchema = z.object({
|
|||
|
||||
type CustomEmoji = z.infer<typeof customEmojiSchema>;
|
||||
|
||||
export { customEmojiSchema, CustomEmoji };
|
||||
export { customEmojiSchema, type CustomEmoji };
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/** Validates the string as an emoji. */
|
||||
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v));
|
||||
import { emojiSchema } from './utils';
|
||||
|
||||
/** Pleroma emoji reaction. */
|
||||
const emojiReactionSchema = z.object({
|
||||
|
@ -12,4 +11,4 @@ const emojiReactionSchema = z.object({
|
|||
|
||||
type EmojiReaction = z.infer<typeof emojiReactionSchema>;
|
||||
|
||||
export { emojiReactionSchema, EmojiReaction };
|
||||
export { emojiReactionSchema, type EmojiReaction };
|
|
@ -16,4 +16,4 @@ const groupMemberSchema = z.object({
|
|||
|
||||
type GroupMember = z.infer<typeof groupMemberSchema>;
|
||||
|
||||
export { groupMemberSchema, GroupMember, GroupRoles };
|
||||
export { groupMemberSchema, type GroupMember, GroupRoles };
|
|
@ -14,4 +14,4 @@ const groupRelationshipSchema = z.object({
|
|||
|
||||
type GroupRelationship = z.infer<typeof groupRelationshipSchema>;
|
||||
|
||||
export { groupRelationshipSchema, GroupRelationship };
|
||||
export { groupRelationshipSchema, type GroupRelationship };
|
|
@ -1,14 +1,20 @@
|
|||
export { accountSchema, type Account } from './account';
|
||||
export { attachmentSchema, type Attachment } from './attachment';
|
||||
export { cardSchema, type Card } from './card';
|
||||
export { chatMessageSchema, type ChatMessage } from './chat-message';
|
||||
export { customEmojiSchema, type CustomEmoji } from './custom-emoji';
|
||||
export { emojiReactionSchema, type EmojiReaction } from './emoji-reaction';
|
||||
export { groupSchema, type Group } from './group';
|
||||
export { groupMemberSchema, type GroupMember } from './group-member';
|
||||
export { groupRelationshipSchema, type GroupRelationship } from './group-relationship';
|
||||
export { groupTagSchema, type GroupTag } from './group-tag';
|
||||
export { mentionSchema, type Mention } from './mention';
|
||||
export { notificationSchema, type Notification } from './notification';
|
||||
export { pollSchema, type Poll, type PollOption } from './poll';
|
||||
export { relationshipSchema, type Relationship } from './relationship';
|
||||
export { statusSchema, type Status } from './status';
|
||||
export { tagSchema, type Tag } from './tag';
|
||||
export { tombstoneSchema, type Tombstone } from './tombstone';
|
||||
|
||||
// Soapbox
|
||||
export { adSchema, type Ad } from './soapbox/ad';
|
18
app/soapbox/schemas/mention.ts
Normal file
18
app/soapbox/schemas/mention.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const mentionSchema = z.object({
|
||||
acct: z.string(),
|
||||
id: z.string(),
|
||||
url: z.string().url().catch(''),
|
||||
username: z.string().catch(''),
|
||||
}).transform((mention) => {
|
||||
if (!mention.username) {
|
||||
mention.username = mention.acct.split('@')[0];
|
||||
}
|
||||
|
||||
return mention;
|
||||
});
|
||||
|
||||
type Mention = z.infer<typeof mentionSchema>;
|
||||
|
||||
export { mentionSchema, type Mention };
|
104
app/soapbox/schemas/notification.ts
Normal file
104
app/soapbox/schemas/notification.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { accountSchema } from './account';
|
||||
import { chatMessageSchema } from './chat-message';
|
||||
import { statusSchema } from './status';
|
||||
import { emojiSchema } from './utils';
|
||||
|
||||
const baseNotificationSchema = z.object({
|
||||
account: accountSchema,
|
||||
created_at: z.string().datetime().catch(new Date().toUTCString()),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
total_count: z.number().optional().catch(undefined), // TruthSocial
|
||||
});
|
||||
|
||||
const mentionNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('mention'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const statusNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('status'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const reblogNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('reblog'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const followNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('follow'),
|
||||
});
|
||||
|
||||
const followRequestNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('follow_request'),
|
||||
});
|
||||
|
||||
const favouriteNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('favourite'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const pollNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('poll'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const updateNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('update'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const moveNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('move'),
|
||||
target: accountSchema,
|
||||
});
|
||||
|
||||
const chatMessageNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('chat_message'),
|
||||
chat_message: chatMessageSchema,
|
||||
});
|
||||
|
||||
const emojiReactionNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('pleroma:emoji_reaction'),
|
||||
emoji: emojiSchema,
|
||||
emoji_url: z.string().url().optional().catch(undefined),
|
||||
});
|
||||
|
||||
const eventReminderNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('pleroma:event_reminder'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const participationRequestNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('pleroma:participation_request'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const participationAcceptedNotificationSchema = baseNotificationSchema.extend({
|
||||
type: z.literal('pleroma:participation_accepted'),
|
||||
status: statusSchema,
|
||||
});
|
||||
|
||||
const notificationSchema = z.discriminatedUnion('type', [
|
||||
mentionNotificationSchema,
|
||||
statusNotificationSchema,
|
||||
reblogNotificationSchema,
|
||||
followNotificationSchema,
|
||||
followRequestNotificationSchema,
|
||||
favouriteNotificationSchema,
|
||||
pollNotificationSchema,
|
||||
updateNotificationSchema,
|
||||
moveNotificationSchema,
|
||||
chatMessageNotificationSchema,
|
||||
emojiReactionNotificationSchema,
|
||||
eventReminderNotificationSchema,
|
||||
participationRequestNotificationSchema,
|
||||
participationAcceptedNotificationSchema,
|
||||
]);
|
||||
|
||||
type Notification = z.infer<typeof notificationSchema>;
|
||||
|
||||
export { notificationSchema, type Notification };
|
|
@ -1,9 +1,65 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { toSchema } from 'soapbox/utils/normalizers';
|
||||
import { accountSchema } from './account';
|
||||
import { attachmentSchema } from './attachment';
|
||||
import { cardSchema } from './card';
|
||||
import { customEmojiSchema } from './custom-emoji';
|
||||
import { groupSchema } from './group';
|
||||
import { mentionSchema } from './mention';
|
||||
import { pollSchema } from './poll';
|
||||
import { tagSchema } from './tag';
|
||||
import { contentSchema, dateSchema, filteredArray } from './utils';
|
||||
|
||||
const statusSchema = toSchema(normalizeStatus);
|
||||
const tombstoneSchema = z.object({
|
||||
reason: z.enum(['deleted']),
|
||||
});
|
||||
|
||||
const baseStatusSchema = z.object({
|
||||
account: accountSchema,
|
||||
application: z.object({
|
||||
name: z.string(),
|
||||
website: z.string().url().nullable().catch(null),
|
||||
}).nullable().catch(null),
|
||||
bookmarked: z.coerce.boolean(),
|
||||
card: cardSchema.nullable().catch(null),
|
||||
content: contentSchema,
|
||||
created_at: dateSchema,
|
||||
disliked: z.coerce.boolean(),
|
||||
dislikes_count: z.number().catch(0),
|
||||
edited_at: z.string().datetime().nullable().catch(null),
|
||||
emojis: filteredArray(customEmojiSchema),
|
||||
favourited: z.coerce.boolean(),
|
||||
favourites_count: z.number().catch(0),
|
||||
group: groupSchema.nullable().catch(null),
|
||||
in_reply_to_account_id: z.string().nullable().catch(null),
|
||||
in_reply_to_id: z.string().nullable().catch(null),
|
||||
id: z.string(),
|
||||
language: z.string().nullable().catch(null),
|
||||
media_attachments: filteredArray(attachmentSchema),
|
||||
mentions: filteredArray(mentionSchema),
|
||||
muted: z.coerce.boolean(),
|
||||
pinned: z.coerce.boolean(),
|
||||
pleroma: z.object({}).optional().catch(undefined),
|
||||
poll: pollSchema.nullable().catch(null),
|
||||
quote: z.literal(null).catch(null),
|
||||
quotes_count: z.number().catch(0),
|
||||
reblog: z.literal(null).catch(null),
|
||||
reblogged: z.coerce.boolean(),
|
||||
reblogs_count: z.number().catch(0),
|
||||
replies_count: z.number().catch(0),
|
||||
sensitive: z.coerce.boolean(),
|
||||
spoiler_text: contentSchema,
|
||||
tags: filteredArray(tagSchema),
|
||||
tombstone: tombstoneSchema.nullable().optional(),
|
||||
uri: z.string().url().catch(''),
|
||||
url: z.string().url().catch(''),
|
||||
visibility: z.string().catch('public'),
|
||||
});
|
||||
|
||||
const statusSchema = baseStatusSchema.extend({
|
||||
quote: baseStatusSchema.nullable().catch(null),
|
||||
reblog: baseStatusSchema.nullable().catch(null),
|
||||
});
|
||||
|
||||
type Status = z.infer<typeof statusSchema>;
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ const historySchema = z.object({
|
|||
uses: z.coerce.number(),
|
||||
});
|
||||
|
||||
/** // https://docs.joinmastodon.org/entities/tag */
|
||||
/** https://docs.joinmastodon.org/entities/tag */
|
||||
const tagSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
url: z.string().url().catch(''),
|
||||
|
|
9
app/soapbox/schemas/tombstone.ts
Normal file
9
app/soapbox/schemas/tombstone.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const tombstoneSchema = z.object({
|
||||
reason: z.enum(['deleted']),
|
||||
});
|
||||
|
||||
type Tombstone = z.infer<typeof tombstoneSchema>;
|
||||
|
||||
export { tombstoneSchema, type Tombstone };
|
|
@ -2,6 +2,12 @@ import z from 'zod';
|
|||
|
||||
import type { CustomEmoji } from './custom-emoji';
|
||||
|
||||
/** Ensure HTML content is a string, and drop empty `<p>` tags. */
|
||||
const contentSchema = z.string().catch('').transform((value) => value === '<p></p>' ? '' : value);
|
||||
|
||||
/** Validate to Mastodon's date format, or use the current date. */
|
||||
const dateSchema = z.string().datetime().catch(new Date().toUTCString());
|
||||
|
||||
/** Validates individual items in an array, dropping any that aren't valid. */
|
||||
function filteredArray<T extends z.ZodTypeAny>(schema: T) {
|
||||
return z.any().array().catch([])
|
||||
|
@ -13,6 +19,9 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
|
|||
));
|
||||
}
|
||||
|
||||
/** Validates the string as an emoji. */
|
||||
const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v));
|
||||
|
||||
/** Map a list of CustomEmoji to their shortcodes. */
|
||||
function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
|
||||
return customEmojis.reduce<Record<string, CustomEmoji>>((result, emoji) => {
|
||||
|
@ -21,4 +30,4 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
|
|||
}, {});
|
||||
}
|
||||
|
||||
export { filteredArray, makeCustomEmojiMap };
|
||||
export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema };
|
|
@ -493,6 +493,16 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'),
|
||||
|
||||
/**
|
||||
* Ability to follow hashtags.
|
||||
* @see POST /api/v1/tags/:name/follow
|
||||
* @see POST /api/v1/tags/:name/unfollow
|
||||
*/
|
||||
followHashtags: any([
|
||||
v.software === MASTODON && gte(v.compatVersion, '4.0.0'),
|
||||
v.software === PLEROMA && v.build === AKKOMA,
|
||||
]),
|
||||
|
||||
/**
|
||||
* Ability to lock accounts and manually approve followers.
|
||||
* @see PATCH /api/v1/accounts/update_credentials
|
||||
|
@ -502,6 +512,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
v.software === PLEROMA,
|
||||
]),
|
||||
|
||||
/**
|
||||
* Ability to list followed hashtags.
|
||||
* @see GET /api/v1/followed_tags
|
||||
*/
|
||||
followedHashtagsList: v.software === MASTODON && gte(v.compatVersion, '4.1.0'),
|
||||
|
||||
/**
|
||||
* Whether client settings can be retrieved from the API.
|
||||
* @see GET /api/pleroma/frontend_configurations
|
||||
|
|
|
@ -12,14 +12,4 @@
|
|||
.status__content-wrapper {
|
||||
@apply pl-[calc(42px+12px)] rtl:pl-0 rtl:pr-[calc(42px+12px)];
|
||||
}
|
||||
|
||||
&__connector {
|
||||
@apply bg-gray-200 dark:bg-primary-800 absolute w-0.5 left-5 hidden z-[1] rtl:right-5 rtl:left-auto;
|
||||
|
||||
&--bottom {
|
||||
@apply block;
|
||||
height: calc(100% - 42px - 8px - 1rem);
|
||||
top: calc(12px + 42px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue