diff --git a/CHANGELOG.md b/CHANGELOG.md index f83896efc1..56d3123209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 06fe848e29..e4a10edd08 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -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}`)); } }); }; diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts new file mode 100644 index 0000000000..75d8e00fa6 --- /dev/null +++ b/app/soapbox/actions/tags.ts @@ -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, +}; diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts new file mode 100644 index 0000000000..8afd06f1a3 --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts new file mode 100644 index 0000000000..2397b16ceb --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts new file mode 100644 index 0000000000..a68b79eb1d --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts new file mode 100644 index 0000000000..6f2fb6eac5 --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts new file mode 100644 index 0000000000..739a1c0af6 --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupLookup.ts b/app/soapbox/api/hooks/groups/useGroupLookup.ts index 89c778a159..6e41975e5b 100644 --- a/app/soapbox/api/hooks/groups/useGroupLookup.ts +++ b/app/soapbox/api/hooks/groups/useGroupLookup.ts @@ -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 }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupMedia.ts b/app/soapbox/api/hooks/groups/useGroupMedia.ts index 23375bdc7b..4db7fd179b 100644 --- a/app/soapbox/api/hooks/groups/useGroupMedia.ts +++ b/app/soapbox/api/hooks/groups/useGroupMedia.ts @@ -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(); diff --git a/app/soapbox/api/hooks/groups/useGroupRelationship.ts b/app/soapbox/api/hooks/groups/useGroupRelationship.ts index 6b24c463ca..21d8d3efdd 100644 --- a/app/soapbox/api/hooks/groups/useGroupRelationship.ts +++ b/app/soapbox/api/hooks/groups/useGroupRelationship.ts @@ -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( - [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(() => { diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 8456a46a41..0c1a7be59c 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -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 = (props) => { const isUnderReview = actualStatus.visibility === 'self'; const isSensitive = actualStatus.hidden; + const isSoftDeleted = status.tombstone?.reason === 'deleted'; + + if (isSoftDeleted) { + return ( + onMoveUp ? onMoveUp(id) : null} + onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null} + /> + ); + } return ( diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index 6c6a2a6f90..b92fb7e70f 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -19,10 +19,17 @@ const Tombstone: React.FC = ({ id, onMoveUp, onMoveDown }) => { return ( -
- - - +
+
+ + + +
); diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 8813b107b1..8d7c2da397 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -51,6 +51,8 @@ export interface IColumn { withHeader?: boolean /** Extra class name for top
element. */ className?: string + /** Extra class name for the element. */ + bodyClassName?: string /** Ref forwarded to column. */ ref?: React.Ref /** 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 = React.forwardRef((props, ref: React.ForwardedRef): 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 = React.forwardRef((props, ref: React.ForwardedR /> )} - + {children} diff --git a/app/soapbox/components/ui/icon/icon.tsx b/app/soapbox/components/ui/icon/icon.tsx index f51c3ca38f..709b3126f0 100644 --- a/app/soapbox/components/ui/icon/icon.tsx +++ b/app/soapbox/components/ui/icon/icon.tsx @@ -17,11 +17,16 @@ interface IIcon extends Pick, '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 = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => ( -
+
{count ? ( diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 63447ae676..3d57c8ab02 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -14,6 +14,8 @@ interface UseEntityOpts { schema?: EntitySchema /** 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( @@ -31,6 +33,7 @@ function useEntity( 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( }; useEffect(() => { + if (!isEnabled) return; if (!entity || opts.refetch) { fetchEntity(); } - }, []); + }, [isEnabled]); return { entity, diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index fbdfc3181d..adddce9e20 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -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 = ({ account }) => { }); }; + const handleCopy: React.EventHandler = (e) => { + copy(account.url); + }; + const makeMenu = () => { const menu: Menu = []; @@ -306,8 +312,22 @@ const Header: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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({ diff --git a/app/soapbox/features/auth-login/components/login-form.tsx b/app/soapbox/features/auth-login/components/login-form.tsx index 9a4768903d..d7871fc759 100644 --- a/app/soapbox/features/auth-login/components/login-form.tsx +++ b/app/soapbox/features/auth-login/components/login-form.tsx @@ -57,7 +57,7 @@ const LoginForm: React.FC = ({ isLoading, handleSubmit }) => { + { 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 (
diff --git a/app/soapbox/features/followed_tags/index.tsx b/app/soapbox/features/followed_tags/index.tsx new file mode 100644 index 0000000000..6745f5fc0a --- /dev/null +++ b/app/soapbox/features/followed_tags/index.tsx @@ -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 = ; + + return ( + + handleLoadMore(dispatch)} + placeholderComponent={PlaceholderHashtag} + placeholderCount={5} + itemClassName='pb-3' + > + {tags.map(tag => )} + + + ); +}; + +export default FollowedTags; diff --git a/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx index 4418fff868..f91853dc43 100644 --- a/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx @@ -91,31 +91,32 @@ describe('', () => { 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(); + 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(); - 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(); - expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); - }); + it('does not render the pin icon', () => { + render(); + expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); }); }); }); diff --git a/app/soapbox/features/group/components/group-action-button.tsx b/app/soapbox/features/group/components/group-action-button.tsx index 005b9e2459..f3b2085744 100644 --- a/app/soapbox/features/group/components/group-action-button.tsx +++ b/app/soapbox/features/group/components/group-action-button.tsx @@ -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 = () => diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index ad22e1e13a..2491a7bb73 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -99,7 +99,7 @@ const GroupHeader: React.FC = ({ group }) => { if (!isDefaultHeader(group.header)) { header = ( - + {header} ); @@ -155,6 +155,7 @@ const GroupHeader: React.FC = ({ group }) => { theme='muted' align='center' dangerouslySetInnerHTML={{ __html: group.note_emojified }} + className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue' /> diff --git a/app/soapbox/features/group/components/group-options-button.tsx b/app/soapbox/features/group/components/group-options-button.tsx index 597a751d7b..ebc3152e47 100644 --- a/app/soapbox/features/group/components/group-options-button.tsx +++ b/app/soapbox/features/group/components/group-options-button.tsx @@ -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), diff --git a/app/soapbox/features/group/components/group-tag-list-item.tsx b/app/soapbox/features/group/components/group-tag-list-item.tsx index bf02cc2029..07660cf21c 100644 --- a/app/soapbox/features/group/components/group-tag-list-item.tsx +++ b/app/soapbox/features/group/components/group-tag-list-item.tsx @@ -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 ( + + ); + } + + if (!isOwner) { + return null; + } + if (isPinnable) { return ( { - {isOwner ? ( - - {tag.visible ? ( - renderPinIcon() - ) : null} + + {tag.visible ? ( + renderPinIcon() + ) : null} + {isOwner ? ( { iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue' /> - - ) : null} + ) : null} + ); }; diff --git a/app/soapbox/features/group/components/group-tags-field.tsx b/app/soapbox/features/group/components/group-tags-field.tsx index ba98d808e2..f8092d5c05 100644 --- a/app/soapbox/features/group/components/group-tags-field.tsx +++ b/app/soapbox/features/group/components/group-tags-field.tsx @@ -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 = ({ tags, onChange, onAddItem, const HashtagField: StreamfieldComponent = ({ value, onChange, autoFocus = false }) => { const intl = useIntl(); + const formattedValue = useMemo(() => { + return `#${value}`; + }, [value]); + const handleChange: React.ChangeEventHandler = ({ target }) => { - onChange(target.value); + onChange(target.value.replace('#', '')); }; return ( = ({ params: { groupId } }) => { const { group, isLoading } = useGroup(groupId); const { updateGroup } = useUpdateGroup(groupId); + const { invalidate } = useGroupTags(groupId); const [isSubmitting, setIsSubmitting] = useState(false); const [tags, setTags] = useState(['']); @@ -64,6 +65,7 @@ const EditGroup: React.FC = ({ params: { groupId } }) => { tags, }, { onSuccess() { + invalidate(); toast.success(intl.formatMessage(messages.groupSaved)); }, onError(error) { diff --git a/app/soapbox/features/group/group-tags.tsx b/app/soapbox/features/group/group-tags.tsx index 710a4fdb59..d5335e8447 100644 --- a/app/soapbox/features/group/group-tags.tsx +++ b/app/soapbox/features/group/group-tags.tsx @@ -36,7 +36,7 @@ const GroupTopics: React.FC = (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={ diff --git a/app/soapbox/features/groups/components/group-link-preview.tsx b/app/soapbox/features/groups/components/group-link-preview.tsx index 18ca586a52..98ca030767 100644 --- a/app/soapbox/features/groups/components/group-link-preview.tsx +++ b/app/soapbox/features/groups/components/group-link-preview.tsx @@ -19,7 +19,7 @@ const GroupLinkPreview: React.FC = ({ card }) => { return (
diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 2133e3e3ac..e448bef8a5 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -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 = ({ 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 = ({ 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 = ({ params }) => { }, [id]); return ( - + + {features.followHashtags && ( + + } + > + + + + )} = ({ params }) => { ); }; -export default HashtagTimeline; \ No newline at end of file +export default HashtagTimeline; diff --git a/app/soapbox/features/status/components/thread-status.tsx b/app/soapbox/features/status/components/thread-status.tsx index 8f3e5b2496..442bae0e34 100644 --- a/app/soapbox/features/status/components/thread-status.tsx +++ b/app/soapbox/features/status/components/thread-status.tsx @@ -31,9 +31,8 @@ const ThreadStatus: React.FC = (props): JSX.Element => { return (