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

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-lexical-ujdd17/deployments/3585
marcin mikołajczak 1 year ago
commit 5161b3cba9

@ -1,15 +1,12 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { buildAccount, buildRelationship } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes';
import { normalizeAccount } from '../../normalizers';
import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';
import type { Account } from 'soapbox/types/entities';
describe('submitAccountNote()', () => {
let store: ReturnType<typeof mockStore>;
@ -72,13 +69,13 @@ describe('initAccountNoteModal()', () => {
});
it('dispatches the proper actions', async() => {
const account = normalizeAccount({
const account = buildAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
verified: true,
}) as Account;
});
const expectedActions = [
{ type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' },
{ type: 'MODAL_CLOSE', modalType: 'ACCOUNT_NOTE' },

@ -19,12 +19,10 @@ import {
fetchFollowing,
fetchFollowRequests,
fetchRelationships,
followAccount,
muteAccount,
removeFromFollowers,
subscribeAccount,
unblockAccount,
unfollowAccount,
unmuteAccount,
unsubscribeAccount,
} from '../accounts';
@ -76,9 +74,14 @@ describe('fetchAccount()', () => {
});
const state = rootState
.set('accounts', ImmutableMap({
[id]: account,
}) as any);
.set('entities', {
'ACCOUNTS': {
store: {
[id]: account,
},
lists: {},
},
});
store = mockStore(state);
@ -168,9 +171,14 @@ describe('fetchAccountByUsername()', () => {
});
state = rootState
.set('accounts', ImmutableMap({
[id]: account,
}));
.set('entities', {
'ACCOUNTS': {
store: {
[id]: account,
},
lists: {},
},
});
store = mockStore(state);
@ -371,169 +379,6 @@ describe('fetchAccountByUsername()', () => {
});
});
describe('followAccount()', () => {
describe('when logged out', () => {
beforeEach(() => {
const state = rootState.set('me', null);
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(followAccount('1'));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('when logged in', () => {
const id = '1';
beforeEach(() => {
const state = rootState.set('me', '123');
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onPost(`/api/v1/accounts/${id}/follow`).reply(200, { success: true });
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{
type: 'ACCOUNT_FOLLOW_REQUEST',
id,
locked: false,
skipLoading: true,
},
{
type: 'ACCOUNT_FOLLOW_SUCCESS',
relationship: { success: true },
alreadyFollowing: undefined,
skipLoading: true,
},
];
await store.dispatch(followAccount(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onPost(`/api/v1/accounts/${id}/follow`).networkError();
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{
type: 'ACCOUNT_FOLLOW_REQUEST',
id,
locked: false,
skipLoading: true,
},
{
type: 'ACCOUNT_FOLLOW_FAIL',
error: new Error('Network Error'),
locked: false,
skipLoading: true,
},
];
try {
await store.dispatch(followAccount(id));
} catch (e) {
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
expect(e).toEqual(new Error('Network Error'));
}
});
});
});
});
describe('unfollowAccount()', () => {
describe('when logged out', () => {
beforeEach(() => {
const state = rootState.set('me', null);
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(unfollowAccount('1'));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('when logged in', () => {
const id = '1';
beforeEach(() => {
const state = rootState.set('me', '123');
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onPost(`/api/v1/accounts/${id}/unfollow`).reply(200, { success: true });
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'ACCOUNT_UNFOLLOW_REQUEST', id: '1', skipLoading: true },
{
type: 'ACCOUNT_UNFOLLOW_SUCCESS',
relationship: { success: true },
statuses: ImmutableMap({}),
skipLoading: true,
},
];
await store.dispatch(unfollowAccount(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onPost(`/api/v1/accounts/${id}/unfollow`).networkError();
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{
type: 'ACCOUNT_UNFOLLOW_REQUEST',
id,
skipLoading: true,
},
{
type: 'ACCOUNT_UNFOLLOW_FAIL',
error: new Error('Network Error'),
skipLoading: true,
},
];
await store.dispatch(unfollowAccount(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});
describe('blockAccount()', () => {
const id = '1';

@ -1,13 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildAccount } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { AccountRecord } from 'soapbox/normalizers';
import { AuthUserRecord, ReducerRecord } from 'soapbox/reducers/auth';
import { AuthUserRecord, ReducerRecord } from '../../reducers/auth';
import {
fetchMe, patchMe,
} from '../me';
import { fetchMe, patchMe } from '../me';
jest.mock('../../storage/kv-store', () => ({
__esModule: true,
@ -48,11 +46,15 @@ describe('fetchMe()', () => {
}),
}),
}))
.set('accounts', ImmutableMap({
[accountUrl]: AccountRecord({
url: accountUrl,
}),
}) as any);
.set('entities', {
'ACCOUNTS': {
store: {
[accountUrl]: buildAccount({ url: accountUrl }),
},
lists: {},
},
});
store = mockStore(state);
});

@ -1,4 +1,6 @@
import { rootState } from '../../jest/test-helpers';
import { rootState } from 'soapbox/jest/test-helpers';
import { RootState } from 'soapbox/store';
import { getSoapboxConfig } from '../soapbox';
const ASCII_HEART = '❤'; // '\u2764\uFE0F'
@ -6,13 +8,13 @@ const RED_HEART_RGI = '❤️'; // '\u2764'
describe('getSoapboxConfig()', () => {
it('returns RGI heart on Pleroma > 2.3', () => {
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.3.0)');
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.3.0)') as RootState;
expect(getSoapboxConfig(state).allowedEmoji.includes(RED_HEART_RGI)).toBe(true);
expect(getSoapboxConfig(state).allowedEmoji.includes(ASCII_HEART)).toBe(false);
});
it('returns an ASCII heart on Pleroma < 2.3', () => {
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.0.0)');
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.0.0)') as RootState;
expect(getSoapboxConfig(state).allowedEmoji.includes(ASCII_HEART)).toBe(true);
expect(getSoapboxConfig(state).allowedEmoji.includes(RED_HEART_RGI)).toBe(false);
});

@ -4,8 +4,8 @@ import { openModal, closeModal } from './modals';
import type { AxiosError } from 'axios';
import type { AnyAction } from 'redux';
import type { Account } from 'soapbox/schemas';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account } from 'soapbox/types/entities';
const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';

@ -1,3 +1,5 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features';
@ -23,14 +25,6 @@ const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL';
const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
@ -227,81 +221,6 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({
skipAlert: true,
});
type FollowAccountOpts = {
reblogs?: boolean
notify?: boolean
};
const followAccount = (id: string, options?: FollowAccountOpts) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
const alreadyFollowing = getState().relationships.get(id)?.following || undefined;
const locked = getState().accounts.get(id)?.locked || false;
dispatch(followAccountRequest(id, locked));
return api(getState)
.post(`/api/v1/accounts/${id}/follow`, options)
.then(response => dispatch(followAccountSuccess(response.data, alreadyFollowing)))
.catch(error => {
dispatch(followAccountFail(error, locked));
throw error;
});
};
const unfollowAccount = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
dispatch(unfollowAccountRequest(id));
return api(getState)
.post(`/api/v1/accounts/${id}/unfollow`)
.then(response => dispatch(unfollowAccountSuccess(response.data, getState().statuses)))
.catch(error => dispatch(unfollowAccountFail(error)));
};
const followAccountRequest = (id: string, locked: boolean) => ({
type: ACCOUNT_FOLLOW_REQUEST,
id,
locked,
skipLoading: true,
});
const followAccountSuccess = (relationship: APIEntity, alreadyFollowing?: boolean) => ({
type: ACCOUNT_FOLLOW_SUCCESS,
relationship,
alreadyFollowing,
skipLoading: true,
});
const followAccountFail = (error: AxiosError, locked: boolean) => ({
type: ACCOUNT_FOLLOW_FAIL,
error,
locked,
skipLoading: true,
});
const unfollowAccountRequest = (id: string) => ({
type: ACCOUNT_UNFOLLOW_REQUEST,
id,
skipLoading: true,
});
const unfollowAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap<string, Status>) => ({
type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship,
statuses,
skipLoading: true,
});
const unfollowAccountFail = (error: AxiosError) => ({
type: ACCOUNT_UNFOLLOW_FAIL,
error,
skipLoading: true,
});
const blockAccount = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
@ -690,7 +609,10 @@ const fetchRelationships = (accountIds: string[]) =>
return api(getState)
.get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`)
.then(response => dispatch(fetchRelationshipsSuccess(response.data)))
.then(response => {
dispatch(importEntities(response.data, Entities.RELATIONSHIPS));
dispatch(fetchRelationshipsSuccess(response.data));
})
.catch(error => dispatch(fetchRelationshipsFail(error)));
};
@ -988,12 +910,6 @@ export {
ACCOUNT_FETCH_REQUEST,
ACCOUNT_FETCH_SUCCESS,
ACCOUNT_FETCH_FAIL,
ACCOUNT_FOLLOW_REQUEST,
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_FOLLOW_FAIL,
ACCOUNT_UNFOLLOW_REQUEST,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_FAIL,
ACCOUNT_BLOCK_REQUEST,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_BLOCK_FAIL,
@ -1069,14 +985,6 @@ export {
fetchAccountRequest,
fetchAccountSuccess,
fetchAccountFail,
followAccount,
unfollowAccount,
followAccountRequest,
followAccountSuccess,
followAccountFail,
unfollowAccountRequest,
unfollowAccountSuccess,
unfollowAccountFail,
blockAccount,
unblockAccount,
blockAccountRequest,

@ -10,8 +10,8 @@ import { importFetchedAccounts } from './importer';
import { patchMeSuccess } from './me';
import type { AxiosError } from 'axios';
import type { Account } from 'soapbox/schemas';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Account } from 'soapbox/types/entities';
const ALIASES_FETCH_REQUEST = 'ALIASES_FETCH_REQUEST';
const ALIASES_FETCH_SUCCESS = 'ALIASES_FETCH_SUCCESS';
@ -56,7 +56,7 @@ const fetchAliasesRequest = () => ({
type: ALIASES_FETCH_REQUEST,
});
const fetchAliasesSuccess = (aliases: APIEntity[]) => ({
const fetchAliasesSuccess = (aliases: unknown[]) => ({
type: ALIASES_FETCH_SUCCESS,
value: aliases,
});
@ -82,7 +82,7 @@ const fetchAliasesSuggestions = (q: string) =>
}).catch(error => toast.showAlertForError(error));
};
const fetchAliasesSuggestionsReady = (query: string, accounts: APIEntity[]) => ({
const fetchAliasesSuggestionsReady = (query: string, accounts: unknown[]) => ({
type: ALIASES_SUGGESTIONS_READY,
query,
accounts,
@ -111,7 +111,7 @@ const addToAliases = (account: Account) =>
dispatch(addToAliasesRequest());
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma.get('ap_id')] })
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma?.ap_id] })
.then((response => {
toast.success(messages.createSuccess);
dispatch(addToAliasesSuccess);

@ -24,72 +24,71 @@ import { createStatus } from './statuses';
import type { EditorState } from 'lexical';
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import type { Emoji } from 'soapbox/features/emoji';
import type { Group } from 'soapbox/schemas';
import type { Account, Group } from 'soapbox/schemas';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
import type { APIEntity, Status, Tag } from 'soapbox/types/entities';
import type { History } from 'soapbox/types/history';
const { CancelToken, isCancel } = axios;
let cancelFetchComposeSuggestions: Canceler;
const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
const COMPOSE_REPLY = 'COMPOSE_REPLY';
const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY';
const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
const COMPOSE_MENTION = 'COMPOSE_MENTION';
const COMPOSE_RESET = 'COMPOSE_RESET';
const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST';
const COMPOSE_SET_GROUP_TIMELINE_VISIBLE = 'COMPOSE_SET_GROUP_TIMELINE_VISIBLE';
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE';
const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD';
const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE';
const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD';
const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE';
const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE';
const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD';
const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET';
const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE';
const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS';
const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS';
const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET';
const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const;
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const;
const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS' as const;
const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL' as const;
const COMPOSE_REPLY = 'COMPOSE_REPLY' as const;
const COMPOSE_EVENT_REPLY = 'COMPOSE_EVENT_REPLY' as const;
const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL' as const;
const COMPOSE_QUOTE = 'COMPOSE_QUOTE' as const;
const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL' as const;
const COMPOSE_DIRECT = 'COMPOSE_DIRECT' as const;
const COMPOSE_MENTION = 'COMPOSE_MENTION' as const;
const COMPOSE_RESET = 'COMPOSE_RESET' as const;
const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST' as const;
const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS' as const;
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL' as const;
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS' as const;
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO' as const;
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST' as const;
const COMPOSE_SET_GROUP_TIMELINE_VISIBLE = 'COMPOSE_SET_GROUP_TIMELINE_VISIBLE' as const;
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR' as const;
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY' as const;
const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT' as const;
const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE' as const;
const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE' as const;
const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE' as const;
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE' as const;
const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE' as const;
const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE' as const;
const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE' as const;
const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const;
const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST' as const;
const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS' as const;
const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL' as const;
const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD' as const;
const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE' as const;
const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD' as const;
const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE' as const;
const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE' as const;
const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE' as const;
const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD' as const;
const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET' as const;
const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE' as const;
const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS' as const;
const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS' as const;
const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS' as const;
const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET' as const;
const messages = defineMessages({
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
@ -105,12 +104,24 @@ const messages = defineMessages({
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
interface ComposeSetStatusAction {
type: typeof COMPOSE_SET_STATUS
id: string
status: Status
rawText: string
explicitAddressing: boolean
spoilerText?: string
contentType?: string | false
v: ReturnType<typeof parseVersion>
withRedraft?: boolean
}
const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const { instance } = getState();
const { explicitAddressing } = getFeatures(instance);
dispatch({
const action: ComposeSetStatusAction = {
type: COMPOSE_SET_STATUS,
id: 'compose-modal',
status,
@ -120,7 +131,9 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin
contentType,
v: parseVersion(instance.version),
withRedraft,
});
};
dispatch(action);
};
const changeCompose = (composeId: string, text: string) => ({
@ -129,20 +142,29 @@ const changeCompose = (composeId: string, text: string) => ({
text: text,
});
interface ComposeReplyAction {
type: typeof COMPOSE_REPLY
id: string
status: Status
account: Account
explicitAddressing: boolean
}
const replyCompose = (status: Status) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const { explicitAddressing } = getFeatures(instance);
dispatch({
const action: ComposeReplyAction = {
type: COMPOSE_REPLY,
id: 'compose-modal',
status: status,
account: state.accounts.get(state.me),
account: state.accounts.get(state.me)!,
explicitAddressing,
});
};
dispatch(action);
dispatch(openModal('COMPOSE'));
};
@ -151,20 +173,29 @@ const cancelReplyCompose = () => ({
id: 'compose-modal',
});
interface ComposeQuoteAction {
type: typeof COMPOSE_QUOTE
id: string
status: Status
account: Account | undefined
explicitAddressing: boolean
}
const quoteCompose = (status: Status) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const { explicitAddressing } = getFeatures(instance);
dispatch({
const action: ComposeQuoteAction = {
type: COMPOSE_QUOTE,
id: 'compose-modal',
status: status,
account: state.accounts.get(state.me),
explicitAddressing,
});
};
dispatch(action);
dispatch(openModal('COMPOSE'));
};
@ -186,38 +217,54 @@ const resetCompose = (composeId = 'compose-modal') => ({
id: composeId,
});
interface ComposeMentionAction {
type: typeof COMPOSE_MENTION
id: string
account: Account
}
const mentionCompose = (account: Account) =>
(dispatch: AppDispatch) => {
dispatch({
const action: ComposeMentionAction = {
type: COMPOSE_MENTION,
id: 'compose-modal',
account: account,
});
};
dispatch(action);
dispatch(openModal('COMPOSE'));
};
interface ComposeDirectAction {
type: typeof COMPOSE_DIRECT
id: string
account: Account
}
const directCompose = (account: Account) =>
(dispatch: AppDispatch) => {
dispatch({
const action: ComposeDirectAction = {
type: COMPOSE_DIRECT,
id: 'compose-modal',
account: account,
});
account,
};
dispatch(action);
dispatch(openModal('COMPOSE'));
};
const directComposeById = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const account = getState().accounts.get(accountId);
if (!account) return;
dispatch({
const action: ComposeDirectAction = {
type: COMPOSE_DIRECT,
id: 'compose-modal',
account: account,
});
account,
};
dispatch(action);
dispatch(openModal('COMPOSE'));
};
@ -494,14 +541,11 @@ const undoUploadCompose = (composeId: string, media_id: string) => ({
media_id: media_id,
});
const groupCompose = (composeId: string, groupId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: COMPOSE_GROUP_POST,
id: composeId,
group_id: groupId,
});
};
const groupCompose = (composeId: string, groupId: string) => ({
type: COMPOSE_GROUP_POST,
id: composeId,
group_id: groupId,
});
const setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolean) => ({
type: COMPOSE_SET_GROUP_TIMELINE_VISIBLE,
@ -598,6 +642,14 @@ const fetchComposeSuggestions = (composeId: string, token: string) =>
}
};
interface ComposeSuggestionsReadyAction {
type: typeof COMPOSE_SUGGESTIONS_READY
id: string
token: string
emojis?: Emoji[]
accounts?: APIEntity[]
}
const readyComposeSuggestionsEmojis = (composeId: string, token: string, emojis: Emoji[]) => ({
type: COMPOSE_SUGGESTIONS_READY,
id: composeId,
@ -612,6 +664,15 @@ const readyComposeSuggestionsAccounts = (composeId: string, token: string, accou
accounts,
});
interface ComposeSuggestionSelectAction {
type: typeof COMPOSE_SUGGESTION_SELECT
id: string
position: number
token: string | null
completion: string
path: Array<string | number>
}
const selectComposeSuggestion = (composeId: string, position: number, token: string | null, suggestion: AutoSuggestion, path: Array<string | number>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
let completion, startPosition;
@ -629,14 +690,16 @@ const selectComposeSuggestion = (composeId: string, position: number, token: str
startPosition = position;
}
dispatch({
const action: ComposeSuggestionSelectAction = {
type: COMPOSE_SUGGESTION_SELECT,
id: composeId,
position: startPosition,
token,
completion,
path,
});
};
dispatch(action);
};
const updateSuggestionTags = (composeId: string, token: string, tags: ImmutableList<Tag>) => ({
@ -746,7 +809,7 @@ const removePollOption = (composeId: string, index: number) => ({
index,
});
const changePollSettings = (composeId: string, expiresIn?: string | number, isMultiple?: boolean) => ({
const changePollSettings = (composeId: string, expiresIn?: number, isMultiple?: boolean) => ({
type: COMPOSE_POLL_SETTINGS_CHANGE,
id: composeId,
expiresIn,
@ -760,30 +823,54 @@ const openComposeWithText = (composeId: string, text = '') =>
dispatch(changeCompose(composeId, text));
};
interface ComposeAddToMentionsAction {
type: typeof COMPOSE_ADD_TO_MENTIONS
id: string
account: string
}
const addToMentions = (composeId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const acct = state.accounts.get(accountId)!.acct;
return dispatch({
const action: ComposeAddToMentionsAction = {
type: COMPOSE_ADD_TO_MENTIONS,
id: composeId,
account: acct,
});
};
return dispatch(action);
};
interface ComposeRemoveFromMentionsAction {
type: typeof COMPOSE_REMOVE_FROM_MENTIONS
id: string
account: string
}
const removeFromMentions = (composeId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const acct = state.accounts.get(accountId)!.acct;
return dispatch({
const action: ComposeRemoveFromMentionsAction = {
type: COMPOSE_REMOVE_FROM_MENTIONS,
id: composeId,
account: acct,
});
};
return dispatch(action);
};
interface ComposeEventReplyAction {
type: typeof COMPOSE_EVENT_REPLY
id: string
status: Status
account: Account
explicitAddressing: boolean
}
const eventDiscussionCompose = (composeId: string, status: Status) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
@ -805,6 +892,53 @@ const setEditorState = (composeId: string, editorState: EditorState | string | n
editorState: editorState,
});
type ComposeAction =
ComposeSetStatusAction
| ReturnType<typeof changeCompose>
| ComposeReplyAction
| ReturnType<typeof cancelReplyCompose>
| ComposeQuoteAction
| ReturnType<typeof cancelQuoteCompose>
| ReturnType<typeof resetCompose>
| ComposeMentionAction
| ComposeDirectAction
| ReturnType<typeof submitComposeRequest>
| ReturnType<typeof submitComposeSuccess>
| ReturnType<typeof submitComposeFail>
| ReturnType<typeof changeUploadComposeRequest>
| ReturnType<typeof changeUploadComposeSuccess>
| ReturnType<typeof changeUploadComposeFail>
| ReturnType<typeof uploadComposeRequest>
| ReturnType<typeof uploadComposeProgress>
| ReturnType<typeof uploadComposeSuccess>
| ReturnType<typeof uploadComposeFail>
| ReturnType<typeof undoUploadCompose>
| ReturnType<typeof groupCompose>
| ReturnType<typeof setGroupTimelineVisible>
| ReturnType<typeof clearComposeSuggestions>
| ComposeSuggestionsReadyAction
| ComposeSuggestionSelectAction
| ReturnType<typeof updateSuggestionTags>
| ReturnType<typeof updateTagHistory>
| ReturnType<typeof changeComposeSpoilerness>
| ReturnType<typeof changeComposeContentType>
| ReturnType<typeof changeComposeSpoilerText>
| ReturnType<typeof changeComposeVisibility>
| ReturnType<typeof insertEmojiCompose>
| ReturnType<typeof addPoll>
| ReturnType<typeof removePoll>
| ReturnType<typeof addSchedule>
| ReturnType<typeof setSchedule>
| ReturnType<typeof removeSchedule>
| ReturnType<typeof addPollOption>
| ReturnType<typeof changePollOption>
| ReturnType<typeof removePollOption>
| ReturnType<typeof changePollSettings>
| ComposeAddToMentionsAction
| ComposeRemoveFromMentionsAction
| ComposeEventReplyAction
| ReturnType<typeof setEditorState>
export {
COMPOSE_CHANGE,
COMPOSE_SUBMIT_REQUEST,
@ -834,7 +968,6 @@ export {
COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LISTABILITY_CHANGE,
COMPOSE_COMPOSING_CHANGE,
COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
@ -907,4 +1040,5 @@ export {
removeFromMentions,
eventDiscussionCompose,
setEditorState,
type ComposeAction,
};

@ -3,7 +3,6 @@ import { isLoggedIn } from 'soapbox/utils/auth';
import api, { getLinks } from '../api';
import type { AxiosError } from 'axios';
import type { List as ImmutableList } from 'immutable';
import type { AppDispatch, RootState } from 'soapbox/store';
const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
@ -30,8 +29,11 @@ const blockDomain = (domain: string) =>
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
const at_domain = '@' + domain;
const accounts = getState().accounts.filter(item => item.acct.endsWith(at_domain)).valueSeq().map(item => item.id);
dispatch(blockDomainSuccess(domain, accounts.toList()));
const accounts = getState().accounts
.filter(item => item.acct.endsWith(at_domain))
.map(item => item.id);
dispatch(blockDomainSuccess(domain, accounts));
}).catch(err => {
dispatch(blockDomainFail(domain, err));
});
@ -42,7 +44,7 @@ const blockDomainRequest = (domain: string) => ({
domain,
});
const blockDomainSuccess = (domain: string, accounts: ImmutableList<string>) => ({
const blockDomainSuccess = (domain: string, accounts: string[]) => ({
type: DOMAIN_BLOCK_SUCCESS,
domain,
accounts,
@ -68,8 +70,8 @@ const unblockDomain = (domain: string) =>
api(getState).delete('/api/v1/domain_blocks', params).then(() => {
const at_domain = '@' + domain;
const accounts = getState().accounts.filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
dispatch(unblockDomainSuccess(domain, accounts.toList()));
const accounts = getState().accounts.filter(item => item.acct.endsWith(at_domain)).map(item => item.id);
dispatch(unblockDomainSuccess(domain, accounts));
}).catch(err => {
dispatch(unblockDomainFail(domain, err));
});
@ -80,7 +82,7 @@ const unblockDomainRequest = (domain: string) => ({
domain,
});
const unblockDomainSuccess = (domain: string, accounts: ImmutableList<string>) => ({
const unblockDomainSuccess = (domain: string, accounts: string[]) => ({
type: DOMAIN_UNBLOCK_SUCCESS,
domain,
accounts,

@ -77,7 +77,7 @@ const emojiReact = (status: Status, emoji: string, custom?: string) =>
dispatch(emojiReactRequest(status, emoji, custom));
return api(getState)
.put(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
.put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`)
.then(function(response) {
dispatch(importFetchedStatus(response.data));
dispatch(emojiReactSuccess(status, emoji));
@ -93,7 +93,7 @@ const unEmojiReact = (status: Status, emoji: string) =>
dispatch(unEmojiReactRequest(status, emoji));
return api(getState)
.delete(`/api/v1/pleroma/statuses/${status.get('id')}/reactions/${emoji}`)
.delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`)
.then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(unEmojiReactSuccess(status, emoji));

@ -15,73 +15,74 @@ import {
} from './statuses';
import type { AxiosError } from 'axios';
import type { ReducerStatus } from 'soapbox/reducers/statuses';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status as StatusEntity } from 'soapbox/types/entities';
const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST';
const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS';
const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL';
const LOCATION_SEARCH_REQUEST = 'LOCATION_SEARCH_REQUEST' as const;
const LOCATION_SEARCH_SUCCESS = 'LOCATION_SEARCH_SUCCESS' as const;
const LOCATION_SEARCH_FAIL = 'LOCATION_SEARCH_FAIL' as const;
const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE';
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE';
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE';
const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE';
const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE';
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE';
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE';
const EDIT_EVENT_NAME_CHANGE = 'EDIT_EVENT_NAME_CHANGE' as const;
const EDIT_EVENT_DESCRIPTION_CHANGE = 'EDIT_EVENT_DESCRIPTION_CHANGE' as const;
const EDIT_EVENT_START_TIME_CHANGE = 'EDIT_EVENT_START_TIME_CHANGE' as const;
const EDIT_EVENT_HAS_END_TIME_CHANGE = 'EDIT_EVENT_HAS_END_TIME_CHANGE' as const;
const EDIT_EVENT_END_TIME_CHANGE = 'EDIT_EVENT_END_TIME_CHANGE' as const;
const EDIT_EVENT_APPROVAL_REQUIRED_CHANGE = 'EDIT_EVENT_APPROVAL_REQUIRED_CHANGE' as const;
const EDIT_EVENT_LOCATION_CHANGE = 'EDIT_EVENT_LOCATION_CHANGE' as const;
const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST';
const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS';
const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS';
const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL';
const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO';
const EVENT_BANNER_UPLOAD_REQUEST = 'EVENT_BANNER_UPLOAD_REQUEST' as const;
const EVENT_BANNER_UPLOAD_PROGRESS = 'EVENT_BANNER_UPLOAD_PROGRESS' as const;
const EVENT_BANNER_UPLOAD_SUCCESS = 'EVENT_BANNER_UPLOAD_SUCCESS' as const;
const EVENT_BANNER_UPLOAD_FAIL = 'EVENT_BANNER_UPLOAD_FAIL' as const;
const EVENT_BANNER_UPLOAD_UNDO = 'EVENT_BANNER_UPLOAD_UNDO' as const;
const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST';
const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS';
const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL';
const EVENT_SUBMIT_REQUEST = 'EVENT_SUBMIT_REQUEST' as const;
const EVENT_SUBMIT_SUCCESS = 'EVENT_SUBMIT_SUCCESS' as const;
const EVENT_SUBMIT_FAIL = 'EVENT_SUBMIT_FAIL' as const;
const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST';
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS';
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL';
const EVENT_JOIN_REQUEST = 'EVENT_JOIN_REQUEST' as const;
const EVENT_JOIN_SUCCESS = 'EVENT_JOIN_SUCCESS' as const;
const EVENT_JOIN_FAIL = 'EVENT_JOIN_FAIL' as const;
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST';
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS';
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL';
const EVENT_LEAVE_REQUEST = 'EVENT_LEAVE_REQUEST' as const;
const EVENT_LEAVE_SUCCESS = 'EVENT_LEAVE_SUCCESS' as const;
const EVENT_LEAVE_FAIL = 'EVENT_LEAVE_FAIL' as const;
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST';
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS';
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL';
const EVENT_PARTICIPATIONS_FETCH_REQUEST = 'EVENT_PARTICIPATIONS_FETCH_REQUEST' as const;
const EVENT_PARTICIPATIONS_FETCH_SUCCESS = 'EVENT_PARTICIPATIONS_FETCH_SUCCESS' as const;
const EVENT_PARTICIPATIONS_FETCH_FAIL = 'EVENT_PARTICIPATIONS_FETCH_FAIL' as const;
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST';
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS';
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL';
const EVENT_PARTICIPATIONS_EXPAND_REQUEST = 'EVENT_PARTICIPATIONS_EXPAND_REQUEST' as const;
const EVENT_PARTICIPATIONS_EXPAND_SUCCESS = 'EVENT_PARTICIPATIONS_EXPAND_SUCCESS' as const;
const EVENT_PARTICIPATIONS_EXPAND_FAIL = 'EVENT_PARTICIPATIONS_EXPAND_FAIL' as const;
const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST';
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS';
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL';
const EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_FETCH_REQUEST' as const;
const EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_FETCH_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL = 'EVENT_PARTICIPATION_REQUESTS_FETCH_FAIL' as const;
const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL';
const EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_REQUEST' as const;
const EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL = 'EVENT_PARTICIPATION_REQUESTS_EXPAND_FAIL' as const;
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL';
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_REQUEST' as const;
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL = 'EVENT_PARTICIPATION_REQUEST_AUTHORIZE_FAIL' as const;
const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST';
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS';
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL';
const EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST = 'EVENT_PARTICIPATION_REQUEST_REJECT_REQUEST' as const;
const EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS = 'EVENT_PARTICIPATION_REQUEST_REJECT_SUCCESS' as const;
const EVENT_PARTICIPATION_REQUEST_REJECT_FAIL = 'EVENT_PARTICIPATION_REQUEST_REJECT_FAIL' as const;
const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL';
const EVENT_COMPOSE_CANCEL = 'EVENT_COMPOSE_CANCEL' as const;
const EVENT_FORM_SET = 'EVENT_FORM_SET';
const EVENT_FORM_SET = 'EVENT_FORM_SET' as const;
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST';
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS';
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL';
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST';
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS';
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL';
const RECENT_EVENTS_FETCH_REQUEST = 'RECENT_EVENTS_FETCH_REQUEST' as const;
const RECENT_EVENTS_FETCH_SUCCESS = 'RECENT_EVENTS_FETCH_SUCCESS' as const;
const RECENT_EVENTS_FETCH_FAIL = 'RECENT_EVENTS_FETCH_FAIL' as const;
const JOINED_EVENTS_FETCH_REQUEST = 'JOINED_EVENTS_FETCH_REQUEST' as const;
const JOINED_EVENTS_FETCH_SUCCESS = 'JOINED_EVENTS_FETCH_SUCCESS' as const;
const JOINED_EVENTS_FETCH_FAIL = 'JOINED_EVENTS_FETCH_FAIL' as const;
const noOp = () => new Promise(f => f(undefined));
@ -576,6 +577,13 @@ const cancelEventCompose = () => ({
type: EVENT_COMPOSE_CANCEL,
});
interface EventFormSetAction {
type: typeof EVENT_FORM_SET
status: ReducerStatus
text: string
location: Record<string, any>
}
const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
const status = getState().statuses.get(id)!;
@ -637,6 +645,10 @@ const fetchJoinedEvents = () =>
});
};
type EventsAction =
| ReturnType<typeof cancelEventCompose>
| EventFormSetAction;
export {
LOCATION_SEARCH_REQUEST,
LOCATION_SEARCH_SUCCESS,
@ -743,4 +755,5 @@ export {
editEvent,
fetchRecentEvents,
fetchJoinedEvents,
type EventsAction,
};

@ -2,8 +2,11 @@ import { AppDispatch, RootState } from 'soapbox/store';
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import type { APIEntity } from 'soapbox/types/entities';
export const FAMILIAR_FOLLOWERS_FETCH_REQUEST = 'FAMILIAR_FOLLOWERS_FETCH_REQUEST';
export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCESS';
export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL';
@ -19,6 +22,7 @@ export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: A
const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts;
dispatch(importFetchedAccounts(accounts));
dispatch(fetchRelationships(accounts.map((item: APIEntity) => item.id)));
dispatch({
type: FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
id: accountId,

@ -1,6 +1,6 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { Group, groupSchema } from 'soapbox/schemas';
import { Group, accountSchema, groupSchema } from 'soapbox/schemas';
import { filteredArray } from 'soapbox/schemas/utils';
import { getSettings } from '../settings';
@ -17,11 +17,27 @@ const STATUSES_IMPORT = 'STATUSES_IMPORT';
const POLLS_IMPORT = 'POLLS_IMPORT';
const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP';
const importAccount = (account: APIEntity) =>
({ type: ACCOUNT_IMPORT, account });
const importAccount = (data: APIEntity) =>
(dispatch: AppDispatch, _getState: () => RootState) => {
dispatch({ type: ACCOUNT_IMPORT, account: data });
try {
const account = accountSchema.parse(data);
dispatch(importEntities([account], Entities.ACCOUNTS));
} catch (e) {
//
}
};
const importAccounts = (accounts: APIEntity[]) =>
({ type: ACCOUNTS_IMPORT, accounts });
const importAccounts = (data: APIEntity[]) =>
(dispatch: AppDispatch, _getState: () => RootState) => {
dispatch({ type: ACCOUNTS_IMPORT, accounts: data });
try {
const accounts = filteredArray(accountSchema).parse(data);
dispatch(importEntities(accounts, Entities.ACCOUNTS));
} catch (e) {
//
}
};
const importGroup = (group: Group) =>
importEntities([group], Entities.GROUPS);

@ -3,7 +3,7 @@ import { defineMessages } from 'react-intl';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import api from '../api';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer';
@ -73,6 +73,12 @@ const REMOTE_INTERACTION_REQUEST = 'REMOTE_INTERACTION_REQUEST';
const REMOTE_INTERACTION_SUCCESS = 'REMOTE_INTERACTION_SUCCESS';
const REMOTE_INTERACTION_FAIL = 'REMOTE_INTERACTION_FAIL';
const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
const messages = defineMessages({
bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' },
bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' },
@ -85,7 +91,7 @@ const reblog = (status: StatusEntity) =>
dispatch(reblogRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function(response) {
api(getState).post(`/api/v1/statuses/${status.id}/reblog`).then(function(response) {
// The reblog API method returns a new status wrapped around the original. In this case we are only
// interested in how the original is modified, hence passing it skipping the wrapper
dispatch(importFetchedStatus(response.data.reblog));
@ -101,7 +107,7 @@ const unreblog = (status: StatusEntity) =>
dispatch(unreblogRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(() => {
api(getState).post(`/api/v1/statuses/${status.id}/unreblog`).then(() => {
dispatch(unreblogSuccess(status));
}).catch(error => {
dispatch(unreblogFail(status, error));
@ -234,7 +240,7 @@ const dislike = (status: StatusEntity) =>
dispatch(dislikeRequest(status));
api(getState).post(`/api/friendica/statuses/${status.get('id')}/dislike`).then(function() {
api(getState).post(`/api/friendica/statuses/${status.id}/dislike`).then(function() {
dispatch(dislikeSuccess(status));
}).catch(function(error) {
dispatch(dislikeFail(status, error));
@ -247,7 +253,7 @@ const undislike = (status: StatusEntity) =>
dispatch(undislikeRequest(status));
api(getState).post(`/api/friendica/statuses/${status.get('id')}/undislike`).then(() => {
api(getState).post(`/api/friendica/statuses/${status.id}/undislike`).then(() => {
dispatch(undislikeSuccess(status));
}).catch(error => {
dispatch(undislikeFail(status, error));
@ -305,7 +311,7 @@ const bookmark = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(bookmarkRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) {
api(getState).post(`/api/v1/statuses/${status.id}/bookmark`).then(function(response) {
dispatch(importFetchedStatus(response.data));
dispatch(bookmarkSuccess(status, response.data));
toast.success(messages.bookmarkAdded, {
@ -321,7 +327,7 @@ const unbookmark = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(unbookmarkRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => {
api(getState).post(`/api/v1/statuses/${status.id}/unbookmark`).then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(unbookmarkSuccess(status, response.data));
toast.success(messages.bookmarkRemoved);
@ -380,9 +386,10 @@ const fetchReblogs = (id: string) =>
dispatch(fetchReblogsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
dispatch(fetchReblogsSuccess(id, response.data));
dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchReblogsFail(id, error));
});
@ -393,10 +400,11 @@ const fetchReblogsRequest = (id: string) => ({
id,
});
const fetchReblogsSuccess = (id: string, accounts: APIEntity[]) => ({
const fetchReblogsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: REBLOGS_FETCH_SUCCESS,
id,
accounts,
next,
});
const fetchReblogsFail = (id: string, error: AxiosError) => ({
@ -405,6 +413,31 @@ const fetchReblogsFail = (id: string, error: AxiosError) => ({
error,
});
const expandReblogs = (id: string, path: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
api(getState).get(path).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandReblogsFail(id, error));
});
};
const expandReblogsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: REBLOGS_EXPAND_SUCCESS,
id,
accounts,
next,
});
const expandReblogsFail = (id: string, error: AxiosError) => ({
type: REBLOGS_EXPAND_FAIL,
id,
error,
});
const fetchFavourites = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
@ -412,9 +445,10 @@ const fetchFavourites = (id: string) =>
dispatch(fetchFavouritesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
dispatch(fetchFavouritesSuccess(id, response.data));
dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchFavouritesFail(id, error));
});
@ -425,10 +459,11 @@ const fetchFavouritesRequest = (id: string) => ({
id,
});
const fetchFavouritesSuccess = (id: string, accounts: APIEntity[]) => ({
const fetchFavouritesSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: FAVOURITES_FETCH_SUCCESS,
id,
accounts,
next,
});
const fetchFavouritesFail = (id: string, error: AxiosError) => ({
@ -437,6 +472,31 @@ const fetchFavouritesFail = (id: string, error: AxiosError) => ({
error,
});
const expandFavourites = (id: string, path: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
api(getState).get(path).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFavouritesFail(id, error));
});
};
const expandFavouritesSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
type: FAVOURITES_EXPAND_SUCCESS,
id,
accounts,
next,
});
const expandFavouritesFail = (id: string, error: AxiosError) => ({
type: FAVOURITES_EXPAND_FAIL,
id,
error,
});
const fetchDislikes = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
@ -504,7 +564,7 @@ const pin = (status: StatusEntity) =>
dispatch(pinRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
api(getState).post(`/api/v1/statuses/${status.id}/pin`).then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(pinSuccess(status));
}).catch(error => {
@ -515,14 +575,14 @@ const pin = (status: StatusEntity) =>
const pinToGroup = (status: StatusEntity, group: Group) =>
(dispatch: AppDispatch, getState: () => RootState) => {
return api(getState)
.post(`/api/v1/groups/${group.id}/statuses/${status.get('id')}/pin`)
.post(`/api/v1/groups/${group.id}/statuses/${status.id}/pin`)
.then(() => dispatch(expandGroupFeaturedTimeline(group.id)));
};
const unpinFromGroup = (status: StatusEntity, group: Group) =>
(dispatch: AppDispatch, getState: () => RootState) => {
return api(getState)
.post(`/api/v1/groups/${group.id}/statuses/${status.get('id')}/unpin`)
.post(`/api/v1/groups/${group.id}/statuses/${status.id}/unpin`)
.then(() => dispatch(expandGroupFeaturedTimeline(group.id)));
};
@ -551,7 +611,7 @@ const unpin = (status: StatusEntity) =>
dispatch(unpinRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
api(getState).post(`/api/v1/statuses/${status.id}/unpin`).then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(unpinSuccess(status));
}).catch(error => {
@ -669,6 +729,10 @@ export {
REMOTE_INTERACTION_REQUEST,
REMOTE_INTERACTION_SUCCESS,
REMOTE_INTERACTION_FAIL,
FAVOURITES_EXPAND_SUCCESS,
FAVOURITES_EXPAND_FAIL,
REBLOGS_EXPAND_SUCCESS,
REBLOGS_EXPAND_FAIL,
reblog,
unreblog,
toggleReblog,
@ -709,10 +773,12 @@ export {
fetchReblogsRequest,
fetchReblogsSuccess,
fetchReblogsFail,
expandReblogs,
fetchFavourites,
fetchFavouritesRequest,
fetchFavouritesSuccess,
fetchFavouritesFail,
expandFavourites,
fetchDislikes,
fetchDislikesRequest,
fetchDislikesSuccess,

@ -10,14 +10,14 @@ import type { AxiosError, RawAxiosRequestHeaders } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
const ME_FETCH_REQUEST = 'ME_FETCH_REQUEST';
const ME_FETCH_SUCCESS = 'ME_FETCH_SUCCESS';
const ME_FETCH_FAIL = 'ME_FETCH_FAIL';
const ME_FETCH_SKIP = 'ME_FETCH_SKIP';
const ME_FETCH_REQUEST = 'ME_FETCH_REQUEST' as const;
const ME_FETCH_SUCCESS = 'ME_FETCH_SUCCESS' as const;
const ME_FETCH_FAIL = 'ME_FETCH_FAIL' as const;
const ME_FETCH_SKIP = 'ME_FETCH_SKIP' as const;
const ME_PATCH_REQUEST = 'ME_PATCH_REQUEST';
const ME_PATCH_SUCCESS = 'ME_PATCH_SUCCESS';
const ME_PATCH_FAIL = 'ME_PATCH_FAIL';
const ME_PATCH_REQUEST = 'ME_PATCH_REQUEST' as const;
const ME_PATCH_SUCCESS = 'ME_PATCH_SUCCESS' as const;
const ME_PATCH_FAIL = 'ME_PATCH_FAIL' as const;
const noOp = () => new Promise(f => f(undefined));
@ -85,13 +85,10 @@ const fetchMeRequest = () => ({
type: ME_FETCH_REQUEST,
});
const fetchMeSuccess = (me: APIEntity) =>
(dispatch: AppDispatch) => {
dispatch({
type: ME_FETCH_SUCCESS,
me,
});
};
const fetchMeSuccess = (me: APIEntity) => ({
type: ME_FETCH_SUCCESS,
me,
});
const fetchMeFail = (error: APIEntity) => ({
type: ME_FETCH_FAIL,
@ -103,13 +100,20 @@ const patchMeRequest = () => ({
type: ME_PATCH_REQUEST,
});
interface MePatchSuccessAction {
type: typeof ME_PATCH_SUCCESS
me: APIEntity
}
const patchMeSuccess = (me: APIEntity) =>
(dispatch: AppDispatch) => {
dispatch(importFetchedAccount(me));
dispatch({
const action: MePatchSuccessAction = {
type: ME_PATCH_SUCCESS,
me,
});
};
dispatch(importFetchedAccount(me));
dispatch(action);
};
const patchMeFail = (error: AxiosError) => ({
@ -118,6 +122,14 @@ const patchMeFail = (error: AxiosError) => ({
skipAlert: true,
});
type MeAction =
| ReturnType<typeof fetchMeRequest>
| ReturnType<typeof fetchMeSuccess>
| ReturnType<typeof fetchMeFail>
| ReturnType<typeof patchMeRequest>
| MePatchSuccessAction
| ReturnType<typeof patchMeFail>;
export {
ME_FETCH_REQUEST,
ME_FETCH_SUCCESS,
@ -134,4 +146,5 @@ export {
patchMeRequest,
patchMeSuccess,
patchMeFail,
type MeAction,
};

@ -48,7 +48,7 @@ const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm =
const message = (
<Stack space={4}>
<OutlineBox>
<AccountContainer id={accountId} />
<AccountContainer id={accountId} hideActions />
</OutlineBox>
<Text>
@ -83,7 +83,7 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () =
const message = (
<Stack space={4}>
<OutlineBox>
<AccountContainer id={accountId} />
<AccountContainer id={accountId} hideActions />
</OutlineBox>
<Text>

@ -1,95 +1,14 @@
import { isLoggedIn } from 'soapbox/utils/auth';
import { getNextLinkName } from 'soapbox/utils/quirks';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import { openModal } from './modals';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Account as AccountEntity } from 'soapbox/types/entities';
const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL';
const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
import type { Account } from 'soapbox/schemas';
import type { AppDispatch } from 'soapbox/store';
import type { Account as AccountEntity } from 'soapbox/types/entities';
const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
const fetchMutes = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const nextLinkName = getNextLinkName(getState);
dispatch(fetchMutesRequest());
api(getState).get('/api/v1/mutes').then(response => {
const next = getLinks(response).refs.find(link => link.rel === nextLinkName);
dispatch(importFetchedAccounts(response.data));
dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
}).catch(error => dispatch(fetchMutesFail(error)));
};
const fetchMutesRequest = () => ({
type: MUTES_FETCH_REQUEST,
});
const fetchMutesSuccess = (accounts: APIEntity[], next: string | null) => ({
type: MUTES_FETCH_SUCCESS,
accounts,
next,
});
const fetchMutesFail = (error: AxiosError) => ({
type: MUTES_FETCH_FAIL,
error,
});
const expandMutes = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const nextLinkName = getNextLinkName(getState);
const url = getState().user_lists.mutes.next;
if (url === null) {
return;
}
dispatch(expandMutesRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === nextLinkName);
dispatch(importFetchedAccounts(response.data));
dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
}).catch(error => dispatch(expandMutesFail(error)));
};
const expandMutesRequest = () => ({
type: MUTES_EXPAND_REQUEST,
});
const expandMutesSuccess = (accounts: APIEntity[], next: string | null) => ({
type: MUTES_EXPAND_SUCCESS,
accounts,
next,
});
const expandMutesFail = (error: AxiosError) => ({
type: MUTES_EXPAND_FAIL,
error,
});
const initMuteModal = (account: AccountEntity) =>
const initMuteModal = (account: AccountEntity | Account) =>
(dispatch: AppDispatch) => {
dispatch({
type: MUTES_INIT_MODAL,
@ -113,23 +32,9 @@ const changeMuteDuration = (duration: number) =>
};
export {
MUTES_FETCH_REQUEST,
MUTES_FETCH_SUCCESS,
MUTES_FETCH_FAIL,
MUTES_EXPAND_REQUEST,
MUTES_EXPAND_SUCCESS,
MUTES_EXPAND_FAIL,
MUTES_INIT_MODAL,
MUTES_TOGGLE_HIDE_NOTIFICATIONS,
MUTES_CHANGE_DURATION,
fetchMutes,
fetchMutesRequest,
fetchMutesSuccess,
fetchMutesFail,
expandMutes,
expandMutesRequest,
expandMutesSuccess,
expandMutesFail,
initMuteModal,
toggleHideNotifications,
changeMuteDuration,

@ -12,6 +12,7 @@ import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from 'soapbox/utils/notification';
import { joinPublicPath } from 'soapbox/utils/static';
import { fetchRelationships } from './accounts';
import { fetchGroupRelationships } from './groups';
import {
importFetchedAccount,
importFetchedAccounts,
@ -23,7 +24,7 @@ import { getSettings, saveSettings } from './settings';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
import type { APIEntity, Status } from 'soapbox/types/entities';
const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -237,6 +238,9 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
dispatch(importFetchedAccounts(Object.values(entries.accounts)));
dispatch(importFetchedStatuses(Object.values(entries.statuses)));
const statusesFromGroups = (Object.values(entries.statuses) as Status[]).filter((status) => !!status.group);
dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id)));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore));
fetchRelatedRelationships(dispatch, response.data);
done();

@ -3,8 +3,9 @@ import api from '../api';
import { openModal } from './modals';
import type { AxiosError } from 'axios';
import type { Account } from 'soapbox/schemas';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, ChatMessage, Group, Status } from 'soapbox/types/entities';
import type { ChatMessage, Group, Status } from 'soapbox/types/entities';
const REPORT_INIT = 'REPORT_INIT';
const REPORT_CANCEL = 'REPORT_CANCEL';

@ -1,4 +1,4 @@
import api from '../api';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
@ -83,7 +83,9 @@ const submitSearch = (filter?: SearchFilter) =>
dispatch(importFetchedStatuses(response.data.statuses));
}
dispatch(fetchSearchSuccess(response.data, value, type));
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchSearchSuccess(response.data, value, type, next ? next.uri : null));
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => {
dispatch(fetchSearchFail(error));
@ -95,11 +97,12 @@ const fetchSearchRequest = (value: string) => ({
value,
});
const fetchSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter) => ({
const fetchSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter, next: string | null) => ({
type: SEARCH_FETCH_SUCCESS,
results,
searchTerm,
searchType,
next,
});
const fetchSearchFail = (error: AxiosError) => ({
@ -125,17 +128,26 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
dispatch(expandSearchRequest(type));
const params: Record<string, any> = {
q: value,
type,
offset,
};
let url = getState().search.next as string;
let params: Record<string, any> = {};
if (accountId) params.account_id = accountId;
// if no URL was extracted from the Link header,
// fall back on querying with the offset
if (!url) {
url = '/api/v2/search';
params = {
q: value,
type,
offset,
};
if (accountId) params.account_id = accountId;
}
api(getState).get('/api/v2/search', {
api(getState).get(url, {
params,
}).then(({ data }) => {
}).then(response => {
const data = response.data;
if (data.accounts) {
dispatch(importFetchedAccounts(data.accounts));
}
@ -144,7 +156,9 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
dispatch(importFetchedStatuses(data.statuses));
}
dispatch(expandSearchSuccess(data, value, type));
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandSearchSuccess(data, value, type, next ? next.uri : null));
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => {
dispatch(expandSearchFail(error));
@ -156,11 +170,12 @@ const expandSearchRequest = (searchType: SearchFilter) => ({
searchType,
});
const expandSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter) => ({
const expandSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter, next: string | null) => ({
type: SEARCH_EXPAND_SUCCESS,
results,
searchTerm,
searchType,
next,
});
const expandSearchFail = (error: AxiosError) => ({

@ -10,9 +10,9 @@ import { isLoggedIn } from 'soapbox/utils/auth';
import type { AppDispatch, RootState } from 'soapbox/store';
const SETTING_CHANGE = 'SETTING_CHANGE';
const SETTING_SAVE = 'SETTING_SAVE';
const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
const SETTING_CHANGE = 'SETTING_CHANGE' as const;
const SETTING_SAVE = 'SETTING_SAVE' as const;
const SETTINGS_UPDATE = 'SETTINGS_UPDATE' as const;
const FE_NAME = 'soapbox_fe';
@ -181,25 +181,33 @@ const getSettings = createSelector([
.mergeDeep(settings);
});
interface SettingChangeAction {
type: typeof SETTING_CHANGE
path: string[]
value: any
}
const changeSettingImmediate = (path: string[], value: any, opts?: SettingOpts) =>
(dispatch: AppDispatch) => {
dispatch({
const action: SettingChangeAction = {
type: SETTING_CHANGE,
path,
value,
});
};
dispatch(action);
dispatch(saveSettingsImmediate(opts));
};
const changeSetting = (path: string[], value: any, opts?: SettingOpts) =>
(dispatch: AppDispatch) => {
dispatch({
const action: SettingChangeAction = {
type: SETTING_CHANGE,
path,
value,
});
};
dispatch(action);
return dispatch(saveSettings(opts));
};
@ -236,6 +244,10 @@ const getLocale = (state: RootState, fallback = 'en') => {
return Object.keys(messages).includes(localeWithVariant) ? localeWithVariant : Object.keys(messages).includes(locale) ? locale : fallback;
};
type SettingsAction =
| SettingChangeAction
| { type: typeof SETTING_SAVE }
export {
SETTING_CHANGE,
SETTING_SAVE,
@ -248,4 +260,5 @@ export {
saveSettingsImmediate,
saveSettings,
getLocale,
type SettingsAction,
};

@ -6,29 +6,30 @@ import { shouldFilter } from 'soapbox/utils/timelines';
import api, { getNextLink, getPrevLink } from '../api';
import { fetchGroupRelationships } from './groups';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status } from 'soapbox/types/entities';
const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
const TIMELINE_DELETE = 'TIMELINE_DELETE';
const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE';
const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE';
const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
const TIMELINE_UPDATE = 'TIMELINE_UPDATE' as const;
const TIMELINE_DELETE = 'TIMELINE_DELETE' as const;
const TIMELINE_CLEAR = 'TIMELINE_CLEAR' as const;
const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE' as const;
const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE' as const;
const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP' as const;
const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST' as const;
const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS' as const;
const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL' as const;
const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
const TIMELINE_CONNECT = 'TIMELINE_CONNECT' as const;
const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT' as const;
const TIMELINE_REPLACE = 'TIMELINE_REPLACE';
const TIMELINE_INSERT = 'TIMELINE_INSERT';
const TIMELINE_CLEAR_FEED_ACCOUNT_ID = 'TIMELINE_CLEAR_FEED_ACCOUNT_ID';
const TIMELINE_REPLACE = 'TIMELINE_REPLACE' as const;
const TIMELINE_INSERT = 'TIMELINE_INSERT' as const;
const TIMELINE_CLEAR_FEED_ACCOUNT_ID = 'TIMELINE_CLEAR_FEED_ACCOUNT_ID' as const;
const MAX_QUEUED_ITEMS = 40;
@ -39,7 +40,7 @@ const processTimelineUpdate = (timeline: string, status: APIEntity, accept: ((st
const hasPendingStatuses = !getState().pending_statuses.isEmpty();
const columnSettings = getSettings(getState()).get(timeline, ImmutableMap());
const shouldSkipQueue = shouldFilter(normalizeStatus(status) as Status, columnSettings);
const shouldSkipQueue = shouldFilter(normalizeStatus(status) as Status, columnSettings as any);
if (ownStatus && hasPendingStatuses) {
// WebSockets push statuses without the Idempotency-Key,
@ -110,19 +111,29 @@ const dequeueTimeline = (timelineId: string, expandFunc?: (lastStatusId: string)
}
};
interface TimelineDeleteAction {
type: typeof TIMELINE_DELETE
id: string
accountId: string
references: ImmutableMap<string, readonly [statusId: string, accountId: string]>
reblogOf: unknown
}
const deleteFromTimelines = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const accountId = getState().statuses.get(id)?.account;
const references = getState().statuses.filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
const accountId = getState().statuses.get(id)?.account?.id!;
const references = getState().statuses.filter(status => status.reblog === id).map(status => [status.id, status.account.id] as const);
const reblogOf = getState().statuses.getIn([id, 'reblog'], null);
dispatch({
const action: TimelineDeleteAction = {
type: TIMELINE_DELETE,
id,
accountId,
references,
reblogOf,
});
};
dispatch(action);
};
const clearTimeline = (timeline: string) =>
@ -177,6 +188,10 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
return api(getState).get(path, { params }).then(response => {
dispatch(importFetchedStatuses(response.data));
const statusesFromGroups = (response.data as Status[]).filter((status) => !!status.group);
dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id)));
dispatch(expandTimelineSuccess(
timelineId,
response.data,
@ -221,29 +236,29 @@ const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts =
return expandTimeline('home', endpoint, params, done);
};
const expandPublicTimeline = ({ maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
const expandPublicTimeline = ({ url, maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`public${onlyMedia ? ':media' : ''}`, url || '/api/v1/timelines/public', url ? {} : { max_id: maxId, only_media: !!onlyMedia }, done);
const expandRemoteTimeline = (instance: string, { maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, '/api/v1/timelines/public', { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done);
const expandRemoteTimeline = (instance: string, { url, maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, url || '/api/v1/timelines/public', url ? {} : { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done);
const expandCommunityTimeline = ({ maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
const expandCommunityTimeline = ({ url, maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`community${onlyMedia ? ':media' : ''}`, url || '/api/v1/timelines/public', url ? {} : { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
const expandDirectTimeline = ({ maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
const expandDirectTimeline = ({ url, maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline('direct', url || '/api/v1/timelines/direct', url ? {} : { max_id: maxId }, done);
const expandAccountTimeline = (accountId: string, { maxId, withReplies }: Record<string, any> = {}) =>
expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId, with_muted: true });
const expandAccountTimeline = (accountId: string, { url, maxId, withReplies }: Record<string, any> = {}) =>
expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, url || `/api/v1/accounts/${accountId}/statuses`, url ? {} : { exclude_replies: !withReplies, max_id: maxId, with_muted: true });
const expandAccountFeaturedTimeline = (accountId: string) =>
expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, with_muted: true });
const expandAccountMediaTimeline = (accountId: string | number, { maxId }: Record<string, any> = {}) =>
expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
const expandAccountMediaTimeline = (accountId: string | number, { url, maxId }: Record<string, any> = {}) =>
expandTimeline(`account:${accountId}:media`, url || `/api/v1/accounts/${accountId}/statuses`, url ? {} : { max_id: maxId, only_media: true, limit: 40, with_muted: true });
const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
const expandListTimeline = (id: string, { url, maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`list:${id}`, url || `/api/v1/timelines/list/${id}`, url ? {} : { max_id: maxId }, done);
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
@ -257,8 +272,8 @@ const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Reco
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
const expandHashtagTimeline = (hashtag: string, { url, maxId, tags }: Record<string, any> = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, url || `/api/v1/timelines/tag/${hashtag}`, url ? {} : {
max_id: maxId,
any: parseTags(tags, 'any'),
all: parseTags(tags, 'all'),
@ -322,6 +337,9 @@ const clearFeedAccountId = () => (dispatch: AppDispatch, _getState: () => RootSt
dispatch({ type: TIMELINE_CLEAR_FEED_ACCOUNT_ID });
};
// TODO: other actions
type TimelineAction = TimelineDeleteAction;
export {
TIMELINE_UPDATE,
TIMELINE_DELETE,
@ -368,4 +386,5 @@ export {
scrollTopTimeline,
insertSuggestionsIntoTimeline,
clearFeedAccountId,
type TimelineAction,
};

@ -1,25 +1,41 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useFeatures, useLoggedIn } from 'soapbox/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { type Account, accountSchema } from 'soapbox/schemas';
import { useRelationship } from './useRelationship';
import { useRelationships } from './useRelationships';
interface UseAccountOpts {
withRelationship?: boolean
}
function useAccount(id: string) {
function useAccount(accountId?: string, opts: UseAccountOpts = {}) {
const api = useApi();
const features = useFeatures();
const { me } = useLoggedIn();
const { withRelationship } = opts;
const { entity: account, ...result } = useEntity<Account>(
[Entities.ACCOUNTS, id],
() => api.get(`/api/v1/accounts/${id}`),
{ schema: accountSchema },
[Entities.ACCOUNTS, accountId!],
() => api.get(`/api/v1/accounts/${accountId}`),
{ schema: accountSchema, enabled: !!accountId },
);
const { relationships, isLoading } = useRelationships([account?.id as string]);
const {
relationship,
isLoading: isRelationshipLoading,
} = useRelationship(accountId, { enabled: withRelationship });
const isBlocked = account?.relationship?.blocked_by === true;
const isUnavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible);
return {
...result,
isLoading: result.isLoading || isLoading,
account: account ? { ...account, relationship: relationships[0] || null } : undefined,
isLoading: result.isLoading,
isRelationshipLoading,
isUnavailable,
account: account ? { ...account, relationship } : undefined,
};
}

@ -0,0 +1,70 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { Account, accountSchema } from 'soapbox/schemas';
import { useRelationships } from './useRelationships';
import type { EntityFn } from 'soapbox/entity-store/hooks/types';
interface useAccountListOpts {
enabled?: boolean
}
function useAccountList(listKey: string[], entityFn: EntityFn<void>, opts: useAccountListOpts = {}) {
const { entities, ...rest } = useEntities(
[Entities.ACCOUNTS, ...listKey],
entityFn,
{ schema: accountSchema, enabled: opts.enabled },
);
const { relationships } = useRelationships(
listKey,
entities.map(({ id }) => id),
);
const accounts: Account[] = entities.map((account) => ({
...account,
relationship: relationships[account.id],
}));
return { accounts, ...rest };
}
function useBlocks() {
const api = useApi();
return useAccountList(['blocks'], () => api.get('/api/v1/blocks'));
}
function useMutes() {
const api = useApi();
return useAccountList(['mutes'], () => api.get('/api/v1/mutes'));
}
function useFollowing(accountId: string | undefined) {
const api = useApi();
return useAccountList(
[accountId!, 'following'],
() => api.get(`/api/v1/accounts/${accountId}/following`),
{ enabled: !!accountId },
);
}
function useFollowers(accountId: string | undefined) {
const api = useApi();
return useAccountList(
[accountId!, 'followers'],
() => api.get(`/api/v1/accounts/${accountId}/followers`),
{ enabled: !!accountId },
);
}
export {
useAccountList,
useBlocks,
useMutes,
useFollowing,
useFollowers,
};

@ -0,0 +1,43 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityLookup } from 'soapbox/entity-store/hooks';
import { useFeatures, useLoggedIn } from 'soapbox/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { type Account, accountSchema } from 'soapbox/schemas';
import { useRelationship } from './useRelationship';
interface UseAccountLookupOpts {
withRelationship?: boolean
}
function useAccountLookup(acct: string | undefined, opts: UseAccountLookupOpts = {}) {
const api = useApi();
const features = useFeatures();
const { me } = useLoggedIn();
const { withRelationship } = opts;
const { entity: account, ...result } = useEntityLookup<Account>(
Entities.ACCOUNTS,
(account) => account.acct === acct,
() => api.get(`/api/v1/accounts/lookup?acct=${acct}`),
{ schema: accountSchema, enabled: !!acct },
);
const {
relationship,
isLoading: isRelationshipLoading,
} = useRelationship(account?.id, { enabled: withRelationship });
const isBlocked = account?.relationship?.blocked_by === true;
const isUnavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible);
return {
...result,
isLoading: result.isLoading,
isRelationshipLoading,
isUnavailable,
account: account ? { ...account, relationship } : undefined,
};
}
export { useAccountLookup };

@ -0,0 +1,88 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { useTransaction } from 'soapbox/entity-store/hooks';
import { useAppDispatch, useLoggedIn } from 'soapbox/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { relationshipSchema } from 'soapbox/schemas';
interface FollowOpts {
reblogs?: boolean
notify?: boolean
languages?: string[]
}
function useFollow() {
const api = useApi();
const dispatch = useAppDispatch();
const { isLoggedIn } = useLoggedIn();
const { transaction } = useTransaction();
function followEffect(accountId: string) {
transaction({
Accounts: {
[accountId]: (account) => ({
...account,
followers_count: account.followers_count + 1,
}),
},
Relationships: {
[accountId]: (relationship) => ({
...relationship,
following: true,
}),
},
});
}
function unfollowEffect(accountId: string) {
transaction({
Accounts: {
[accountId]: (account) => ({
...account,
followers_count: Math.max(0, account.followers_count - 1),
}),
},
Relationships: {
[accountId]: (relationship) => ({
...relationship,
following: false,
}),
},
});
}
async function follow(accountId: string, options: FollowOpts = {}) {
if (!isLoggedIn) return;
followEffect(accountId);
try {
const response = await api.post(`/api/v1/accounts/${accountId}/follow`, options);
const result = relationshipSchema.safeParse(response.data);
if (result.success) {
dispatch(importEntities([result.data], Entities.RELATIONSHIPS));
}
} catch (e) {
unfollowEffect(accountId);
}
}
async function unfollow(accountId: string) {
if (!isLoggedIn) return;
unfollowEffect(accountId);
try {
await api.post(`/api/v1/accounts/${accountId}/unfollow`);
} catch (e) {
followEffect(accountId);
}
}
return {
follow,
unfollow,
followEffect,
unfollowEffect,
};
}
export { useFollow };

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { type PatronUser, patronUserSchema } from 'soapbox/schemas';
function usePatronUser(url?: string) {
const api = useApi();
const { entity: patronUser, ...result } = useEntity<PatronUser>(
[Entities.PATRON_USERS, url || ''],
() => api.get(`/api/patron/v1/accounts/${encodeURIComponent(url!)}`),
{ schema: patronUserSchema, enabled: !!url },
);
return { patronUser, ...result };
}
export { usePatronUser };

@ -0,0 +1,28 @@
import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { type Relationship, relationshipSchema } from 'soapbox/schemas';
interface UseRelationshipOpts {
enabled?: boolean
}
function useRelationship(accountId: string | undefined, opts: UseRelationshipOpts = {}) {
const api = useApi();
const { enabled = false } = opts;
const { entity: relationship, ...result } = useEntity<Relationship>(
[Entities.RELATIONSHIPS, accountId!],
() => api.get(`/api/v1/accounts/relationships?id[]=${accountId}`),
{
enabled: enabled && !!accountId,
schema: z.array(relationshipSchema).nonempty().transform(arr => arr[0]),
},
);
return { relationship, ...result };
}
export { useRelationship };

@ -1,21 +1,26 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useBatchedEntities } from 'soapbox/entity-store/hooks/useBatchedEntities';
import { useLoggedIn } from 'soapbox/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { type Relationship, relationshipSchema } from 'soapbox/schemas';
function useRelationships(ids: string[]) {
function useRelationships(listKey: string[], ids: string[]) {
const api = useApi();
const { isLoggedIn } = useLoggedIn();
const { entities: relationships, ...result } = useEntities<Relationship>(
[Entities.RELATIONSHIPS],
() => api.get(`/api/v1/accounts/relationships?${ids.map(id => `id[]=${id}`).join('&')}`),
{ schema: relationshipSchema, enabled: ids.filter(Boolean).length > 0 },
function fetchRelationships(ids: string[]) {
const q = ids.map((id) => `id[]=${id}`).join('&');
return api.get(`/api/v1/accounts/relationships?${q}`);
}
const { entityMap: relationships, ...result } = useBatchedEntities<Relationship>(
[Entities.RELATIONSHIPS, ...listKey],
ids,
fetchRelationships,
{ schema: relationshipSchema, enabled: isLoggedIn },
);
return {
...result,
relationships,
};
return { relationships, ...result };
}
export { useRelationships };

@ -0,0 +1,64 @@
import { __stub } from 'soapbox/api';
import { Entities } from 'soapbox/entity-store/entities';
import { buildAccount, buildGroup } from 'soapbox/jest/factory';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeInstance } from 'soapbox/normalizers';
import { usePendingGroups } from '../usePendingGroups';
const id = '1';
const group = buildGroup({ id, display_name: 'soapbox' });
const store = {
instance: normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
}),
me: '1',
entities: {
[Entities.ACCOUNTS]: {
store: {
[id]: buildAccount({
id,
acct: 'tiger',
display_name: 'Tiger',
avatar: 'test.jpg',
verified: true,
}),
},
lists: {},
},
},
};
describe('usePendingGroups hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').reply(200, [group]);
});
});
it('is successful', async () => {
const { result } = renderHook(usePendingGroups, 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(usePendingGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(0);
});
});
});

@ -1,12 +1,12 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group, GroupMember } from 'soapbox/schemas';
import type { Account, Group, GroupMember } from 'soapbox/schemas';
function useBlockGroupMember(group: Group, groupMember: GroupMember) {
function useBlockGroupMember(group: Group, account: Account) {
const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
{ post: `/api/v1/groups/${group.id}/blocks` },
[Entities.GROUP_MEMBERSHIPS, account.id],
{ post: `/api/v1/groups/${group?.id}/blocks` },
);
return createEntity;

@ -6,7 +6,7 @@ import type { Group } from 'soapbox/schemas';
function useCancelMembershipRequest(group: Group) {
const api = useApi();
const me = useOwnAccount();
const { account: me } = useOwnAccount();
const { createEntity, isSubmitting } = useCreateEntity(
[Entities.GROUP_RELATIONSHIPS],

@ -11,9 +11,13 @@ function useGroup(groupId: string, refetch = true) {
const { entity: group, ...result } = useEntity<Group>(
[Entities.GROUPS, groupId],
() => api.get(`/api/v1/groups/${groupId}`),
{ schema: groupSchema, refetch },
{
schema: groupSchema,
refetch,
enabled: !!groupId,
},
);
const { entity: relationship } = useGroupRelationship(groupId);
const { groupRelationship: relationship } = useGroupRelationship(groupId);
return {
...result,

@ -15,7 +15,7 @@ function useGroupLookup(slug: string) {
{ schema: groupSchema, enabled: !!slug },
);
const { entity: relationship } = useGroupRelationship(group?.id);
const { groupRelationship: relationship } = useGroupRelationship(group?.id);
return {
...result,

@ -12,7 +12,7 @@ function useGroupMembershipRequests(groupId: string) {
const api = useApi();
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
const { entity: relationship } = useGroupRelationship(groupId);
const { groupRelationship: relationship } = useGroupRelationship(groupId);
const { entities, invalidate, fetchEntities, ...rest } = useEntities(
path,

@ -0,0 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useFeatures } from 'soapbox/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { groupSchema } from 'soapbox/schemas';
import type { Group } from 'soapbox/schemas';
function useGroupMutes() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUP_MUTES],
() => api.get('/api/v1/groups/mutes'),
{ schema: groupSchema, enabled: features.groupsMuting },
);
return {
...result,
mutes: entities,
};
}
export { useGroupMutes };

@ -1,33 +1,24 @@
import { useEffect } from 'react';
import { z } from 'zod';
import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi, useAppDispatch } from 'soapbox/hooks';
import { useApi } from 'soapbox/hooks';
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
function useGroupRelationship(groupId: string | undefined) {
const api = useApi();
const dispatch = useAppDispatch();
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, groupId as string],
[Entities.GROUP_RELATIONSHIPS, groupId!],
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
{
enabled: !!groupId,
schema: z.array(groupRelationshipSchema).transform(arr => arr[0]),
schema: z.array(groupRelationshipSchema).nonempty().transform(arr => arr[0]),
},
);
useEffect(() => {
if (groupRelationship?.id) {
dispatch(fetchGroupRelationshipsSuccess([groupRelationship]));
}
}, [groupRelationship?.id]);
return {
entity: groupRelationship,
groupRelationship,
...result,
};
}

@ -1,27 +1,25 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { useBatchedEntities } from 'soapbox/entity-store/hooks/useBatchedEntities';
import { useApi, useLoggedIn } from 'soapbox/hooks';
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
function useGroupRelationships(groupIds: string[]) {
function useGroupRelationships(listKey: string[], ids: string[]) {
const api = useApi();
const q = groupIds.map(id => `id[]=${id}`).join('&');
const { isLoggedIn } = useLoggedIn();
const { entities, ...result } = useEntities<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
() => api.get(`/api/v1/groups/relationships?${q}`),
{ schema: groupRelationshipSchema, enabled: groupIds.length > 0 },
);
function fetchGroupRelationships(ids: string[]) {
const q = ids.map((id) => `id[]=${id}`).join('&');
return api.get(`/api/v1/groups/relationships?${q}`);
}
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
map[relationship.id] = relationship;
return map;
}, {});
const { entityMap: relationships, ...result } = useBatchedEntities<GroupRelationship>(
[Entities.RELATIONSHIPS, ...listKey],
ids,
fetchGroupRelationships,
{ schema: groupRelationshipSchema, enabled: isLoggedIn },
);
return {
...result,
relationships,
};
return { relationships, ...result };
}
export { useGroupRelationships };

@ -21,7 +21,10 @@ function useGroupSearch(search: string) {
{ enabled: features.groupsDiscovery && !!search, schema: groupSchema },
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const { relationships } = useGroupRelationships(
['discover', 'search', search],
entities.map(entity => entity.id),
);
const groups = entities.map((group) => ({
...group,

@ -15,7 +15,10 @@ function useGroups(q: string = '') {
() => api.get('/api/v1/groups', { params: { q } }),
{ enabled: features.groups, schema: groupSchema },
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const { relationships } = useGroupRelationships(
['search', q],
entities.map(entity => entity.id),
);
const groups = entities.map((group) => ({
...group,

@ -19,7 +19,10 @@ function useGroupsFromTag(tagId: string) {
enabled: features.groupsDiscovery,
},
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const { relationships } = useGroupRelationships(
['tags', tagId],
entities.map(entity => entity.id),
);
const groups = entities.map((group) => ({
...group,

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { type Group, groupRelationshipSchema } from 'soapbox/schemas';
function useMuteGroup(group?: Group) {
const { createEntity, isSubmitting } = useEntityActions(
[Entities.GROUP_RELATIONSHIPS, group?.id as string],
{ post: `/api/v1/groups/${group?.id}/mute` },
{ schema: groupRelationshipSchema },
);
return {
mutate: createEntity,
isSubmitting,
};
}
export { useMuteGroup };

@ -0,0 +1,30 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { Group, groupSchema } from 'soapbox/schemas';
function usePendingGroups() {
const api = useApi();
const { account } = useOwnAccount();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, account?.id!, 'pending'],
() => api.get('/api/v1/groups', {
params: {
pending: true,
},
}),
{
schema: groupSchema,
enabled: !!account && features.groupsPending,
},
);
return {
...result,
groups: entities,
};
}
export { usePendingGroups };

@ -20,7 +20,7 @@ function usePopularGroups() {
},
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const { relationships } = useGroupRelationships(['popular'], entities.map(entity => entity.id));
const groups = entities.map((group) => ({
...group,

@ -8,7 +8,7 @@ import type { Group, GroupMember } from 'soapbox/schemas';
function usePromoteGroupMember(group: Group, groupMember: GroupMember) {
const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
[Entities.GROUP_MEMBERSHIPS, groupMember.account.id],
{ post: `/api/v1/groups/${group.id}/promote` },
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]) },
);

@ -18,7 +18,7 @@ function useSuggestedGroups() {
},
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
const { relationships } = useGroupRelationships(['suggested'], entities.map(entity => entity.id));
const groups = entities.map((group) => ({
...group,

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { type Group, groupRelationshipSchema } from 'soapbox/schemas';
function useUnmuteGroup(group?: Group) {
const { createEntity, isSubmitting } = useEntityActions(
[Entities.GROUP_RELATIONSHIPS, group?.id as string],
{ post: `/api/v1/groups/${group?.id}/unmute` },
{ schema: groupRelationshipSchema },
);
return {
mutate: createEntity,
isSubmitting,
};
}
export { useUnmuteGroup };

@ -1,12 +1,18 @@
/**
* Accounts
*/
// Accounts
export { useAccount } from './accounts/useAccount';
export { useAccountLookup } from './accounts/useAccountLookup';
export {
useBlocks,
useMutes,
useFollowers,
useFollowing,
} from './accounts/useAccountList';
export { useFollow } from './accounts/useFollow';
export { useRelationships } from './accounts/useRelationships';
export { usePatronUser } from './accounts/usePatronUser';
/**
* Groups
*/
// Groups
export { useBlockGroupMember } from './groups/useBlockGroupMember';
export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest';
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
@ -17,6 +23,7 @@ export { useGroupLookup } from './groups/useGroupLookup';
export { useGroupMedia } from './groups/useGroupMedia';
export { useGroupMembers } from './groups/useGroupMembers';
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
export { useGroupMutes } from './groups/useGroupMutes';
export { useGroupRelationship } from './groups/useGroupRelationship';
export { useGroupRelationships } from './groups/useGroupRelationships';
export { useGroupSearch } from './groups/useGroupSearch';
@ -26,15 +33,13 @@ export { useGroupValidation } from './groups/useGroupValidation';
export { useGroups } from './groups/useGroups';
export { useGroupsFromTag } from './groups/useGroupsFromTag';
export { useJoinGroup } from './groups/useJoinGroup';
export { useMuteGroup } from './groups/useMuteGroup';
export { useLeaveGroup } from './groups/useLeaveGroup';
export { usePendingGroups } from './groups/usePendingGroups';
export { usePopularGroups } from './groups/usePopularGroups';
export { usePopularTags } from './groups/usePopularTags';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
export { useSuggestedGroups } from './groups/useSuggestedGroups';
export { useUnmuteGroup } from './groups/useUnmuteGroup';
export { useUpdateGroup } from './groups/useUpdateGroup';
export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
/**
* Relationships
*/
export { useRelationships } from './accounts/useRelationships';

@ -1,25 +1,23 @@
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { buildAccount } from 'soapbox/jest/factory';
import { render, screen } from '../../jest/test-helpers';
import { normalizeAccount } from '../../normalizers';
import Account from '../account';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
describe('<Account />', () => {
it('renders account name and username', () => {
const account = normalizeAccount({
const account = buildAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
}) as ReducerAccount;
});
const store = {
accounts: ImmutableMap({
accounts: {
'1': account,
}),
},
};
render(<Account account={account} />, undefined, store);
@ -29,18 +27,18 @@ describe('<Account />', () => {
describe('verification badge', () => {
it('renders verification badge', () => {
const account = normalizeAccount({
const account = buildAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
verified: true,
}) as ReducerAccount;
});
const store = {
accounts: ImmutableMap({
accounts: {
'1': account,
}),
},
};
render(<Account account={account} />, undefined, store);
@ -48,18 +46,18 @@ describe('<Account />', () => {
});
it('does not render verification badge', () => {
const account = normalizeAccount({
const account = buildAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
verified: false,
}) as ReducerAccount;
});
const store = {
accounts: ImmutableMap({
accounts: {
'1': account,
}),
},
};
render(<Account account={account} />, undefined, store);

@ -1,15 +1,13 @@
import React from 'react';
import { normalizeAccount } from 'soapbox/normalizers';
import { buildAccount } from 'soapbox/jest/factory';
import { render, screen } from '../../jest/test-helpers';
import DisplayName from '../display-name';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {
const account = normalizeAccount({ acct: 'bar@baz' }) as ReducerAccount;
const account = buildAccount({ acct: 'bar@baz' });
render(<DisplayName account={account} />);
expect(screen.getByTestId('display-name')).toHaveTextContent('bar@baz');

@ -1,12 +1,14 @@
import React from 'react';
import { render, screen, rootState } from '../../jest/test-helpers';
import { normalizeStatus, normalizeAccount } from '../../normalizers';
import { buildAccount } from 'soapbox/jest/factory';
import { render, screen, rootState } from 'soapbox/jest/test-helpers';
import { normalizeStatus } from 'soapbox/normalizers';
import Status from '../status';
import type { ReducerStatus } from 'soapbox/reducers/statuses';
const account = normalizeAccount({
const account = buildAccount({
id: '1',
acct: 'alex',
});
@ -34,7 +36,7 @@ describe('<Status />', () => {
});
it('is not rendered if status is under review', () => {
const inReviewStatus = normalizeStatus({ ...status, visibility: 'self' });
const inReviewStatus = status.set('visibility', 'self');
render(<Status status={inReviewStatus as ReducerStatus} />, undefined, state);
expect(screen.queryAllByTestId('status-action-bar')).toHaveLength(0);
});

@ -15,10 +15,9 @@ import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
import type { Account as AccountSchema } from 'soapbox/schemas';
import type { Account as AccountEntity } from 'soapbox/types/entities';
interface IInstanceFavicon {
account: AccountEntity | AccountSchema
account: AccountSchema
disabled?: boolean
}
@ -42,13 +41,17 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
}
};
if (!account.pleroma?.favicon) {
return null;
}
return (
<button
className='h-4 w-4 flex-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
onClick={handleClick}
disabled={disabled}
>
<img src={account.favicon} alt='' title={account.domain} className='max-h-full w-full' />
<img src={account.pleroma.favicon} alt='' title={account.domain} className='max-h-full w-full' />
</button>
);
};
@ -68,7 +71,7 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
};
export interface IAccount {
account: AccountEntity | AccountSchema
account: AccountSchema
action?: React.ReactElement
actionAlignment?: 'center' | 'top'
actionIcon?: string
@ -230,7 +233,7 @@ const Account = ({
<HStack alignItems='center' space={1}>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && (
{account.pleroma?.favicon && (
<InstanceFavicon account={account} disabled={!withLinkToProfile} />
)}

@ -8,10 +8,10 @@ import { getAcct } from '../utils/accounts';
import { HStack, Text } from './ui';
import VerificationBadge from './verification-badge';
import type { Account } from 'soapbox/types/entities';
import type { Account } from 'soapbox/schemas';
interface IDisplayName {
account: Account
account: Pick<Account, 'id' | 'acct' | 'fqn' | 'verified' | 'display_name_html'>
withSuffix?: boolean
children?: React.ReactNode
}
@ -37,7 +37,7 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
return (
<span className='display-name' data-testid='display-name'>
<HoverRefWrapper accountId={account.get('id')} inline>
<HoverRefWrapper accountId={account.id} inline>
{displayName}
</HoverRefWrapper>
{withSuffix && suffix}

@ -9,32 +9,33 @@ import {
closeProfileHoverCard,
updateProfileHoverCard,
} from 'soapbox/actions/profile-hover-card';
import { useAccount, usePatronUser } from 'soapbox/api/hooks';
import Badge from 'soapbox/components/badge';
import ActionButton from 'soapbox/features/ui/components/action-button';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { UserPanel } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
import { isLocal } from 'soapbox/utils/accounts';
import { showProfileHoverCard } from './hover-ref-wrapper';
import { Card, CardBody, HStack, Icon, Stack, Text } from './ui';
import type { Account, PatronUser } from 'soapbox/schemas';
import type { AppDispatch } from 'soapbox/store';
import type { Account } from 'soapbox/types/entities';
const getAccount = makeGetAccount();
const getBadges = (account: Account): JSX.Element[] => {
const getBadges = (
account?: Pick<Account, 'admin' | 'moderator'>,
patronUser?: Pick<PatronUser, 'is_patron'>,
): JSX.Element[] => {
const badges = [];
if (account.admin) {
if (account?.admin) {
badges.push(<Badge key='admin' slug='admin' title='Admin' />);
} else if (account.moderator) {
} else if (account?.moderator) {
badges.push(<Badge key='moderator' slug='moderator' title='Moderator' />);
}
if (account.getIn(['patron', 'is_patron'])) {
if (patronUser?.is_patron) {
badges.push(<Badge key='patron' slug='patron' title='Patron' />);
}
@ -67,9 +68,10 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
const me = useAppSelector(state => state.me);
const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.accountId || undefined);
const account = useAppSelector(state => accountId && getAccount(state, accountId));
const { account } = useAccount(accountId, { withRelationship: true });
const { patronUser } = usePatronUser(account?.url);
const targetRef = useAppSelector(state => state.profile_hover_card.ref?.current);
const badges = account ? getBadges(account) : [];
const badges = getBadges(account, patronUser);
useEffect(() => {
if (accountId) dispatch(fetchRelationships([accountId]));
@ -112,7 +114,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
<BundleContainer fetchComponent={UserPanel}>
{Component => (
<Component
accountId={account.get('id')}
accountId={account.id}
action={<ActionButton account={account} small />}
badges={badges}
/>

@ -1,17 +1,18 @@
/* eslint-disable jsx-a11y/interactive-supports-focus */
import clsx from 'clsx';
import React from 'react';
import React, { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link, NavLink } from 'react-router-dom';
import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth';
import { getSettings } from 'soapbox/actions/settings';
import { closeSidebar } from 'soapbox/actions/sidebar';
import { useAccount } from 'soapbox/api/hooks';
import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui';
import ProfileStats from 'soapbox/features/ui/components/profile-stats';
import { useAppDispatch, useAppSelector, useGroupsPath, useFeatures } from 'soapbox/hooks';
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
import { makeGetOtherAccounts } from 'soapbox/selectors';
import { Divider, HStack, Icon, IconButton, Text } from './ui';
@ -27,6 +28,7 @@ const messages = defineMessages({
domainBlocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
followedTags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
soapboxConfig: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' },
@ -76,16 +78,14 @@ const SidebarLink: React.FC<ISidebarLink> = ({ href, to, icon, text, onClick })
);
};
const getOtherAccounts = makeGetOtherAccounts();
const SidebarMenu: React.FC = (): JSX.Element | null => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getOtherAccounts = useCallback(makeGetOtherAccounts(), []);
const features = useFeatures();
const getAccount = makeGetAccount();
const me = useAppSelector((state) => state.me);
const account = useAppSelector((state) => me ? getAccount(state, me) : null);
const { account } = useAccount(me || undefined);
const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const settings = useAppSelector((state) => getSettings(state));
@ -306,6 +306,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
)}
{features.followedHashtagsList && (
<SidebarLink
to='/followed_tags'
icon={require('@tabler/icons/hash.svg')}
text={intl.formatMessage(messages.followedTags)}
onClick={onClose}
/>
)}
{account.admin && (
<SidebarLink
to='/soapbox/config'

@ -24,7 +24,7 @@ const SidebarNavigation = () => {
const features = useFeatures();
const settings = useSettings();
const account = useOwnAccount();
const { account } = useOwnAccount();
const groupsPath = useGroupsPath();
const notificationCount = useAppSelector((state) => state.notifications.unread);

@ -1,7 +1,7 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { useHistory, useRouteMatch } from 'react-router-dom';
import { blockAccount } from 'soapbox/actions/accounts';
import { launchChat } from 'soapbox/actions/chats';
@ -14,6 +14,7 @@ import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import { deleteFromTimelines } from 'soapbox/actions/timelines';
import { useBlockGroupMember, useGroup, useGroupRelationship, useMuteGroup, useUnmuteGroup } from 'soapbox/api/hooks';
import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusActionButton from 'soapbox/components/status-action-button';
@ -35,6 +36,7 @@ const messages = defineMessages({
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
blocked: { id: 'group.group_mod_block.success', defaultMessage: '@{name} is banned' },
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
@ -56,6 +58,9 @@ const messages = defineMessages({
embed: { id: 'status.embed', defaultMessage: 'Embed' },
external: { id: 'status.external', defaultMessage: 'View post on {domain}' },
favourite: { id: 'status.favourite', defaultMessage: 'Like' },
groupBlockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' },
groupBlockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' },
groupBlockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' },
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' },
group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' },
@ -64,12 +69,16 @@ const messages = defineMessages({
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
muteConfirm: { id: 'confirmations.mute_group.confirm', defaultMessage: 'Mute' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
muteGroup: { id: 'group.mute.long_label', defaultMessage: 'Mute Group' },
muteHeading: { id: 'confirmations.mute_group.heading', defaultMessage: 'Mute Group' },
muteMessage: { id: 'confirmations.mute_group.message', defaultMessage: 'You are about to mute the group. Do you want to continue?' },
muteSuccess: { id: 'group.mute.success', defaultMessage: 'Muted the group' },
open: { id: 'status.open', defaultMessage: 'Expand this post' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
pinToGroup: { id: 'status.pin_to_group', defaultMessage: 'Pin to Group' },
pinToGroupSuccess: { id: 'status.pin_to_group.success', defaultMessage: 'Pinned to Group!' },
unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' },
quotePost: { id: 'status.quote', defaultMessage: 'Quote post' },
reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' },
reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' },
@ -92,7 +101,10 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' },
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
unmuteGroup: { id: 'group.unmute.long_label', defaultMessage: 'Unmute Group' },
unmuteSuccess: { id: 'group.unmute.success', defaultMessage: 'Unmuted the group' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' },
});
interface IStatusActionBar {
@ -113,17 +125,24 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug');
const { group } = useGroup((status.group as Group)?.id as string);
const muteGroup = useMuteGroup(group as Group);
const unmuteGroup = useUnmuteGroup(group as Group);
const isMutingGroup = !!group?.relationship?.muting;
const deleteGroupStatus = useDeleteGroupStatus(group as Group, status.id);
const blockGroupMember = useBlockGroupMember(group as Group, status?.account as any);
const me = useAppSelector(state => state.me);
const groupRelationship = useAppSelector(state => status.group ? state.group_relationships.get((status.group as Group).id) : null);
const { groupRelationship } = useGroupRelationship(status.group?.id);
const features = useFeatures();
const settings = useSettings();
const soapboxConfig = useSoapboxConfig();
const deleteGroupStatus = useDeleteGroupStatus(status?.group as Group, status.id);
const { allowedEmoji } = soapboxConfig;
const account = useOwnAccount();
const { account } = useOwnAccount();
const isStaff = account ? account.staff : false;
const isAdmin = account ? account.admin : false;
@ -264,8 +283,29 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
dispatch(initMuteModal(status.account as Account));
};
const handleMuteGroupClick: React.EventHandler<React.MouseEvent> = () =>
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.muteHeading),
message: intl.formatMessage(messages.muteMessage),
confirm: intl.formatMessage(messages.muteConfirm),
confirmationTheme: 'primary',
onConfirm: () => muteGroup.mutate(undefined, {
onSuccess() {
toast.success(intl.formatMessage(messages.muteSuccess));
},
}),
}));
const handleUnmuteGroupClick: React.EventHandler<React.MouseEvent> = () => {
unmuteGroup.mutate(undefined, {
onSuccess() {
toast.success(intl.formatMessage(messages.unmuteSuccess));
},
});
};
const handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => {
const account = status.get('account') as Account;
const account = status.account as Account;
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/ban.svg'),
@ -282,12 +322,12 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleOpen: React.EventHandler<React.MouseEvent> = (e) => {
history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}`);
history.push(`/@${status.account.acct}/posts/${status.id}`);
};
const handleEmbed = () => {
dispatch(openModal('EMBED', {
url: status.get('url'),
url: status.url,
onError: (error: any) => toast.showAlertForError(error),
}));
};
@ -336,11 +376,26 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}));
};
const handleBlockFromGroup = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.groupBlockFromGroupHeading),
message: intl.formatMessage(messages.groupBlockFromGroupMessage, { name: (status.account as any).username }),
confirm: intl.formatMessage(messages.groupBlockConfirm),
onConfirm: () => {
blockGroupMember({ account_ids: [(status.account as any).id] }, {
onSuccess() {
toast.success(intl.formatMessage(messages.blocked, { name: account?.acct }));
},
});
},
}));
};
const _makeMenu = (publicStatus: boolean) => {
const mutingConversation = status.muted;
const ownAccount = status.getIn(['account', 'id']) === me;
const username = String(status.getIn(['account', 'username']));
const account = status.account as Account;
const ownAccount = status.account.id === me;
const username = status.account.username;
const account = status.account;
const domain = account.fqn.split('@')[1];
const menu: Menu = [];
@ -456,7 +511,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
icon: require('@tabler/icons/at.svg'),
});
if (status.getIn(['account', 'pleroma', 'accepts_chat_messages']) === true) {
if (status.account.pleroma?.accepts_chat_messages === true) {
menu.push({
text: intl.formatMessage(messages.chat, { name: username }),
action: handleChatClick,
@ -471,6 +526,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}
menu.push(null);
if (features.groupsMuting && status.group) {
menu.push({
text: isMutingGroup ? intl.formatMessage(messages.unmuteGroup) : intl.formatMessage(messages.muteGroup),
icon: require('@tabler/icons/volume-3.svg'),
action: isMutingGroup ? handleUnmuteGroupClick : handleMuteGroupClick,
});
menu.push(null);
}
menu.push({
text: intl.formatMessage(messages.mute, { name: username }),
action: handleMuteClick,
@ -494,10 +558,24 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER;
const isGroupAdmin = groupRelationship?.role === GroupRoles.ADMIN;
const isStatusFromOwner = group.owner.id === account.id;
const canBanUser = match?.isExact && (isGroupOwner || isGroupAdmin) && !isStatusFromOwner && !ownAccount;
const canDeleteStatus = !ownAccount && (isGroupOwner || (isGroupAdmin && !isStatusFromOwner));
if (canDeleteStatus) {
if (canBanUser || canDeleteStatus) {
menu.push(null);
}
if (canBanUser) {
menu.push({
text: 'Ban from Group',
action: handleBlockFromGroup,
icon: require('@tabler/icons/ban.svg'),
destructive: true,
});
}
if (canDeleteStatus) {
menu.push({
text: intl.formatMessage(messages.groupModDelete),
action: handleDeleteFromGroup,

@ -178,8 +178,15 @@ const StatusList: React.FC<IStatusList> = ({
));
};
const renderFeedSuggestions = (): React.ReactNode => {
return <FeedSuggestions key='suggestions' />;
const renderFeedSuggestions = (statusId: string): React.ReactNode => {
return (
<FeedSuggestions
key='suggestions'
statusId={statusId}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
);
};
const renderStatuses = (): React.ReactNode[] => {
@ -201,7 +208,7 @@ const StatusList: React.FC<IStatusList> = ({
}
} else if (statusId.startsWith('末suggestions-')) {
if (soapboxConfig.feedInjection) {
acc.push(renderFeedSuggestions());
acc.push(renderFeedSuggestions(statusId));
}
} else if (statusId.startsWith('末pending-')) {
acc.push(renderPendingStatus(statusId));

@ -15,7 +15,7 @@ interface IStatusReactionWrapper {
/** Provides emoji reaction functionality to the underlying button component */
const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useAppDispatch();
const ownAccount = useOwnAccount();
const { account: ownAccount } = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId));
const soapboxConfig = useSoapboxConfig();

@ -26,7 +26,6 @@ import { Card, Icon, Stack, Text } from './ui';
import type {
Account as AccountEntity,
Group as GroupEntity,
Status as StatusEntity,
} from 'soapbox/types/entities';
@ -90,8 +89,8 @@ const Status: React.FC<IStatus> = (props) => {
const actualStatus = getActualStatus(status);
const isReblog = status.reblog && typeof status.reblog === 'object';
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
const group = actualStatus.group as GroupEntity | null;
const statusUrl = `/@${actualStatus.account.acct}/posts/${actualStatus.id}`;
const group = actualStatus.group;
const filtered = (status.filtered.size || actualStatus.filtered.size) > 0;
@ -177,7 +176,7 @@ const Status: React.FC<IStatus> = (props) => {
};
const handleHotkeyOpenProfile = (): void => {
history.push(`/@${actualStatus.getIn(['account', 'acct'])}`);
history.push(`/@${actualStatus.account.acct}`);
};
const handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
@ -224,25 +223,25 @@ const Status: React.FC<IStatus> = (props) => {
values={{
name: (
<Link
to={`/@${status.getIn(['account', 'acct'])}`}
to={`/@${status.account.acct}`}
className='hover:underline'
>
<bdi className='truncate'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: String(status.getIn(['account', 'display_name_html'])),
__html: status.account.display_name_html,
}}
/>
</bdi>
</Link>
),
group: (
<Link to={`/group/${(status.group as GroupEntity).slug}`} className='hover:underline'>
<Link to={`/group/${group.slug}`} className='hover:underline'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: (status.group as GroupEntity).display_name_html,
__html: group.display_name_html,
}}
/>
</Link>
@ -263,12 +262,12 @@ const Status: React.FC<IStatus> = (props) => {
defaultMessage='{name} reposted'
values={{
name: (
<Link to={`/@${status.getIn(['account', 'acct'])}`} className='hover:underline'>
<Link to={`/@${status.account.acct}`} className='hover:underline'>
<bdi className='truncate'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: String(status.getIn(['account', 'display_name_html'])),
__html: status.account.display_name_html,
}}
/>
</bdi>
@ -322,7 +321,7 @@ const Status: React.FC<IStatus> = (props) => {
return (
<div ref={node}>
<>
{actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])}
{actualStatus.account.display_name || actualStatus.account.username}
{actualStatus.content}
</>
</div>
@ -354,7 +353,7 @@ const Status: React.FC<IStatus> = (props) => {
if (status.reblog && typeof status.reblog === 'object') {
rebloggedByText = intl.formatMessage(
messages.reblogged_by,
{ name: String(status.getIn(['account', 'acct'])) },
{ name: status.account.acct },
);
}
@ -425,8 +424,8 @@ const Status: React.FC<IStatus> = (props) => {
{renderStatusInfo()}
<AccountContainer
key={String(actualStatus.getIn(['account', 'id']))}
id={String(actualStatus.getIn(['account', 'id']))}
key={actualStatus.account.id}
id={actualStatus.account.id}
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
action={accountAction}

@ -35,7 +35,7 @@ interface ISensitiveContentOverlay {
const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveContentOverlay>((props, ref) => {
const { onToggleVisibility, status } = props;
const account = useOwnAccount();
const { account } = useOwnAccount();
const dispatch = useAppDispatch();
const intl = useIntl();
const settings = useSettings();

@ -6,7 +6,7 @@ import { useStatContext } from 'soapbox/contexts/stat-context';
import { useAppSelector, useFeatures, useGroupsPath, useOwnAccount } from 'soapbox/hooks';
const ThumbNavigation: React.FC = (): JSX.Element => {
const account = useOwnAccount();
const { account } = useOwnAccount();
const features = useFeatures();
const groupsPath = useGroupsPath();

@ -27,6 +27,7 @@ interface ICard {
className?: string
/** Elements inside the card. */
children: React.ReactNode
tabIndex?: number
}
/** An opaque backdrop to hold a collection of related elements. */

@ -1,17 +1,15 @@
import React, { useCallback } from 'react';
import React from 'react';
import { useAppSelector } from 'soapbox/hooks';
import Account, { IAccount } from '../components/account';
import { makeGetAccount } from '../selectors';
import { useAccount } from 'soapbox/api/hooks';
import Account, { IAccount } from 'soapbox/components/account';
interface IAccountContainer extends Omit<IAccount, 'account'> {
id: string
withRelationship?: boolean
}
const AccountContainer: React.FC<IAccountContainer> = ({ id, ...props }) => {
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector(state => getAccount(state, id));
const AccountContainer: React.FC<IAccountContainer> = ({ id, withRelationship, ...props }) => {
const { account } = useAccount(id, { withRelationship });
return (
<Account account={account!} {...props} />

@ -90,12 +90,12 @@ const SoapboxMount = () => {
const me = useAppSelector(state => state.me);
const instance = useInstance();
const account = useOwnAccount();
const { account } = useOwnAccount();
const soapboxConfig = useSoapboxConfig();
const features = useFeatures();
const { pepeEnabled } = useRegistrationStatus();
const waitlisted = account && !account.source.get('approved', true);
const waitlisted = account && account.source?.approved === false;
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
const showOnboarding = account && !waitlisted && needsOnboarding;
const { redirectRootNoLogin } = soapboxConfig;
@ -216,7 +216,7 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
const dispatch = useAppDispatch();
const me = useAppSelector(state => state.me);
const account = useOwnAccount();
const { account } = useOwnAccount();
const swUpdating = useAppSelector(state => state.meta.swUpdating);
const { locale } = useLocale();

@ -27,7 +27,7 @@ const ChatProvider: React.FC<IChatProvider> = ({ children }) => {
const history = useHistory();
const dispatch = useAppDispatch();
const settings = useSettings();
const account = useOwnAccount();
const { account } = useOwnAccount();
const path = history.location.pathname;
const isUsingMainChatPage = Boolean(path.match(/^\/chats/));

@ -1,4 +1,4 @@
import type { Entity, EntityListState, ImportPosition } from './types';
import type { EntitiesTransaction, Entity, EntityListState, ImportPosition } from './types';
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
@ -8,6 +8,7 @@ const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const;
const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const;
const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
const ENTITIES_TRANSACTION = 'ENTITIES_TRANSACTION' as const;
/** Action to import entities into the cache. */
function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) {
@ -95,6 +96,13 @@ function invalidateEntityList(entityType: string, listKey: string) {
};
}
function entitiesTransaction(transaction: EntitiesTransaction) {
return {
type: ENTITIES_TRANSACTION,
transaction,
};
}
/** Any action pertaining to entities. */
type EntityAction =
ReturnType<typeof importEntities>
@ -104,7 +112,8 @@ type EntityAction =
| ReturnType<typeof entitiesFetchRequest>
| ReturnType<typeof entitiesFetchSuccess>
| ReturnType<typeof entitiesFetchFail>
| ReturnType<typeof invalidateEntityList>;
| ReturnType<typeof invalidateEntityList>
| ReturnType<typeof entitiesTransaction>;
export {
ENTITIES_IMPORT,
@ -115,6 +124,7 @@ export {
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
ENTITIES_INVALIDATE_LIST,
ENTITIES_TRANSACTION,
importEntities,
deleteEntities,
dismissEntities,
@ -123,7 +133,7 @@ export {
entitiesFetchSuccess,
entitiesFetchFail,
invalidateEntityList,
EntityAction,
entitiesTransaction,
};
export type { DeleteEntitiesOpts };
export type { DeleteEntitiesOpts, EntityAction };

@ -1,9 +1,26 @@
export enum Entities {
import type * as Schemas from 'soapbox/schemas';
enum Entities {
ACCOUNTS = 'Accounts',
GROUPS = 'Groups',
GROUP_MEMBERSHIPS = 'GroupMemberships',
GROUP_MUTES = 'GroupMutes',
GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_TAGS = 'GroupTags',
PATRON_USERS = 'PatronUsers',
RELATIONSHIPS = 'Relationships',
STATUSES = 'Statuses'
}
}
interface EntityTypes {
[Entities.ACCOUNTS]: Schemas.Account
[Entities.GROUPS]: Schemas.Group
[Entities.GROUP_MEMBERSHIPS]: Schemas.GroupMember
[Entities.GROUP_RELATIONSHIPS]: Schemas.GroupRelationship
[Entities.GROUP_TAGS]: Schemas.GroupTag
[Entities.PATRON_USERS]: Schemas.PatronUser
[Entities.RELATIONSHIPS]: Schemas.Relationship
[Entities.STATUSES]: Schemas.Status
}
export { Entities, type EntityTypes };

@ -5,4 +5,6 @@ export { useEntityLookup } from './useEntityLookup';
export { useCreateEntity } from './useCreateEntity';
export { useDeleteEntity } from './useDeleteEntity';
export { useDismissEntity } from './useDismissEntity';
export { useIncrementEntity } from './useIncrementEntity';
export { useIncrementEntity } from './useIncrementEntity';
export { useChangeEntity } from './useChangeEntity';
export { useTransaction } from './useTransaction';

@ -0,0 +1,103 @@
import { useEffect } from 'react';
import { z } from 'zod';
import { useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks';
import { filteredArray } from 'soapbox/schemas/utils';
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions';
import { selectCache, selectListState, useListState } from '../selectors';
import { parseEntitiesPath } from './utils';
import type { Entity } from '../types';
import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
import type { RootState } from 'soapbox/store';
interface UseBatchedEntitiesOpts<TEntity extends Entity> {
schema?: EntitySchema<TEntity>
enabled?: boolean
}
function useBatchedEntities<TEntity extends Entity>(
expandedPath: ExpandedEntitiesPath,
ids: string[],
entityFn: EntityFn<string[]>,
opts: UseBatchedEntitiesOpts<TEntity> = {},
) {
const getState = useGetState();
const dispatch = useAppDispatch();
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
const schema = opts.schema || z.custom<TEntity>();
const isEnabled = opts.enabled ?? true;
const isFetching = useListState(path, 'fetching');
const lastFetchedAt = useListState(path, 'lastFetchedAt');
const isFetched = useListState(path, 'fetched');
const isInvalid = useListState(path, 'invalid');
const error = useListState(path, 'error');
/** Get IDs of entities not yet in the store. */
const filteredIds = useAppSelector((state) => {
const cache = selectCache(state, path);
if (!cache) return ids;
return ids.filter((id) => !cache.store[id]);
});
const entityMap = useAppSelector((state) => selectEntityMap<TEntity>(state, path, ids));
async function fetchEntities() {
const isFetching = selectListState(getState(), path, 'fetching');
if (isFetching) return;
dispatch(entitiesFetchRequest(entityType, listKey));
try {
const response = await entityFn(filteredIds);
const entities = filteredArray(schema).parse(response.data);
dispatch(entitiesFetchSuccess(entities, entityType, listKey, 'end', {
next: undefined,
prev: undefined,
totalCount: undefined,
fetching: false,
fetched: true,
error: null,
lastFetchedAt: new Date(),
invalid: false,
}));
} catch (e) {
dispatch(entitiesFetchFail(entityType, listKey, e));
}
}
useEffect(() => {
if (filteredIds.length && isEnabled) {
fetchEntities();
}
}, [filteredIds.length]);
return {
entityMap,
isFetching,
lastFetchedAt,
isFetched,
isError: !!error,
isInvalid,
};
}
function selectEntityMap<TEntity extends Entity>(
state: RootState,
path: EntitiesPath,
entityIds: string[],
): Record<string, TEntity> {
const cache = selectCache(state, path);
return entityIds.reduce<Record<string, TEntity>>((result, id) => {
const entity = cache?.store[id];
if (entity) {
result[id] = entity as TEntity;
}
return result;
}, {});
}
export { useBatchedEntities };

@ -0,0 +1,24 @@
import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { type Entity } from 'soapbox/entity-store/types';
import { useAppDispatch, useGetState } from 'soapbox/hooks';
type ChangeEntityFn<TEntity extends Entity> = (entity: TEntity) => TEntity
function useChangeEntity<TEntity extends Entity = Entity>(entityType: Entities) {
const getState = useGetState();
const dispatch = useAppDispatch();
function changeEntity(entityId: string, change: ChangeEntityFn<TEntity>): void {
if (!entityId) return;
const entity = getState().entities[entityType]?.store[entityId] as TEntity | undefined;
if (entity) {
const newEntity = change(entity);
dispatch(importEntities([newEntity], entityType));
}
}
return { changeEntity };
}
export { useChangeEntity, type ChangeEntityFn };

@ -7,12 +7,12 @@ import { filteredArray } from 'soapbox/schemas/utils';
import { realNumberSchema } from 'soapbox/utils/numbers';
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions';
import { selectEntities, selectListState, useListState } from '../selectors';
import { parseEntitiesPath } from './utils';
import type { Entity, EntityListState } from '../types';
import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
import type { RootState } from 'soapbox/store';
import type { Entity } from '../types';
import type { EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
/** Additional options for the hook. */
interface UseEntitiesOpts<TEntity extends Entity> {
@ -42,6 +42,7 @@ function useEntities<TEntity extends Entity>(
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
const schema = opts.schema || z.custom<TEntity>();
const isEnabled = opts.enabled ?? true;
const isFetching = useListState(path, 'fetching');
@ -62,7 +63,6 @@ function useEntities<TEntity extends Entity>(
dispatch(entitiesFetchRequest(entityType, listKey));
try {
const response = await req();
const schema = opts.schema || z.custom<TEntity>();
const entities = filteredArray(schema).parse(response.data);
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
const totalCount = parsedCount.success ? parsedCount.data : undefined;
@ -133,46 +133,6 @@ function useEntities<TEntity extends Entity>(
};
}
/** Get cache at path from Redux. */
const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]];
/** Get list at path from Redux. */
const selectList = (state: RootState, path: EntitiesPath) => {
const [, ...listKeys] = path;
const listKey = listKeys.join(':');
return selectCache(state, path)?.lists[listKey];
};
/** Select a particular item from a list state. */
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntitiesPath, key: K) {
const listState = selectList(state, path)?.state;
return listState ? listState[key] : undefined;
}
/** Hook to get a particular item from a list state. */
function useListState<K extends keyof EntityListState>(path: EntitiesPath, key: K) {
return useAppSelector(state => selectListState(state, path, key));
}
/** Get list of entities from Redux. */
function selectEntities<TEntity extends Entity>(state: RootState, path: EntitiesPath): readonly TEntity[] {
const cache = selectCache(state, path);
const list = selectList(state, path);
const entityIds = list?.ids;
return entityIds ? (
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
const entity = cache?.store[id];
if (entity) {
result.push(entity as TEntity);
}
return result;
}, [])
) : [];
}
export {
useEntities,
};

@ -26,7 +26,7 @@ function useEntityActions<TEntity extends Entity = Entity, Data = any>(
const { entityType, path } = parseEntitiesPath(expandedPath);
const { deleteEntity, isSubmitting: deleteSubmitting } =
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replace(/:id/g, entityId)));
const { createEntity, isSubmitting: createSubmitting } =
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);

@ -0,0 +1,23 @@
import { entitiesTransaction } from 'soapbox/entity-store/actions';
import { useAppDispatch } from 'soapbox/hooks';
import type { EntityTypes } from 'soapbox/entity-store/entities';
import type { EntitiesTransaction, Entity } from 'soapbox/entity-store/types';
type Updater<TEntity extends Entity> = Record<string, (entity: TEntity) => TEntity>
type Changes = Partial<{
[K in keyof EntityTypes]: Updater<EntityTypes[K]>
}>
function useTransaction() {
const dispatch = useAppDispatch();
function transaction(changes: Changes): void {
dispatch(entitiesTransaction(changes as EntitiesTransaction));
}
return { transaction };
}
export { useTransaction };

@ -10,11 +10,12 @@ import {
EntityAction,
ENTITIES_INVALIDATE_LIST,
ENTITIES_INCREMENT,
ENTITIES_TRANSACTION,
} from './actions';
import { createCache, createList, updateStore, updateList } from './utils';
import type { DeleteEntitiesOpts } from './actions';
import type { Entity, EntityCache, EntityListState, ImportPosition } from './types';
import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types';
enableMapSet();
@ -156,6 +157,20 @@ const invalidateEntityList = (state: State, entityType: string, listKey: string)
});
};
const doTransaction = (state: State, transaction: EntitiesTransaction) => {
return produce(state, draft => {
for (const [entityType, changes] of Object.entries(transaction)) {
const cache = draft[entityType] ?? createCache();
for (const [id, change] of Object.entries(changes)) {
const entity = cache.store[id];
if (entity) {
cache.store[id] = change(entity);
}
}
}
});
};
/** Stores various entity data and lists in a one reducer. */
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
switch (action.type) {
@ -175,6 +190,8 @@ function reducer(state: Readonly<State> = {}, action: EntityAction): State {
return setFetching(state, action.entityType, action.listKey, false, action.error);
case ENTITIES_INVALIDATE_LIST:
return invalidateEntityList(state, action.entityType, action.listKey);
case ENTITIES_TRANSACTION:
return doTransaction(state, action.transaction);
default:
return state;
}

@ -0,0 +1,53 @@
import { useAppSelector } from 'soapbox/hooks';
import type { EntitiesPath } from './hooks/types';
import type { Entity, EntityListState } from './types';
import type { RootState } from 'soapbox/store';
/** Get cache at path from Redux. */
const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]];
/** Get list at path from Redux. */
const selectList = (state: RootState, path: EntitiesPath) => {
const [, ...listKeys] = path;
const listKey = listKeys.join(':');
return selectCache(state, path)?.lists[listKey];
};
/** Select a particular item from a list state. */
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntitiesPath, key: K) {
const listState = selectList(state, path)?.state;
return listState ? listState[key] : undefined;
}
/** Hook to get a particular item from a list state. */
function useListState<K extends keyof EntityListState>(path: EntitiesPath, key: K) {
return useAppSelector(state => selectListState(state, path, key));
}
/** Get list of entities from Redux. */
function selectEntities<TEntity extends Entity>(state: RootState, path: EntitiesPath): readonly TEntity[] {
const cache = selectCache(state, path);
const list = selectList(state, path);
const entityIds = list?.ids;
return entityIds ? (
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
const entity = cache?.store[id];
if (entity) {
result.push(entity as TEntity);
}
return result;
}, [])
) : [];
}
export {
selectCache,
selectList,
selectListState,
useListState,
selectEntities,
};

@ -26,7 +26,7 @@ interface EntityListState {
/** Total number of items according to the API. */
totalCount: number | undefined
/** Error returned from the API, if any. */
error: any
error: unknown
/** Whether data has already been fetched */
fetched: boolean
/** Whether data for this list is currently being fetched. */
@ -50,11 +50,19 @@ interface EntityCache<TEntity extends Entity = Entity> {
/** Whether to import items at the start or end of the list. */
type ImportPosition = 'start' | 'end'
export {
/** Map of entity mutation functions to perform at once on the store. */
interface EntitiesTransaction {
[entityType: string]: {
[entityId: string]: <TEntity extends Entity>(entity: TEntity) => TEntity
}
}
export type {
Entity,
EntityStore,
EntityList,
EntityListState,
EntityCache,
ImportPosition,
EntitiesTransaction,
};

@ -2,17 +2,14 @@ import React, { useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router-dom';
import {
fetchAccount,
fetchAccountByUsername,
} from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import { expandAccountMediaTimeline } from 'soapbox/actions/timelines';
import { useAccountLookup } from 'soapbox/api/hooks';
import LoadMore from 'soapbox/components/load-more';
import MissingIndicator from 'soapbox/components/missing-indicator';
import { Column, Spinner } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { getAccountGallery, findAccountByUsername } from 'soapbox/selectors';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { getAccountGallery } from 'soapbox/selectors';
import MediaItem from './components/media-item';
@ -37,33 +34,17 @@ const LoadMoreMedia: React.FC<ILoadMoreMedia> = ({ maxId, onLoadMore }) => {
const AccountGallery = () => {
const dispatch = useAppDispatch();
const { username } = useParams<{ username: string }>();
const features = useFeatures();
const { accountId, unavailable, accountUsername } = useAppSelector((state) => {
const me = state.me;
const accountFetchError = (state.accounts.get(-1)?.username || '').toLowerCase() === username.toLowerCase();
const {
account,
isLoading: accountLoading,
isUnavailable,
} = useAccountLookup(username, { withRelationship: true });
let accountId: string | -1 | null = -1;
let accountUsername = username;
if (accountFetchError) {
accountId = null;
} else {
const account = findAccountByUsername(state, username);
accountId = account ? (account.id || null) : -1;
accountUsername = account?.acct || '';
}
const isBlocked = state.relationships.get(String(accountId))?.blocked_by || false;
return {
accountId,
unavailable: (me === accountId) ? false : (isBlocked && !features.blockersVisible),
accountUsername,
};
});
const isAccount = useAppSelector((state) => !!state.accounts.get(accountId));
const attachments: ImmutableList<Attachment> = useAppSelector((state) => getAccountGallery(state, accountId as string));
const isLoading = useAppSelector((state) => state.timelines.get(`account:${accountId}:media`)?.isLoading);
const hasMore = useAppSelector((state) => state.timelines.get(`account:${accountId}:media`)?.hasMore);
const attachments: ImmutableList<Attachment> = useAppSelector((state) => getAccountGallery(state, account!.id));
const isLoading = useAppSelector((state) => state.timelines.get(`account:${account?.id}:media`)?.isLoading);
const hasMore = useAppSelector((state) => state.timelines.get(`account:${account?.id}:media`)?.hasMore);
const next = useAppSelector(state => state.timelines.get(`account:${account?.id}:media`)?.next);
const node = useRef<HTMLDivElement>(null);
@ -74,8 +55,8 @@ const AccountGallery = () => {
};
const handleLoadMore = (maxId: string | null) => {
if (accountId && accountId !== -1) {
dispatch(expandAccountMediaTimeline(accountId, { maxId }));
if (account) {
dispatch(expandAccountMediaTimeline(account.id, { url: next, maxId }));
}
};
@ -96,25 +77,22 @@ const AccountGallery = () => {
};
useEffect(() => {
if (accountId && accountId !== -1) {
dispatch(fetchAccount(accountId));
dispatch(expandAccountMediaTimeline(accountId));
} else {
dispatch(fetchAccountByUsername(username));
if (account) {
dispatch(expandAccountMediaTimeline(account.id));
}
}, [accountId]);
}, [account?.id]);
if (!isAccount && accountId !== -1) {
if (accountLoading || (!attachments && isLoading)) {
return (
<MissingIndicator />
<Column>
<Spinner />
</Column>
);
}
if (accountId === -1 || (!attachments && isLoading)) {
if (!account) {
return (
<Column>
<Spinner />
</Column>
<MissingIndicator />
);
}
@ -124,7 +102,7 @@ const AccountGallery = () => {
loadOlder = <LoadMore className='my-auto' visible={!isLoading} onClick={handleLoadOlder} />;
}
if (unavailable) {
if (isUnavailable) {
return (
<Column>
<div className='empty-column-indicator'>
@ -135,7 +113,7 @@ const AccountGallery = () => {
}
return (
<Column label={`@${accountUsername}`} transparent withHeader={false}>
<Column label={`@${account.acct}`} transparent withHeader={false}>
<div role='feed' className='grid grid-cols-2 gap-2 sm:grid-cols-3' ref={node}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.get(index + 1)?.id} maxId={index > 0 ? (attachments.get(index - 1)?.id || null) : null} onLoadMore={handleLoadMore} />

@ -5,7 +5,7 @@ import Account from 'soapbox/components/account';
import Icon from 'soapbox/components/icon';
import { HStack, Text } from 'soapbox/components/ui';
import type { Account as AccountEntity } from 'soapbox/types/entities';
import type { Account as AccountEntity } from 'soapbox/schemas';
interface IMovedNote {
from: AccountEntity

@ -5,11 +5,12 @@ import { useHistory } from 'react-router-dom';
import { fetchAccountByUsername } from 'soapbox/actions/accounts';
import { fetchPatronAccount } from 'soapbox/actions/patron';
import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'soapbox/actions/timelines';
import { useAccountLookup } from 'soapbox/api/hooks';
import MissingIndicator from 'soapbox/components/missing-indicator';
import StatusList from 'soapbox/components/status-list';
import { Card, CardBody, Spinner, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { makeGetStatusIds, findAccountByUsername } from 'soapbox/selectors';
import { makeGetStatusIds } from 'soapbox/selectors';
const getStatusIds = makeGetStatusIds();
@ -27,7 +28,7 @@ const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = fal
const settings = useSettings();
const soapboxConfig = useSoapboxConfig();
const account = useAppSelector(state => findAccountByUsername(state, params.username));
const { account } = useAccountLookup(params.username, { withRelationship: true });
const [accountLoading, setAccountLoading] = useState<boolean>(!account);
const path = withReplies ? `${account?.id}:with_replies` : account?.id;
@ -40,6 +41,7 @@ const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = fal
const patronEnabled = soapboxConfig.getIn(['extensions', 'patron', 'enabled']) === true;
const isLoading = useAppSelector(state => state.getIn(['timelines', `account:${path}`, 'isLoading']) === true);
const hasMore = useAppSelector(state => state.getIn(['timelines', `account:${path}`, 'hasMore']) === true);
const next = useAppSelector(state => state.timelines.get(`account:${path}`)?.next);
const accountUsername = account?.username || params.username;
@ -69,7 +71,7 @@ const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = fal
const handleLoadMore = (maxId: string) => {
if (account) {
dispatch(expandAccountTimeline(account.id, { maxId, withReplies }));
dispatch(expandAccountTimeline(account.id, { url: next, maxId, withReplies }));
}
};

@ -7,7 +7,7 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { blockAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { mentionCompose, directCompose } from 'soapbox/actions/compose';
import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks';
import { openModal } from 'soapbox/actions/modals';
@ -15,6 +15,7 @@ import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { setSearchAccount } from 'soapbox/actions/search';
import { getSettings } from 'soapbox/actions/settings';
import { useFollow } from 'soapbox/api/hooks';
import Badge from 'soapbox/components/badge';
import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu';
import StillImage from 'soapbox/components/still-image';
@ -27,8 +28,8 @@ import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soap
import { normalizeAttachment } from 'soapbox/normalizers';
import { ChatKeys, useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { Account } from 'soapbox/schemas';
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';
@ -86,7 +87,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
const dispatch = useAppDispatch();
const features = useFeatures();
const ownAccount = useOwnAccount();
const { account: ownAccount } = useOwnAccount();
const { follow } = useFollow();
const { software } = useAppSelector((state) => parseVersion(state.instance.version));
@ -154,9 +156,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
const onReblogToggle = () => {
if (account.relationship?.showing_reblogs) {
dispatch(followAccount(account.id, { reblogs: false }));
follow(account.id, { reblogs: false });
} else {
dispatch(followAccount(account.id, { reblogs: true }));
follow(account.id, { reblogs: true });
}
};
@ -574,7 +576,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
disabled={createAndNavigateToChat.isLoading}
/>
);
} else if (account.getIn(['pleroma', 'accepts_chat_messages']) === true) {
} else if (account.pleroma?.accepts_chat_messages) {
return (
<IconButton
src={require('@tabler/icons/messages.svg')}
@ -615,7 +617,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
return (
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'>
{(account.moved && typeof account.moved === 'object') && (
<MovedNote from={account} to={account.moved} />
<MovedNote from={account} to={account.moved as Account} />
)}
<div>

@ -1,10 +1,10 @@
import React, { useCallback } from 'react';
import React from 'react';
import { approveUsers, deleteUsers } from 'soapbox/actions/admin';
import { useAccount } from 'soapbox/api/hooks';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
import { Stack, HStack, Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
interface IUnapprovedAccount {
accountId: string
@ -13,9 +13,8 @@ interface IUnapprovedAccount {
/** Displays an unapproved account for moderation purposes. */
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector(state => getAccount(state, accountId));
const { account } = useAccount(accountId);
const adminAccount = useAppSelector(state => state.admin.users.get(accountId));
if (!account) return null;
@ -27,7 +26,7 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
<HStack space={4} justifyContent='between'>
<Stack space={1}>
<Text weight='semibold'>
@{account.get('acct')}
@{account.acct}
</Text>
<Text tag='blockquote' size='sm'>
{adminAccount?.invite_request || ''}

@ -16,7 +16,7 @@ const messages = defineMessages({
const Admin: React.FC = () => {
const intl = useIntl();
const account = useOwnAccount();
const { account } = useOwnAccount();
if (!account) return null;

@ -18,7 +18,7 @@ const Dashboard: React.FC = () => {
const history = useHistory();
const instance = useInstance();
const features = useFeatures();
const account = useOwnAccount();
const { account } = useOwnAccount();
const handleSubscribersClick: React.MouseEventHandler = e => {
dispatch(getSubscribersCsv()).then(({ data }) => {

@ -1,14 +1,12 @@
import React, { useCallback } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { addToAliases } from 'soapbox/actions/aliases';
import { useAccount } from 'soapbox/api/hooks';
import AccountComponent from 'soapbox/components/account';
import IconButton from 'soapbox/components/icon-button';
import { HStack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
import type { List as ImmutableList } from 'immutable';
const messages = defineMessages({
add: { id: 'aliases.account.add', defaultMessage: 'Create alias' },
@ -16,7 +14,7 @@ const messages = defineMessages({
interface IAccount {
accountId: string
aliases: ImmutableList<string>
aliases: string[]
}
const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
@ -24,17 +22,12 @@ const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
const dispatch = useAppDispatch();
const features = useFeatures();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
const me = useAppSelector((state) => state.me);
const { account } = useAccount(accountId);
const added = useAppSelector((state) => {
const account = getAccount(state, accountId);
const apId = account?.pleroma.get('ap_id');
const name = features.accountMoving ? account?.acct : apId;
return aliases.includes(name);
});
const apId = account?.pleroma?.ap_id;
const name = features.accountMoving ? account?.acct : apId;
const added = name ? aliases.includes(name) : false;
const handleOnAdd = () => dispatch(addToAliases(account!));

@ -1,4 +1,3 @@
import { List as ImmutableList } from 'immutable';
import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -24,15 +23,15 @@ const Aliases = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const features = useFeatures();
const account = useOwnAccount();
const { account } = useOwnAccount();
const aliases = useAppSelector((state) => {
if (features.accountMoving) {
return state.aliases.aliases.items;
return [...state.aliases.aliases.items];
} else {
return account!.pleroma.get('also_known_as');
return account?.pleroma?.also_known_as ?? [];
}
}) as ImmutableList<string>;
});
const searchAccountIds = useAppSelector((state) => state.aliases.suggestions.items);
const loaded = useAppSelector((state) => state.aliases.suggestions.loaded);

@ -26,7 +26,7 @@ const AuthLayout = () => {
const history = useHistory();
const { search } = useLocation();
const account = useOwnAccount();
const { account } = useOwnAccount();
const instance = useInstance();
const { isOpen } = useRegistrationStatus();
const isLoginPage = history.location.pathname === '/login';

@ -1,11 +1,10 @@
import React, { useCallback } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useAccount } from 'soapbox/api/hooks';
import AccountComponent from 'soapbox/components/account';
import Icon from 'soapbox/components/icon';
import { HStack } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const messages = defineMessages({
birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' },
@ -17,13 +16,11 @@ interface IAccount {
const Account: React.FC<IAccount> = ({ accountId }) => {
const intl = useIntl();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
const { account } = useAccount(accountId);
if (!account) return null;
const birthday = account.birthday;
const birthday = account.pleroma?.birthday;
if (!birthday) return null;
const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' });

@ -1,33 +1,26 @@
import debounce from 'lodash/debounce';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchBlocks, expandBlocks } from 'soapbox/actions/blocks';
import { useBlocks } from 'soapbox/api/hooks';
import Account from 'soapbox/components/account';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Column, Spinner } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
heading: { id: 'column.blocks', defaultMessage: 'Blocks' },
});
const handleLoadMore = debounce((dispatch) => {
dispatch(expandBlocks());
}, 300, { leading: true });
const Blocks: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const accountIds = useAppSelector((state) => state.user_lists.blocks.items);
const hasMore = useAppSelector((state) => !!state.user_lists.blocks.next);
React.useEffect(() => {
dispatch(fetchBlocks());
}, []);
const {
accounts,
hasNextPage,
fetchNextPage,
isLoading,
} = useBlocks();
if (!accountIds) {
if (isLoading) {
return (
<Column>
<Spinner />
@ -41,14 +34,15 @@ const Blocks: React.FC = () => {
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='blocks'
onLoadMore={() => handleLoadMore(dispatch)}
hasMore={hasMore}
onLoadMore={fetchNextPage}
hasMore={hasNextPage}
emptyMessage={emptyMessage}
itemClassName='pb-4'
emptyMessageCard={false}
itemClassName='pb-4 last:pb-0'
>
{accountIds.map((id) =>
<AccountContainer key={id} id={id} actionType='blocking' />,
)}
{accounts.map((account) => (
<Account key={account.id} account={account} actionType='blocking' />
))}
</ScrollableList>
</Column>
);

@ -3,8 +3,8 @@ import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { ChatContext } from 'soapbox/contexts/chat-context';
import { buildAccount } from 'soapbox/jest/factory';
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';
import { IAccount } from 'soapbox/queries/accounts';
import { ChatMessage } from 'soapbox/types/entities';
import { __stub } from '../../../../api';
@ -14,7 +14,7 @@ import ChatMessageList from '../chat-message-list';
const chat: IChat = {
accepted: true,
account: {
account: buildAccount({
username: 'username',
verified: true,
id: '1',
@ -22,7 +22,7 @@ const chat: IChat = {
avatar: 'avatar',
avatar_static: 'avatar',
display_name: 'my name',
} as IAccount,
}),
chat_type: 'direct',
created_at: '2020-06-10T02:05:06.000Z',
created_by_account: '2',

@ -1,26 +1,32 @@
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { normalizeAccount } from 'soapbox/normalizers';
import { buildAccount } from 'soapbox/jest/factory';
import { render, rootState } from '../../../../jest/test-helpers';
import ChatWidget from '../chat-widget/chat-widget';
const id = '1';
const account = normalizeAccount({
const account = buildAccount({
id,
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: true,
source: {
chats_onboarded: true,
},
});
const store = rootState
.set('me', id)
.set('accounts', ImmutableMap({
[id]: account,
}) as any);
.set('entities', {
'ACCOUNTS': {
store: {
[id]: account,
},
lists: {},
},
});
describe('<ChatWidget />', () => {
describe('when on the /chats endpoint', () => {
@ -43,28 +49,35 @@ describe('<ChatWidget />', () => {
});
});
describe('when the user has not onboarded chats', () => {
it('hides the widget', async () => {
const accountWithoutChats = normalizeAccount({
id,
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: false,
});
const newStore = store.set('accounts', ImmutableMap({
[id]: accountWithoutChats,
}) as any);
// describe('when the user has not onboarded chats', () => {
// it('hides the widget', async () => {
// const accountWithoutChats = buildAccount({
// id,
// acct: 'justin-username',
// display_name: 'Justin L',
// avatar: 'test.jpg',
// source: {
// chats_onboarded: false,
// },
// });
// const newStore = store.set('entities', {
// 'ACCOUNTS': {
// store: {
// [id]: accountWithoutChats,
// },
// lists: {},
// },
// });
const screen = render(
<ChatWidget />,
{},
newStore,
);
// const screen = render(
// <ChatWidget />,
// {},
// newStore,
// );
expect(screen.queryAllByTestId('pane')).toHaveLength(0);
});
});
// expect(screen.queryAllByTestId('pane')).toHaveLength(0);
// });
// });
describe('when the user is onboarded and the endpoint is not /chats', () => {
it('shows the widget', async () => {

@ -69,7 +69,7 @@ interface IChatMessageList {
/** Scrollable list of chat messages. */
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const intl = useIntl();
const account = useOwnAccount();
const { account } = useOwnAccount();
const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date;
const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null;
@ -109,9 +109,13 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
return [];
}
const currentYear = new Date().getFullYear();
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
const lastMessage = formattedChatMessages[idx - 1];
const messageDate = new Date(curr.created_at);
if (lastMessage) {
switch (timeChange(lastMessage, curr)) {
case 'today':
@ -123,7 +127,14 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
case 'date':
acc.push({
type: 'divider',
text: intl.formatDate(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' }),
text: intl.formatDate(messageDate, {
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
month: 'short',
day: 'numeric',
year: messageDate.getFullYear() !== currentYear ? '2-digit' : undefined,
}),
});
break;
}

@ -13,7 +13,6 @@ import emojify from 'soapbox/features/emoji';
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { normalizeAccount } from 'soapbox/normalizers';
import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { stripHTML } from 'soapbox/utils/html';
@ -24,7 +23,7 @@ import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-mes
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
import type { IMediaGallery } from 'soapbox/components/media-gallery';
import type { Account, ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const messages = defineMessages({
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
@ -178,7 +177,7 @@ const ChatMessage = (props: IChatMessage) => {
if (features.reportChats) {
menu.push({
text: intl.formatMessage(messages.report),
action: () => dispatch(initReport(ReportableEntities.CHAT_MESSAGE, normalizeAccount(chat.account) as Account, { chatMessage })),
action: () => dispatch(initReport(ReportableEntities.CHAT_MESSAGE, chat.account, { chatMessage })),
icon: require('@tabler/icons/flag.svg'),
});
}

@ -1,90 +1,94 @@
import userEvent from '@testing-library/user-event';
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { __stub } from 'soapbox/api';
import { normalizeAccount } from 'soapbox/normalizers';
import { ReducerAccount } from 'soapbox/reducers/accounts';
import { render, screen, waitFor } from '../../../../../jest/test-helpers';
import ChatPage from '../chat-page';
describe('<ChatPage />', () => {
let store: any;
describe('before you finish onboarding', () => {
it('renders the Welcome component', () => {
render(<ChatPage />);
expect(screen.getByTestId('chats-welcome')).toBeInTheDocument();
});
describe('when you complete onboarding', () => {
const id = '1';
beforeEach(() => {
store = {
me: id,
accounts: ImmutableMap({
[id]: normalizeAccount({
id,
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: false,
}) as ReducerAccount,
}),
};
__stub((mock) => {
mock
.onPatch('/api/v1/accounts/update_credentials')
.reply(200, { chats_onboarded: true, id });
});
});
it('renders the Chats', async () => {
render(<ChatPage />, undefined, store);
await userEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('chat-page')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings updated successfully');
});
});
});
describe('when the API returns an error', () => {
beforeEach(() => {
store = {
me: '1',
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: false,
}) as ReducerAccount,
}),
};
__stub((mock) => {
mock
.onPatch('/api/v1/accounts/update_credentials')
.networkError();
});
});
it('renders the Chats', async () => {
render(<ChatPage />, undefined, store);
await userEvent.click(screen.getByTestId('button'));
await waitFor(() => {
expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings failed to update.');
});
});
});
});
});
test.skip('skip', () => {});
// import userEvent from '@testing-library/user-event';
// import React from 'react';
// import { __stub } from 'soapbox/api';
// import { buildAccount } from 'soapbox/jest/factory';
// import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
// import ChatPage from '../chat-page';
// describe('<ChatPage />', () => {
// let store: any;
// describe('before you finish onboarding', () => {
// it('renders the Welcome component', () => {
// render(<ChatPage />);
// expect(screen.getByTestId('chats-welcome')).toBeInTheDocument();
// });
// describe('when you complete onboarding', () => {
// const id = '1';
// beforeEach(() => {
// store = {
// me: id,
// accounts: {
// [id]: buildAccount({
// id,
// acct: 'justin-username',
// display_name: 'Justin L',
// avatar: 'test.jpg',
// source: {
// chats_onboarded: false,
// },
// }),
// },
// };
// __stub((mock) => {
// mock
// .onPatch('/api/v1/accounts/update_credentials')
// .reply(200, { chats_onboarded: true, id });
// });
// });
// it('renders the Chats', async () => {
// render(<ChatPage />, undefined, store);
// await userEvent.click(screen.getByTestId('button'));
// expect(screen.getByTestId('chat-page')).toBeInTheDocument();
// await waitFor(() => {
// expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings updated successfully');
// });
// });
// });
// describe('when the API returns an error', () => {
// beforeEach(() => {
// store = {
// me: '1',
// accounts: {
// '1': buildAccount({
// id: '1',
// acct: 'justin-username',
// display_name: 'Justin L',
// avatar: 'test.jpg',
// source: {
// chats_onboarded: false,
// },
// }),
// },
// };
// __stub((mock) => {
// mock
// .onPatch('/api/v1/accounts/update_credentials')
// .networkError();
// });
// });
// it('renders the Chats', async () => {
// render(<ChatPage />, undefined, store);
// await userEvent.click(screen.getByTestId('button'));
// await waitFor(() => {
// expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings failed to update.');
// });
// });
// });
// });
// });

@ -16,10 +16,10 @@ interface IChatPage {
}
const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
const account = useOwnAccount();
const { account } = useOwnAccount();
const history = useHistory();
const isOnboarded = account?.chats_onboarded;
const isOnboarded = account?.source?.chats_onboarded ?? true;
const path = history.location.pathname;
const isSidebarHidden = matchPath(path, {

@ -24,7 +24,7 @@ const messages = defineMessages({
});
const ChatPageSettings = () => {
const account = useOwnAccount();
const { account } = useOwnAccount();
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
@ -33,7 +33,7 @@ const ChatPageSettings = () => {
const [data, setData] = useState<FormData>({
chats_onboarded: true,
accepts_chat_messages: account?.accepts_chat_messages,
accepts_chat_messages: account?.pleroma?.accepts_chat_messages === true,
});
const onToggleChange = (key: string[], checked: boolean) => {

@ -20,13 +20,13 @@ const messages = defineMessages({
});
const Welcome = () => {
const account = useOwnAccount();
const { account } = useOwnAccount();
const intl = useIntl();
const updateCredentials = useUpdateCredentials();
const [data, setData] = useState<FormData>({
chats_onboarded: true,
accepts_chat_messages: account?.accepts_chat_messages,
accepts_chat_messages: account?.pleroma?.accepts_chat_messages === true,
});
const handleSubmit = (event: React.FormEvent) => {

@ -7,13 +7,14 @@ import { useOwnAccount } from 'soapbox/hooks';
import ChatPane from '../chat-pane/chat-pane';
const ChatWidget = () => {
const account = useOwnAccount();
const { account } = useOwnAccount();
const history = useHistory();
const path = history.location.pathname;
const shouldHideWidget = Boolean(path.match(/^\/chats/));
const isChatsPath = Boolean(path.match(/^\/chats/));
const isOnboarded = account?.source?.chats_onboarded ?? true;
if (!account?.chats_onboarded || shouldHideWidget) {
if (!isOnboarded || isChatsPath) {
return null;
}

@ -5,7 +5,7 @@ import { connectCommunityStream } from 'soapbox/actions/streaming';
import { expandCommunityTimeline } from 'soapbox/actions/timelines';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import { Column } from 'soapbox/components/ui';
import { useAppDispatch, useSettings } from 'soapbox/hooks';
import { useAppSelector, useAppDispatch, useSettings } from 'soapbox/hooks';
import Timeline from '../ui/components/timeline';
@ -19,11 +19,12 @@ const CommunityTimeline = () => {
const settings = useSettings();
const onlyMedia = settings.getIn(['community', 'other', 'onlyMedia']);
const next = useAppSelector(state => state.timelines.get('community')?.next);
const timelineId = 'community';
const handleLoadMore = (maxId: string) => {
dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
dispatch(expandCommunityTimeline({ url: next, maxId, onlyMedia }));
};
const handleRefresh = () => {

@ -1,30 +1,32 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
test.skip('skip', () => {});
import { __stub } from 'soapbox/api';
// import userEvent from '@testing-library/user-event';
// import React from 'react';
import { render, screen, waitFor } from '../../../../jest/test-helpers';
import Search from '../search';
// import { __stub } from 'soapbox/api';
describe('<Search />', () => {
it('successfully renders', async() => {
render(<Search autosuggest />);
expect(screen.getByLabelText('Search')).toBeInTheDocument();
});
// import { render, screen, waitFor } from '../../../../jest/test-helpers';
// import Search from '../search';
it('handles onChange', async() => {
__stub(mock => {
mock.onGet('/api/v1/accounts/search').reply(200, [{ id: 1 }]);
});
const user = userEvent.setup();
// describe('<Search />', () => {
// it('successfully renders', async() => {
// render(<Search autosuggest />);
// expect(screen.getByLabelText('Search')).toBeInTheDocument();
// });
render(<Search autosuggest />);
// it('handles onChange', async() => {
// __stub(mock => {
// mock.onGet('/api/v1/accounts/search').reply(200, [{ id: 1 }]);
// });
// const user = userEvent.setup();
await user.type(screen.getByLabelText('Search'), '@jus');
// render(<Search autosuggest />);
await waitFor(() => {
expect(screen.getByLabelText('Search')).toHaveValue('@jus');
expect(screen.getByTestId('account')).toBeInTheDocument();
});
});
});
// await user.type(screen.getByLabelText('Search'), '@jus');
// await waitFor(() => {
// expect(screen.getByLabelText('Search')).toHaveValue('@jus');
// expect(screen.getByTestId('account')).toBeInTheDocument();
// });
// });
// });

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

Loading…
Cancel
Save