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

This commit is contained in:
marcin mikołajczak 2023-05-16 22:14:22 +02:00
commit 65db6f503d
57 changed files with 1148 additions and 106 deletions

View file

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

View file

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

@ -0,0 +1,201 @@
import api, { getLinks } from '../api';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchHashtagRequest());
api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => {
dispatch(fetchHashtagSuccess(name, data));
}).catch(err => {
dispatch(fetchHashtagFail(err));
});
};
const fetchHashtagRequest = () => ({
type: HASHTAG_FETCH_REQUEST,
});
const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({
type: HASHTAG_FETCH_SUCCESS,
name,
tag,
});
const fetchHashtagFail = (error: AxiosError) => ({
type: HASHTAG_FETCH_FAIL,
error,
});
const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(followHashtagRequest(name));
api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
dispatch(followHashtagSuccess(name, data));
}).catch(err => {
dispatch(followHashtagFail(name, err));
});
};
const followHashtagRequest = (name: string) => ({
type: HASHTAG_FOLLOW_REQUEST,
name,
});
const followHashtagSuccess = (name: string, tag: APIEntity) => ({
type: HASHTAG_FOLLOW_SUCCESS,
name,
tag,
});
const followHashtagFail = (name: string, error: AxiosError) => ({
type: HASHTAG_FOLLOW_FAIL,
name,
error,
});
const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(unfollowHashtagRequest(name));
api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
dispatch(unfollowHashtagSuccess(name, data));
}).catch(err => {
dispatch(unfollowHashtagFail(name, err));
});
};
const unfollowHashtagRequest = (name: string) => ({
type: HASHTAG_UNFOLLOW_REQUEST,
name,
});
const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({
type: HASHTAG_UNFOLLOW_SUCCESS,
name,
tag,
});
const unfollowHashtagFail = (name: string, error: AxiosError) => ({
type: HASHTAG_UNFOLLOW_FAIL,
name,
error,
});
const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchFollowedHashtagsRequest());
api(getState).get('/api/v1/followed_tags').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchFollowedHashtagsFail(err));
});
};
const fetchFollowedHashtagsRequest = () => ({
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
});
const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
followed_tags,
next,
});
const fetchFollowedHashtagsFail = (error: AxiosError) => ({
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
error,
});
const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
const url = getState().followed_tags.next;
if (url === null) {
return;
}
dispatch(expandFollowedHashtagsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFollowedHashtagsFail(error));
});
};
const expandFollowedHashtagsRequest = () => ({
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
});
const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
followed_tags,
next,
});
const expandFollowedHashtagsFail = (error: AxiosError) => ({
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
error,
});
export {
HASHTAG_FETCH_REQUEST,
HASHTAG_FETCH_SUCCESS,
HASHTAG_FETCH_FAIL,
HASHTAG_FOLLOW_REQUEST,
HASHTAG_FOLLOW_SUCCESS,
HASHTAG_FOLLOW_FAIL,
HASHTAG_UNFOLLOW_REQUEST,
HASHTAG_UNFOLLOW_SUCCESS,
HASHTAG_UNFOLLOW_FAIL,
FOLLOWED_HASHTAGS_FETCH_REQUEST,
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
FOLLOWED_HASHTAGS_FETCH_FAIL,
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
FOLLOWED_HASHTAGS_EXPAND_FAIL,
fetchHashtag,
fetchHashtagRequest,
fetchHashtagSuccess,
fetchHashtagFail,
followHashtag,
followHashtagRequest,
followHashtagSuccess,
followHashtagFail,
unfollowHashtag,
unfollowHashtagRequest,
unfollowHashtagSuccess,
unfollowHashtagFail,
fetchFollowedHashtags,
fetchFollowedHashtagsRequest,
fetchFollowedHashtagsSuccess,
fetchFollowedHashtagsFail,
expandFollowedHashtags,
expandFollowedHashtagsRequest,
expandFollowedHashtagsSuccess,
expandFollowedHashtagsFail,
};

View file

@ -0,0 +1,41 @@
import { __stub } from 'soapbox/api';
import { buildGroup } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { useGroup } from '../useGroup';
const group = buildGroup({ id: '1', display_name: 'soapbox' });
describe('useGroup hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroup(group.id));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.group?.id).toBe(group.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${group.id}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroup(group.id));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.group).toBeUndefined();
});
});
});

View file

@ -0,0 +1,41 @@
import { __stub } from 'soapbox/api';
import { buildGroup } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { useGroupLookup } from '../useGroupLookup';
const group = buildGroup({ id: '1', slug: 'soapbox' });
describe('useGroupLookup hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroupLookup(group.slug));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entity?.id).toBe(group.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroupLookup(group.slug));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entity).toBeUndefined();
});
});
});

View file

@ -0,0 +1,44 @@
import { __stub } from 'soapbox/api';
import { buildStatus } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { useGroupMedia } from '../useGroupMedia';
const status = buildStatus();
const groupId = '1';
describe('useGroupMedia hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroupMedia(groupId));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entities.length).toBe(1);
expect(result.current.entities[0].id).toBe(status.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroupMedia(groupId));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entities.length).toBe(0);
expect(result.current.isError).toBeTruthy();
});
});
});

View file

@ -0,0 +1,45 @@
import { __stub } from 'soapbox/api';
import { buildGroupMember } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { useGroupMembers } from '../useGroupMembers';
const groupMember = buildGroupMember();
const groupId = '1';
describe('useGroupMembers hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groupMembers.length).toBe(1);
expect(result.current.groupMembers[0].id).toBe(groupMember.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groupMembers.length).toBe(0);
expect(result.current.isError).toBeTruthy();
});
});
});

View file

@ -0,0 +1,47 @@
import { __stub } from 'soapbox/api';
import { buildGroup } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeInstance } from 'soapbox/normalizers';
import { useGroups } from '../useGroups';
const group = buildGroup({ id: '1', display_name: 'soapbox' });
const store = {
instance: normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
}),
};
describe('useGroups hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').reply(200, [group]);
});
});
it('is successful', async () => {
const { result } = renderHook(useGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(1);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(useGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(0);
});
});
});

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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 = () =>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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})` }}
/>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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?",

View file

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

View file

@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({
name: '',
url: '',
history: null as ImmutableList<History> | null,
following: false,
});
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {

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

View file

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

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

View file

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

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

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

View file

@ -14,4 +14,4 @@ const customEmojiSchema = z.object({
type CustomEmoji = z.infer<typeof customEmojiSchema>;
export { customEmojiSchema, CustomEmoji };
export { customEmojiSchema, type CustomEmoji };

View file

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

View file

@ -16,4 +16,4 @@ const groupMemberSchema = z.object({
type GroupMember = z.infer<typeof groupMemberSchema>;
export { groupMemberSchema, GroupMember, GroupRoles };
export { groupMemberSchema, type GroupMember, GroupRoles };

View file

@ -14,4 +14,4 @@ const groupRelationshipSchema = z.object({
type GroupRelationship = z.infer<typeof groupRelationshipSchema>;
export { groupRelationshipSchema, GroupRelationship };
export { groupRelationshipSchema, type GroupRelationship };

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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