diff --git a/app/soapbox/actions/familiar-followers.ts b/app/soapbox/actions/familiar-followers.ts index ee38084ba..a412509a8 100644 --- a/app/soapbox/actions/familiar-followers.ts +++ b/app/soapbox/actions/familiar-followers.ts @@ -33,5 +33,6 @@ export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: A type: FAMILIAR_FOLLOWERS_FETCH_FAIL, id: accountId, error, + skipAlert: true, })); }; diff --git a/app/soapbox/api/hooks/accounts/useAccount.ts b/app/soapbox/api/hooks/accounts/useAccount.ts index ac756f6ca..468049441 100644 --- a/app/soapbox/api/hooks/accounts/useAccount.ts +++ b/app/soapbox/api/hooks/accounts/useAccount.ts @@ -1,3 +1,6 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + import { Entities } from 'soapbox/entity-store/entities'; import { useEntity } from 'soapbox/entity-store/hooks'; import { useFeatures, useLoggedIn } from 'soapbox/hooks'; @@ -12,11 +15,12 @@ interface UseAccountOpts { function useAccount(accountId?: string, opts: UseAccountOpts = {}) { const api = useApi(); + const history = useHistory(); const features = useFeatures(); const { me } = useLoggedIn(); const { withRelationship } = opts; - const { entity: account, ...result } = useEntity( + const { entity: account, isUnauthorized, ...result } = useEntity( [Entities.ACCOUNTS, accountId!], () => api.get(`/api/v1/accounts/${accountId}`), { schema: accountSchema, enabled: !!accountId }, @@ -30,10 +34,17 @@ function useAccount(accountId?: string, opts: UseAccountOpts = {}) { const isBlocked = account?.relationship?.blocked_by === true; const isUnavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible); + useEffect(() => { + if (isUnauthorized) { + history.push('/login'); + } + }, [isUnauthorized]); + return { ...result, isLoading: result.isLoading, isRelationshipLoading, + isUnauthorized, isUnavailable, account: account ? { ...account, relationship } : undefined, }; diff --git a/app/soapbox/api/hooks/accounts/useAccountLookup.ts b/app/soapbox/api/hooks/accounts/useAccountLookup.ts index dc7f2fb29..ed73617aa 100644 --- a/app/soapbox/api/hooks/accounts/useAccountLookup.ts +++ b/app/soapbox/api/hooks/accounts/useAccountLookup.ts @@ -1,3 +1,6 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + import { Entities } from 'soapbox/entity-store/entities'; import { useEntityLookup } from 'soapbox/entity-store/hooks'; import { useFeatures, useLoggedIn } from 'soapbox/hooks'; @@ -13,12 +16,13 @@ interface UseAccountLookupOpts { function useAccountLookup(acct: string | undefined, opts: UseAccountLookupOpts = {}) { const api = useApi(); const features = useFeatures(); + const history = useHistory(); const { me } = useLoggedIn(); const { withRelationship } = opts; - const { entity: account, ...result } = useEntityLookup( + const { entity: account, isUnauthorized, ...result } = useEntityLookup( Entities.ACCOUNTS, - (account) => account.acct === acct, + (account) => account.acct.toLowerCase() === acct?.toLowerCase(), () => api.get(`/api/v1/accounts/lookup?acct=${acct}`), { schema: accountSchema, enabled: !!acct }, ); @@ -31,10 +35,17 @@ function useAccountLookup(acct: string | undefined, opts: UseAccountLookupOpts = const isBlocked = account?.relationship?.blocked_by === true; const isUnavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible); + useEffect(() => { + if (isUnauthorized) { + history.push('/login'); + } + }, [isUnauthorized]); + return { ...result, isLoading: result.isLoading, isRelationshipLoading, + isUnauthorized, isUnavailable, account: account ? { ...account, relationship } : undefined, }; diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts index 2397b16ce..6723aee78 100644 --- a/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts @@ -1,10 +1,11 @@ import { __stub } from 'soapbox/api'; import { buildGroup } from 'soapbox/jest/factory'; -import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; +import { renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers'; import { useGroupLookup } from '../useGroupLookup'; const group = buildGroup({ id: '1', slug: 'soapbox' }); +const state = rootState.setIn(['instance', 'version'], '3.4.1 (compatible; TruthSocial 1.0.0)'); describe('useGroupLookup hook', () => { describe('with a successful request', () => { @@ -15,7 +16,7 @@ describe('useGroupLookup hook', () => { }); it('is successful', async () => { - const { result } = renderHook(() => useGroupLookup(group.slug)); + const { result } = renderHook(() => useGroupLookup(group.slug), undefined, state); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -31,7 +32,7 @@ describe('useGroupLookup hook', () => { }); it('is has error state', async() => { - const { result } = renderHook(() => useGroupLookup(group.slug)); + const { result } = renderHook(() => useGroupLookup(group.slug), undefined, state); await waitFor(() => expect(result.current.isFetching).toBe(false)); diff --git a/app/soapbox/api/hooks/groups/useGroup.ts b/app/soapbox/api/hooks/groups/useGroup.ts index 5eb6147d2..9efafb13c 100644 --- a/app/soapbox/api/hooks/groups/useGroup.ts +++ b/app/soapbox/api/hooks/groups/useGroup.ts @@ -1,3 +1,6 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + import { Entities } from 'soapbox/entity-store/entities'; import { useEntity } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks'; @@ -7,8 +10,9 @@ import { useGroupRelationship } from './useGroupRelationship'; function useGroup(groupId: string, refetch = true) { const api = useApi(); + const history = useHistory(); - const { entity: group, ...result } = useEntity( + const { entity: group, isUnauthorized, ...result } = useEntity( [Entities.GROUPS, groupId], () => api.get(`/api/v1/groups/${groupId}`), { @@ -19,8 +23,15 @@ function useGroup(groupId: string, refetch = true) { ); const { groupRelationship: relationship } = useGroupRelationship(groupId); + useEffect(() => { + if (isUnauthorized) { + history.push('/login'); + } + }, [isUnauthorized]); + return { ...result, + isUnauthorized, group: group ? { ...group, relationship: relationship || null } : undefined, }; } diff --git a/app/soapbox/api/hooks/groups/useGroupLookup.ts b/app/soapbox/api/hooks/groups/useGroupLookup.ts index 3e66f72c6..8e161af78 100644 --- a/app/soapbox/api/hooks/groups/useGroupLookup.ts +++ b/app/soapbox/api/hooks/groups/useGroupLookup.ts @@ -1,24 +1,37 @@ +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + import { Entities } from 'soapbox/entity-store/entities'; import { useEntityLookup } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks/useApi'; +import { useFeatures } from 'soapbox/hooks/useFeatures'; import { groupSchema } from 'soapbox/schemas'; import { useGroupRelationship } from './useGroupRelationship'; function useGroupLookup(slug: string) { const api = useApi(); + const features = useFeatures(); + const history = useHistory(); - const { entity: group, ...result } = useEntityLookup( + const { entity: group, isUnauthorized, ...result } = useEntityLookup( Entities.GROUPS, - (group) => group.slug === slug, + (group) => group.slug.toLowerCase() === slug.toLowerCase(), () => api.get(`/api/v1/groups/lookup?name=${slug}`), - { schema: groupSchema, enabled: !!slug }, + { schema: groupSchema, enabled: features.groups && !!slug }, ); const { groupRelationship: relationship } = useGroupRelationship(group?.id); + useEffect(() => { + if (isUnauthorized) { + history.push('/login'); + } + }, [isUnauthorized]); + return { ...result, + isUnauthorized, entity: group ? { ...group, relationship: relationship || null } : undefined, }; } diff --git a/app/soapbox/api/hooks/groups/useUpdateGroup.ts b/app/soapbox/api/hooks/groups/useUpdateGroup.ts index bb3a37dc2..b4ec0aa54 100644 --- a/app/soapbox/api/hooks/groups/useUpdateGroup.ts +++ b/app/soapbox/api/hooks/groups/useUpdateGroup.ts @@ -6,8 +6,8 @@ import { groupSchema } from 'soapbox/schemas'; interface UpdateGroupParams { display_name?: string note?: string - avatar?: File - header?: File + avatar?: File | '' + header?: File | '' group_visibility?: string discoverable?: boolean tags?: string[] @@ -30,4 +30,4 @@ function useUpdateGroup(groupId: string) { }; } -export { useUpdateGroup }; \ No newline at end of file +export { useUpdateGroup }; diff --git a/app/soapbox/components/__tests__/quoted-status.test.tsx b/app/soapbox/components/__tests__/quoted-status.test.tsx index d57b59b30..05c906985 100644 --- a/app/soapbox/components/__tests__/quoted-status.test.tsx +++ b/app/soapbox/components/__tests__/quoted-status.test.tsx @@ -11,6 +11,7 @@ describe('', () => { const account = normalizeAccount({ id: '1', acct: 'alex', + url: 'https://soapbox.test/users/alex', }); const status = normalizeStatus({ diff --git a/app/soapbox/components/avatar-stack.tsx b/app/soapbox/components/avatar-stack.tsx new file mode 100644 index 000000000..b7dbb050d --- /dev/null +++ b/app/soapbox/components/avatar-stack.tsx @@ -0,0 +1,40 @@ +import clsx from 'clsx'; +import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import React from 'react'; + +import { Avatar, HStack } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; + +import type { Account } from 'soapbox/types/entities'; + +const getAccount = makeGetAccount(); + +interface IAvatarStack { + accountIds: ImmutableOrderedSet + limit?: number +} + +const AvatarStack: React.FC = ({ accountIds, limit = 3 }) => { + const accounts = useAppSelector(state => ImmutableList(accountIds.slice(0, limit).map(accountId => getAccount(state, accountId)).filter(account => account))) as ImmutableList; + + return ( + + {accounts.map((account, i) => ( +
+ +
+ ))} +
+ ); +}; + +export default AvatarStack; diff --git a/app/soapbox/components/still-image.tsx b/app/soapbox/components/still-image.tsx index cdebaf359..4a3caf01e 100644 --- a/app/soapbox/components/still-image.tsx +++ b/app/soapbox/components/still-image.tsx @@ -67,7 +67,7 @@ const StillImage: React.FC = ({ alt, className, src, style, letterb )} diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 3d57c8ab0..b9b9f001f 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -1,4 +1,5 @@ -import { useEffect } from 'react'; +import { AxiosError } from 'axios'; +import { useEffect, useState } from 'react'; import z from 'zod'; import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks'; @@ -24,6 +25,8 @@ function useEntity( opts: UseEntityOpts = {}, ) { const [isFetching, setPromise] = useLoading(true); + const [error, setError] = useState(); + const dispatch = useAppDispatch(); const [entityType, entityId] = path; @@ -35,6 +38,7 @@ function useEntity( const isEnabled = opts.enabled ?? true; const isLoading = isFetching && !entity; + const isLoaded = !isFetching && !!entity; const fetchEntity = async () => { try { @@ -42,7 +46,7 @@ function useEntity( const entity = schema.parse(response.data); dispatch(importEntities([entity], entityType)); } catch (e) { - // do nothing + setError(e); } }; @@ -58,6 +62,10 @@ function useEntity( fetchEntity, isFetching, isLoading, + isLoaded, + error, + isUnauthorized: error instanceof AxiosError && error.response?.status === 401, + isForbidden: error instanceof AxiosError && error.response?.status === 403, }; } diff --git a/app/soapbox/entity-store/hooks/useEntityLookup.ts b/app/soapbox/entity-store/hooks/useEntityLookup.ts index 73b2ef938..29cf85244 100644 --- a/app/soapbox/entity-store/hooks/useEntityLookup.ts +++ b/app/soapbox/entity-store/hooks/useEntityLookup.ts @@ -1,4 +1,5 @@ -import { useEffect } from 'react'; +import { AxiosError } from 'axios'; +import { useEffect, useState } from 'react'; import { z } from 'zod'; import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks'; @@ -23,6 +24,7 @@ function useEntityLookup( const dispatch = useAppDispatch(); const [isFetching, setPromise] = useLoading(true); + const [error, setError] = useState(); const entity = useAppSelector(state => findEntity(state, entityType, lookupFn)); const isEnabled = opts.enabled ?? true; @@ -34,7 +36,7 @@ function useEntityLookup( const entity = schema.parse(response.data); dispatch(importEntities([entity], entityType)); } catch (e) { - // do nothing + setError(e); } }; @@ -51,6 +53,8 @@ function useEntityLookup( fetchEntity, isFetching, isLoading, + isUnauthorized: error instanceof AxiosError && error.response?.status === 401, + isForbidden: error instanceof AxiosError && error.response?.status === 403, }; } diff --git a/app/soapbox/features/group/components/group-avatar-picker.tsx b/app/soapbox/features/edit-profile/components/avatar-picker.tsx similarity index 61% rename from app/soapbox/features/group/components/group-avatar-picker.tsx rename to app/soapbox/features/edit-profile/components/avatar-picker.tsx index b13dfe80e..09a6b0d29 100644 --- a/app/soapbox/features/group/components/group-avatar-picker.tsx +++ b/app/soapbox/features/edit-profile/components/avatar-picker.tsx @@ -1,19 +1,25 @@ import clsx from 'clsx'; import React from 'react'; +import { FormattedMessage } from 'react-intl'; -import Icon from 'soapbox/components/icon'; -import { Avatar, HStack } from 'soapbox/components/ui'; +import { Avatar, Icon, HStack } from 'soapbox/components/ui'; interface IMediaInput { + className?: string src: string | undefined accept: string onChange: React.ChangeEventHandler disabled?: boolean } -const AvatarPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { +const AvatarPicker = React.forwardRef(({ className, src, onChange, accept, disabled }, ref) => { return ( -